Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-05-20 21:24:17 +03:00
parent c1c1ed99c7
commit c46a934bc9
5 changed files with 526 additions and 48 deletions

View File

@@ -61,6 +61,7 @@ type ProductionHasCostDetailGroupItem struct {
NOnMLNo string `json:"nOnMLNo"`
NOnMLDetNo string `json:"nOnMLDetNo"`
NHammaddeTuruNo string `json:"nHammaddeTuruNo"`
NUrtMTBolumID string `json:"nUrtMTBolumID"`
SKodu string `json:"sKodu"`
SAciklama string `json:"sAciklama"`
SRenk string `json:"sRenk"`

View File

@@ -922,12 +922,13 @@ func GetProductionHasCostDetailRowsByOnMLNo(
SUM(ISNULL(D.lMiktar, 0) * ISNULL(D.lDovizFiyati, 0)) OVER (
PARTITION BY ISNULL(NULLIF(LTRIM(RTRIM(D.sAciklama3)), ''), ISNULL(NULLIF(LTRIM(RTRIM(T.sAciklama3)), ''), N'TANIMSIZ'))
) AS GroupTotalUSDTutar,
RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nOnMLNo, 0))) AS nOnMLNo,
RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nOnMLDetNo, 0))) AS nOnMLDetNo,
RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nHammaddeTuruNo, 0))) AS nHammaddeTuruNo,
-- 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,
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,
RTRIM(CONVERT(VARCHAR(32), ISNULL(D.nUrtMTBolumID, 0))) AS nUrtMTBolumID,
-- 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,
@@ -1141,6 +1142,7 @@ HammaddeTekil AS (
ISNULL(S.sBirimCinsi1, '') AS sBirim,
ISNULL(RMik.lHMiktar, 0) AS lMiktar,
ISNULL(HT.MTnUrtMTBolumID, 0) AS MTnUrtMTBolumID,
RTRIM(CONVERT(VARCHAR(32), ISNULL(HT.MTnUrtMTBolumID, 0))) AS nUrtMTBolumID,
ISNULL(B.sAdi, '') AS sParcaAdi,
ROW_NUMBER() OVER (
PARTITION BY HT.nHammaddeTuruNo
@@ -1174,18 +1176,19 @@ HammaddeTekil AS (
AND ISNULL(B.nUrtTipiID, 0) = 1
WHERE HT.nHammaddeTuruNo IS NOT NULL
)
SELECT
HT.sAciklama3,
0.0 AS GroupTotalTutar,
0.0 AS GroupTotalUSDTutar,
'' AS nOnMLNo,
RTRIM(CONVERT(VARCHAR(32), ISNULL(HT.rowNo, 0))) AS nOnMLDetNo,
HT.nHammaddeTuruNo,
HT.sKodu,
HT.sAciklama,
HT.sRenk AS sRenk,
'' AS sBeden,
'' AS sAciklama2,
SELECT
HT.sAciklama3,
0.0 AS GroupTotalTutar,
0.0 AS GroupTotalUSDTutar,
'' AS nOnMLNo,
RTRIM(CONVERT(VARCHAR(32), ISNULL(HT.rowNo, 0))) AS nOnMLDetNo,
HT.nHammaddeTuruNo,
HT.nUrtMTBolumID,
HT.sKodu,
HT.sAciklama,
HT.sRenk AS sRenk,
'' AS sBeden,
'' AS sAciklama2,
HT.lMiktar,
0.0 AS lFiyat,
0.0 AS lTutar,

View File

@@ -330,6 +330,7 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ
nOnMLNoStr string
nOnMLDetNoStr string
hNoStr string
mtBolumStr string
fiyatGirilen sql.NullFloat64
fiyatDoviz sql.NullString
maliyeteDahil sql.NullBool
@@ -344,6 +345,7 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ
&nOnMLNoStr,
&nOnMLDetNoStr,
&hNoStr,
&mtBolumStr,
&item.SKodu,
&item.SAciklama,
&item.SRenk,
@@ -378,6 +380,7 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ
item.NOnMLNo = strings.TrimSpace(nOnMLNoStr)
item.NOnMLDetNo = strings.TrimSpace(nOnMLDetNoStr)
item.NHammaddeTuruNo = strings.TrimSpace(hNoStr)
item.NUrtMTBolumID = strings.TrimSpace(mtBolumStr)
if fiyatGirilen.Valid {
item.FiyatGirilen = new(float64)
@@ -453,6 +456,7 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ
nOnMLNoStr string
nOnMLDetNoStr string
hNoStr string
mtBolumStr string
fiyatGirilen sql.NullFloat64
fiyatDoviz sql.NullString
maliyeteDahil sql.NullBool
@@ -467,6 +471,7 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ
&nOnMLNoStr,
&nOnMLDetNoStr,
&hNoStr,
&mtBolumStr,
&item.SKodu,
&item.SAciklama,
&item.SRenk,
@@ -501,6 +506,7 @@ func GetProductionHasCostDetailGroupsHandler(w http.ResponseWriter, r *http.Requ
item.NOnMLNo = strings.TrimSpace(nOnMLNoStr)
item.NOnMLDetNo = strings.TrimSpace(nOnMLDetNoStr)
item.NHammaddeTuruNo = strings.TrimSpace(hNoStr)
item.NUrtMTBolumID = strings.TrimSpace(mtBolumStr)
if fiyatGirilen.Valid {
item.FiyatGirilen = new(float64)
@@ -1497,6 +1503,14 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "detail_upserts", "count", len(req.Detail.Upserts))
skippedUpserts := 0
skippedUpsertsSample := 0
// Cache hammadde_turu -> mt_bolum_id so we don't query master table for every row.
mtBolumByHammadde := map[int]int{}
// Collect source rows for recipe sync (variantless, non-CM2 only).
type recipeKey struct {
nUrtMBolumID int
sKodu string
}
recipeQtyByKey := map[recipeKey]float64{}
for _, row := range req.Detail.Upserts {
if row.NOnMLDetNo <= 0 {
skippedUpserts += 1
@@ -1537,10 +1551,74 @@ func PostProductionProductCostingOnMLSaveHandler(w http.ResponseWriter, r *http.
return
}
}
// Guard: keep part/section binding stable.
// UI sometimes doesn't send n_urt_mt_bolum_id; if we write 0 into spUrtOnMLMasDet,
// joins to spUrtMTBolum will break and "parca adi" will render as "-".
if row.NUrtMTBolumID <= 0 && row.NHammaddeTuruNo > 0 {
if cached, ok := mtBolumByHammadde[row.NHammaddeTuruNo]; ok {
if cached > 0 {
row.NUrtMTBolumID = cached
}
} else {
var mtID int
err := tx.QueryRowContext(ctx, `
SELECT TOP 1 ISNULL(MTnUrtMTBolumID, 0) AS MTnUrtMTBolumID
FROM dbo.spUrtOnMLHammaddeTuru WITH (NOLOCK)
WHERE nHammaddeTuruNo = @p1
`, row.NHammaddeTuruNo).Scan(&mtID)
if err != nil && err != sql.ErrNoRows {
logger.Warn("mt bolum lookup error (will keep incoming value)",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"n_hammadde_turu_no", row.NHammaddeTuruNo,
"err", err,
)
mtBolumByHammadde[row.NHammaddeTuruNo] = 0
} else {
mtBolumByHammadde[row.NHammaddeTuruNo] = mtID
if mtID > 0 {
row.NUrtMTBolumID = mtID
logger.Info("mt bolum auto-filled from hammadde master",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"n_hammadde_turu_no", row.NHammaddeTuruNo,
"n_urt_mt_bolum_id", mtID,
)
} else {
logger.Warn("mt bolum missing on hammadde master (keeping 0)",
"trace_id", traceID,
"n_onml_no", nOnMLNo,
"n_onml_det_no", row.NOnMLDetNo,
"n_hammadde_turu_no", row.NHammaddeTuruNo,
)
}
}
}
}
qty := row.LMiktar
if qty < 0 {
qty = 0
}
// Build recipe sync source data:
// - never include CM2 / labor groups
// - never include empty codes
// - use variantless code (we already normalize sKodu on read; here we trust request)
if req.Header.NUrtReceteID > 0 {
group := strings.ToUpper(strings.TrimSpace(row.SAciklama3))
code := strings.TrimSpace(row.SKodu)
if code != "" && group != "CM2" && !strings.Contains(strings.ToUpper(code), " CM2") {
if row.NHammaddeTuruNo > 0 {
k := recipeKey{nUrtMBolumID: row.NHammaddeTuruNo, sKodu: code}
recipeQtyByKey[k] += qty
}
}
}
cur := strings.ToUpper(strings.TrimSpace(row.FiyatDoviz))
in := row.FiyatGirilen
unitTRY := in
@@ -1745,8 +1823,150 @@ WHEN NOT MATCHED THEN
logger.Warn("detail upserts skipped summary (det_no<=0)", "trace_id", traceID, "n_onml_no", nOnMLNo, "skipped", skippedUpserts)
}
// NOTE: Recipe tables are intentionally NOT synced from OnML saves.
// This costing screen is the source of truth only for dbo.spUrtOnMLMas / dbo.spUrtOnMLMasDet.
// Recipe sync (spUrtRecMBolum): insert missing rows, update qty when changed.
// IMPORTANT: We sync only variantless item codes (sModel-like) from OnML and never write CM2 items.
if req.Header.NUrtReceteID > 0 && len(recipeQtyByKey) > 0 {
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "recipe_sync", "n_urt_recete_id", req.Header.NUrtReceteID, "src_count", len(recipeQtyByKey))
// Determine default nUrtUBolumID from existing recipe rows; fallback to 13 (matches current data).
nUrtUBolumID := 13
_ = tx.QueryRowContext(ctx, `
SELECT TOP 1 ISNULL(CONVERT(int, nUrtUBolumID), 0) AS nUrtUBolumID
FROM dbo.spUrtRecMBolum WITH (NOLOCK)
WHERE nUrtReceteID = @p1
ORDER BY nUrtRecMBolumID ASC
`, req.Header.NUrtReceteID).Scan(&nUrtUBolumID)
if nUrtUBolumID <= 0 {
nUrtUBolumID = 13
}
// Load existing rows for quick compare.
existingQty := map[recipeKey]float64{}
if rows, err := tx.QueryContext(ctx, `
SELECT
ISNULL(CONVERT(int, nUrtMBolumID), 0) AS nUrtMBolumID,
LTRIM(RTRIM(ISNULL(nHStokID_G,''))) AS nHStokID_G,
ISNULL(CONVERT(float, lHMiktar_G), 0) AS lHMiktar_G
FROM dbo.spUrtRecMBolum WITH (NOLOCK)
WHERE nUrtReceteID = @p1
`, req.Header.NUrtReceteID); err == nil {
for rows.Next() {
var bolumID int
var code string
var q float64
if err := rows.Scan(&bolumID, &code, &q); err != nil {
continue
}
code = strings.TrimSpace(code)
if bolumID > 0 && code != "" {
existingQty[recipeKey{nUrtMBolumID: bolumID, sKodu: code}] = q
}
}
_ = rows.Close()
}
// Update changed quantities.
updated := 0
for k, q := range recipeQtyByKey {
old, ok := existingQty[k]
if !ok {
continue
}
if old == q {
continue
}
if _, err := tx.ExecContext(ctx, `
UPDATE dbo.spUrtRecMBolum
SET
lHMiktar_G = @p4,
sKullaniciAdiDeg = @p5,
dteIslemTarihiDeg = GETDATE()
WHERE nUrtReceteID = @p1
AND nUrtMBolumID = @p2
AND LTRIM(RTRIM(ISNULL(nHStokID_G,''))) = @p3
`, req.Header.NUrtReceteID, k.nUrtMBolumID, k.sKodu, q, user); err != nil {
logger.Warn("recipe qty update failed", "trace_id", traceID, "n_urt_recete_id", req.Header.NUrtReceteID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu, "err", err)
continue
}
updated++
}
// Insert missing rows.
// We must generate nUrtRecMBolumID (smallint, non-identity) manually.
var baseID int
if err := tx.QueryRowContext(ctx, `
SELECT ISNULL(MAX(CONVERT(int, nUrtRecMBolumID)), 0) AS MaxID
FROM dbo.spUrtRecMBolum WITH (UPDLOCK, HOLDLOCK)
`).Scan(&baseID); err != nil {
logger.Warn("recipe base id lookup failed (skipping inserts)", "trace_id", traceID, "err", err)
} else {
inserted := 0
nextID := baseID
for k, q := range recipeQtyByKey {
if _, ok := existingQty[k]; ok {
continue
}
// FK guard: only insert if nUrtMBolumID exists in spUrtMBolum.
var bolumExists int
if err := tx.QueryRowContext(ctx, `
SELECT CASE WHEN EXISTS (SELECT 1 FROM dbo.spUrtMBolum WITH (NOLOCK) WHERE nUrtMBolumID = @p1) THEN 1 ELSE 0 END
`, k.nUrtMBolumID).Scan(&bolumExists); err != nil || bolumExists != 1 {
logger.Warn("recipe insert skipped (missing spUrtMBolum FK)", "trace_id", traceID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu)
continue
}
nextID++
if nextID > 32767 {
logger.Warn("recipe insert stopped (nUrtRecMBolumID overflow)", "trace_id", traceID, "base_id", baseID, "next_id", nextID)
break
}
// NOTE: sIslemKodu is NOT NULL; keep empty string as default.
// Keep lMiktar_G at 0 (NOT NULL) to avoid producing NULL rows.
if _, err := 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,
lMiktar_G,
nSure,
sIslemKodu,
lHMiktar_GHedef,
nMBolumSarfTipiNo,
sKullaniciAdi,
dteIslemTarihi
)
VALUES (
@p1,@p2,@p3,@p4,
0,1,@p5,
@p6,0,1,
6,0,2,
0,0,0,
'',
0,1,
@p7,GETDATE()
)
`, nextID, req.Header.NUrtReceteID, nUrtUBolumID, k.nUrtMBolumID, k.sKodu, q, user); err != nil {
logger.Warn("recipe insert failed", "trace_id", traceID, "n_urt_recete_id", req.Header.NUrtReceteID, "n_urt_m_bolum_id", k.nUrtMBolumID, "s_kodu", k.sKodu, "err", err)
continue
}
inserted++
}
logger.Info("recipe sync done", "trace_id", traceID, "n_onml_no", nOnMLNo, "n_urt_recete_id", req.Header.NUrtReceteID, "updated", updated, "inserted", inserted)
}
}
logger.Info("tx step", "trace_id", traceID, "n_onml_no", nOnMLNo, "step", "commit")
if err := tx.Commit(); err != nil {

View File

@@ -136,6 +136,7 @@ func loadHasCostDetailGroups(ctx context.Context, uretimDB *sql.DB, nOnMLNo int)
nOnMLNoStr string
nOnMLDetNoStr string
hNoStr string
mtBolumStr string
fiyatGirilen sql.NullFloat64
fiyatDoviz sql.NullString
maliyeteDahil sql.NullBool
@@ -150,6 +151,7 @@ func loadHasCostDetailGroups(ctx context.Context, uretimDB *sql.DB, nOnMLNo int)
&nOnMLNoStr,
&nOnMLDetNoStr,
&hNoStr,
&mtBolumStr,
&item.SKodu,
&item.SAciklama,
&item.SRenk,
@@ -179,6 +181,7 @@ func loadHasCostDetailGroups(ctx context.Context, uretimDB *sql.DB, nOnMLNo int)
item.NOnMLNo = strings.TrimSpace(nOnMLNoStr)
item.NOnMLDetNo = strings.TrimSpace(nOnMLDetNoStr)
item.NHammaddeTuruNo = strings.TrimSpace(hNoStr)
item.NUrtMTBolumID = strings.TrimSpace(mtBolumStr)
if fiyatGirilen.Valid {
item.FiyatGirilen = new(float64)
*item.FiyatGirilen = fiyatGirilen.Float64
@@ -248,7 +251,11 @@ func (c *costingPDF) drawHeaderFull() {
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))
firmaLabel := strings.TrimSpace(c.header.FirmaKodu)
if strings.TrimSpace(c.header.UretimiYapanFirma) != "" {
firmaLabel = fmt.Sprintf("%s - %s", firmaLabel, strings.TrimSpace(c.header.UretimiYapanFirma))
}
line3 := fmt.Sprintf("Firma: %s | Kaydeden: %s | Guncelleme: %s (%s)", firmaLabel, 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)
@@ -258,7 +265,11 @@ func (c *costingPDF) drawHeaderFull() {
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)
firmaLabel := strings.TrimSpace(c.header.FirmaKodu)
if strings.TrimSpace(c.header.UretimiYapanFirma) != "" {
firmaLabel = fmt.Sprintf("%s - %s", firmaLabel, strings.TrimSpace(c.header.UretimiYapanFirma))
}
title := fmt.Sprintf("OnML %s | %s - %s | %s | %s", c.header.NOnMLNo, strings.TrimSpace(c.header.UrunKodu), strings.TrimSpace(c.header.UrunAdi), c.header.DteKayitTarihi, firmaLabel)
pdf.CellFormat(0, 6, title, "", 1, "L", false, 0, "")
pdf.Ln(1)
}
@@ -270,8 +281,23 @@ func (c *costingPDF) drawGroup(g models.ProductionHasCostDetailGroup, firstGroup
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)
// Keep total width <= A4 landscape printable width (297 - left/right margins).
// Also force USD/TRY unit+total columns to always be visible.
cols := []string{
"No",
"Parca",
"Hammadde",
"Kod",
"Aciklama",
"Renk",
"Miktar",
"Br",
"USD\nFiyat",
"USD\nTutar",
"TRY\nFiyat",
"TRY\nTutar",
}
wn := []float64{8, 20, 22, 32, 70, 14, 14, 10, 16, 16, 16, 16} // sum = 250
c.drawTableHeader(cols, wn)
for _, it := range g.Items {
@@ -297,10 +323,29 @@ func (c *costingPDF) drawTableHeader(cols []string, wn []float64) {
pdf.SetFont("dejavu", "B", 8)
pdf.SetFillColor(30, 30, 30)
pdf.SetTextColor(255, 255, 255)
// Compute a stable header height based on wrapped labels.
maxLines := 1
for i, col := range cols {
pdf.CellFormat(wn[i], 5.5, col, "1", 0, "C", true, 0, "")
lines := pdf.SplitLines([]byte(col), wn[i]-1.6)
if len(lines) > maxLines {
maxLines = len(lines)
}
}
pdf.Ln(5.5)
lineH := 3.5
headerH := float64(maxLines)*lineH + 1.2
if headerH < 7.0 {
headerH = 7.0
}
x0 := pdf.GetX()
y0 := pdf.GetY()
x := x0
for i, col := range cols {
c.drawHeaderCellWrap(x, y0, wn[i], headerH, col)
x += wn[i]
}
pdf.SetXY(x0, y0+headerH)
pdf.SetTextColor(0, 0, 0)
}
@@ -319,9 +364,28 @@ func (c *costingPDF) drawRowWithGroup(it models.ProductionHasCostDetailGroupItem
pdf := c.pdf
pdf.SetFont("dejavu", "", 7.2)
// Compute row height based on description wrapping.
// Compute row height based on key wrapped cells.
parcaLines := pdf.SplitLines([]byte(strings.TrimSpace(it.SParcaAdi)), wn[1]-2)
hLabel := strings.TrimSpace(it.NHammaddeTuruNo)
if strings.TrimSpace(it.SHammaddeTuruAdi) != "" {
hLabel = hLabel + " " + strings.TrimSpace(it.SHammaddeTuruAdi)
}
hLines := pdf.SplitLines([]byte(hLabel), wn[2]-2)
kodLines := pdf.SplitLines([]byte(strings.TrimSpace(it.SKodu)), wn[3]-2)
descLines := pdf.SplitLines([]byte(strings.TrimSpace(it.SAciklama)), wn[4]-2)
rowH := float64(len(descLines)) * 3.5
maxLines := len(descLines)
if len(parcaLines) > maxLines {
maxLines = len(parcaLines)
}
if len(hLines) > maxLines {
maxLines = len(hLines)
}
if len(kodLines) > maxLines {
maxLines = len(kodLines)
}
rowH := float64(maxLines) * 3.5
if rowH < 5.0 {
rowH = 5.0
}
@@ -336,15 +400,11 @@ func (c *costingPDF) drawRowWithGroup(it models.ProductionHasCostDetailGroupItem
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")
c.drawCellWrap(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")
c.drawCellWrap(x, y0, wn[2], rowH, hLabel, "L")
x += wn[2]
c.drawCell(x, y0, wn[3], rowH, strings.TrimSpace(it.SKodu), "L")
c.drawCellWrap(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]
@@ -355,24 +415,38 @@ func (c *costingPDF) drawRowWithGroup(it models.ProductionHasCostDetailGroupItem
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")
// Always show USD/TRY unit+total.
// In URETIM schema: lFiyat/lTutar are in TRY, lDovizFiyati/usdTutar are in USD.
c.drawCell(x, y0, wn[8], rowH, pdfMoney(it.LDovizFiyati), "R")
x += wn[8]
c.drawCell(x, y0, wn[9], rowH, cur, "C")
usdTotal := it.USDTutar
if usdTotal == 0 && it.LMiktar != 0 && it.LDovizFiyati != 0 {
usdTotal = it.LMiktar * it.LDovizFiyati
}
c.drawCell(x, y0, wn[9], rowH, pdfMoney(usdTotal), "R")
x += wn[9]
c.drawCell(x, y0, wn[10], rowH, pdfMoney(it.LTutar), "R")
// Prefer input price if present; otherwise lFiyat.
unitTRY := it.LFiyat
if it.FiyatGirilen != nil && *it.FiyatGirilen > 0 && strings.EqualFold(strings.TrimSpace(it.FiyatDoviz), "TRY") {
unitTRY = *it.FiyatGirilen
}
c.drawCell(x, y0, wn[10], rowH, pdfMoney(unitTRY), "R")
x += wn[10]
c.drawCell(x, y0, wn[11], rowH, pdfMoney(it.LTutar), "R")
pdf.SetXY(x0, y0+rowH)
}
func (c *costingPDF) drawHeaderCellWrap(x, y, w, h float64, txt string) {
pdf := c.pdf
pdf.Rect(x, y, w, h, "DF")
pdf.SetXY(x+0.8, y+0.6)
pdf.MultiCell(w-1.6, 3.5, txt, "", "C", true)
// restore cursor (MultiCell moves Y)
pdf.SetXY(x+w, y)
}
func (c *costingPDF) drawCell(x, y, w, h float64, txt, align string) {
pdf := c.pdf
pdf.Rect(x, y, w, h, "")

View File

@@ -254,7 +254,7 @@
{{ 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">
<span v-if="normalizeGroupName(grp.sAciklama3) === 'FABRIC'" class="pcd-sub-mt-qty">
Toplam Miktar: {{ formatBarQuantity(resolveGroupQuantity(grp)) }} MT |
</span>
Grup Toplami TRY: {{ formatBarMoney(resolveGroupTRYTutar(grp)) }} | USD: {{ formatBarMoney(resolveGroupUSDTutar(grp)) }}
@@ -894,6 +894,7 @@ const lineHistoryTargetSummary = ref('')
const lineHistorySearchMode = ref('exact')
const lineHistoryLastPurchaseMatchStage = ref('')
const lineHistoryLastRecipeMatchStage = ref('')
const purchaseAvgUSDCachedByCode = ref({})
const headerInfoCollapsed = ref(false)
const subHeaderTop = ref(140)
const stickyStackRef = ref(null)
@@ -1299,6 +1300,35 @@ function formatBarQuantity (value) {
return formatQuantity(roundedValue)
}
function convertPriceToUSD (price, currency) {
const p = Number(price || 0)
if (!Number.isFinite(p) || p <= 0) return 0
const cur = String(currency || '').trim().toUpperCase()
const usdRate = Number(exchangeRates.value?.usdRate || 0) || 0
const eurRate = Number(exchangeRates.value?.eurRate || 0) || 0
const gbpRate = Number(exchangeRates.value?.gbpRate || 0) || 0
// If we don't have rates, fall back to assuming USD.
if (!(usdRate > 0)) return p
switch (cur) {
case 'USD':
return p
case 'TRY':
case 'TL':
case '':
return p / usdRate
case 'EUR': {
const tryVal = (eurRate > 0 ? p * eurRate : p)
return tryVal / usdRate
}
case 'GBP': {
const tryVal = (gbpRate > 0 ? p * gbpRate : p)
return tryVal / usdRate
}
default:
return p
}
}
function normalizePriceCurrency (value) {
const normalizedValue = String(value || '').trim().toUpperCase()
return ['USD', 'TRY', 'EUR', 'GBP'].includes(normalizedValue) ? normalizedValue : ''
@@ -3881,6 +3911,142 @@ async function confirmDefaultQtyDeviationIfNeeded () {
return ok
}
async function getPurchaseAvgUSDForCode (code) {
const normalized = String(code || '').trim()
if (!normalized) return { ok: false, code: '', avgUSD: 0, n: 0 }
const cached = purchaseAvgUSDCachedByCode.value?.[normalized]
if (cached && cached.ok) return cached
try {
const response = await get('/pricing/production-product-costing/has-cost-detail-line-history', {
n_onml_no: parseInt(String(onMLNo.value || detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || '0'), 10) || 0,
s_kodu: normalized,
maliyet_tarihi: normalizeDateInput(costDate.value),
trace_id: traceId.value
})
const rows = Array.isArray(response?.purchaseRows) ? response.purchaseRows : []
const picked = []
for (const r of rows) {
const p = Number(r?.EvrakFiyat || 0)
if (!(p > 0)) continue
const cur = String(r?.EvrakDoviz || '').trim().toUpperCase() || 'USD'
const usd = convertPriceToUSD(p, cur)
if (!(usd > 0)) continue
picked.push(usd)
if (picked.length >= 10) break
}
const avgUSD = picked.length > 0 ? (picked.reduce((a, b) => a + b, 0) / picked.length) : 0
const result = { ok: picked.length > 0, code: normalized, avgUSD, n: picked.length }
purchaseAvgUSDCachedByCode.value = { ...(purchaseAvgUSDCachedByCode.value || {}), [normalized]: result }
return result
} catch (e) {
const result = { ok: false, code: normalized, avgUSD: 0, n: 0, error: String(e?.message || e) }
purchaseAvgUSDCachedByCode.value = { ...(purchaseAvgUSDCachedByCode.value || {}), [normalized]: result }
return result
}
}
async function confirmBrPriceDeviationIfNeeded () {
const rows = Array.isArray(flatDetailRows.value) ? flatDetailRows.value : []
if (rows.length === 0) return true
// Only consider rows that have a code + a non-zero entered price.
const candidates = rows
.filter(r => String(r?.sKodu || '').trim() !== '')
.map(r => {
const price = Number(resolveNumericRowInputPrice(r) || 0)
const cur = String(resolveInputCurrency(r) || '').trim().toUpperCase() || 'USD'
return { row: r, code: String(r?.sKodu || '').trim(), price, cur }
})
.filter(x => x.price > 0)
if (candidates.length === 0) return true
const uniqueCodes = Array.from(new Set(candidates.map(x => x.code)))
// Fetch averages with simple batching to avoid hammering the API.
const avgByCode = {}
const batchSize = 8
for (let i = 0; i < uniqueCodes.length; i += batchSize) {
const batch = uniqueCodes.slice(i, i + batchSize)
const results = await Promise.all(batch.map(c => getPurchaseAvgUSDForCode(c)))
results.forEach(r => { avgByCode[r.code] = r })
}
const outliers = []
for (const c of candidates) {
const avg = avgByCode[c.code]
if (!avg || !avg.ok || !(avg.avgUSD > 0) || avg.n < 3) continue // too little history -> ignore
const enteredUSD = convertPriceToUSD(c.price, c.cur)
if (!(enteredUSD > 0)) continue
const pct = ((enteredUSD - avg.avgUSD) / avg.avgUSD) * 100
if (Math.abs(pct) > 10) {
outliers.push({
code: c.code,
avgUSD: avg.avgUSD,
enteredUSD,
pct
})
}
}
if (outliers.length === 0) return true
outliers.sort((a, b) => Math.abs(b.pct) - Math.abs(a.pct))
const maxRows = 30
const rowsHtml = outliers.slice(0, maxRows).map(x => {
const sign = x.pct >= 0 ? '+' : ''
const pct = `${sign}${round1(x.pct)}%`
const cls = x.pct >= 0 ? 'color:#b71c1c;' : 'color:#1b5e20;'
return `
<tr>
<td style="padding:6px 8px; white-space:nowrap; font-weight:600;">${escapeHtml(x.code)}</td>
<td style="padding:6px 8px; text-align:right; white-space:nowrap;">${formatMoney(x.avgUSD)}</td>
<td style="padding:6px 8px; text-align:right; white-space:nowrap;">${formatMoney(x.enteredUSD)}</td>
<td style="padding:6px 8px; text-align:right; white-space:nowrap; ${cls} font-weight:600;">${pct}</td>
</tr>
`
}).join('')
const truncatedNote = outliers.length > maxRows
? `<div style="margin-top:8px; color:#666;">Toplam ${outliers.length} satir var. Ilk ${maxRows} gosterildi.</div>`
: ''
const ok = await new Promise(resolve => {
$q.dialog({
title: 'Fiyat Kontrolu (Satinalma Ortalama)',
html: true,
message: `
<div style="margin-bottom:10px;">
Bazı satırlarda girilen fiyat, BAGGI_V3 satınalma geçmişindeki <b>son 10</b> kaydın USD ortalamasından <b>%10</b>'dan fazla sapıyor.
</div>
<div style="max-height: 360px; overflow:auto; border:1px solid #e0e0e0; border-radius:6px;">
<table style="width:100%; border-collapse:collapse; font-size:13px;">
<thead>
<tr style="background:#f5f5f5; position: sticky; top: 0;">
<th style="text-align:left; padding:6px 8px;">Kod</th>
<th style="text-align:right; padding:6px 8px;">Ort USD</th>
<th style="text-align:right; padding:6px 8px;">Girilen USD</th>
<th style="text-align:right; padding:6px 8px;">Fark %</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
</div>
${truncatedNote}
<div style="margin-top:10px;">
Onayliyorsaniz <b>Onayla ve Kaydet</b>'e basın. Duzenlemek icin <b>Geri Don</b>.
</div>
`,
cancel: { label: 'Geri Don' },
ok: { label: 'Onayla ve Kaydet', color: 'primary' },
persistent: true
}).onOk(() => resolve(true)).onCancel(() => resolve(false))
})
return ok
}
async function deleteCosting () {
if (!detailHeader.value) return
const n = parseInt(String(detailHeader.value?.nOnMLNo || detailHeader.value?.NOnMLNo || onMLNo.value || '0'), 10) || 0
@@ -4030,6 +4196,9 @@ async function saveChanges () {
const okDefaultQty = await confirmDefaultQtyDeviationIfNeeded()
if (!okDefaultQty) return
const okBrPrice = await confirmBrPriceDeviationIfNeeded()
if (!okBrPrice) return
if (!detailHeader.value) {
$q.notify({ type: 'negative', message: 'Header bulunamadi.', position: 'top-right' })
return
@@ -4507,6 +4676,10 @@ watch(
font-size: 12px;
text-transform: uppercase;
text-align: right;
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
}
.pcd-sub-right-clickable {
cursor: pointer;
@@ -4514,6 +4687,13 @@ watch(
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
}
.pcd-sub-mt-qty {
flex: 0 0 auto;
white-space: nowrap;
opacity: 0.9;
}
.pcd-detail-table :deep(.q-table__middle) {