package routes import ( "context" "database/sql" "fmt" "math" "sort" "strings" "time" "bssapp-backend/internal/mailer" "bssapp-backend/models" "bssapp-backend/queries" ) type mailBucket struct { InputByCur map[string]float64 USD float64 TRY float64 HasCMT bool // Fabric-only helpers (for UI parity) MeterQty float64 MeterUom string UnitIn float64 UnitCur string } func formatDateTR2(t time.Time) string { if t.IsZero() { return "-" } // dd.MM.yyyy return t.Format("02.01.2006") } func formatDateTimeTR2(t time.Time) string { if t.IsZero() { return "-" } // dd.MM.yyyy HH:mm return t.Format("02.01.2006 15:04") } func formatAnyDateTimeTR2(s string) string { s = strings.TrimSpace(s) if s == "" { return "-" } // Common MSSQL string renderings (best-effort). layouts := []string{ "2006-01-02 15:04:05.9999999", "2006-01-02 15:04:05.999999", "2006-01-02 15:04:05.999", "2006-01-02 15:04:05", time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05", "2006-01-02", } for _, layout := range layouts { if t, err := time.Parse(layout, s); err == nil { // Date-only vs datetime if layout == "2006-01-02" { return formatDateTR2(t) } return formatDateTimeTR2(t) } } return s } func addInputAmount(b *mailBucket, cur string, amount float64) { if math.IsNaN(amount) || math.IsInf(amount, 0) || amount == 0 { return } if b.InputByCur == nil { b.InputByCur = map[string]float64{} } b.InputByCur[cur] = b.InputByCur[cur] + amount } func formatMoney2(v float64) string { if math.IsNaN(v) || math.IsInf(v, 0) { v = 0 } // Keep 2 decimals with dot (mail clients). return fmt.Sprintf("%.2f", v) } func formatQty2(v float64) string { if math.IsNaN(v) || math.IsInf(v, 0) { v = 0 } return fmt.Sprintf("%.2f", v) } func normalizePartFromMtBolumTitle(title string) string { v := strings.ToUpper(strings.TrimSpace(title)) if v == "" { return "" } // Keep the part name dynamic (UI shows spUrtMTBolum.sAdi). Still normalize a few aliases. switch { case strings.Contains(v, "AKSESUAR") || strings.Contains(v, "AKS"): return "AKSESUAR" default: return v } } func summarizeInputByCurrency(b *mailBucket) (amountLabel string, curLabel string) { if b == nil || len(b.InputByCur) == 0 { return "-", "-" } curs := make([]string, 0, len(b.InputByCur)) for c := range b.InputByCur { c = strings.ToUpper(strings.TrimSpace(c)) if c == "" { continue } curs = append(curs, c) } sort.Strings(curs) if len(curs) == 0 { return "-", "-" } if len(curs) == 1 { c := curs[0] return formatMoney2(b.InputByCur[c]), c } sum := 0.0 for _, c := range curs { sum += b.InputByCur[c] } return formatMoney2(sum), "MIX" } func formatMeterLabel(b *mailBucket) string { if b == nil || !(b.MeterQty > 0) { return "-" } u := strings.TrimSpace(b.MeterUom) if u == "" { u = "MT" } return fmt.Sprintf("%s %s", formatQty2(b.MeterQty), u) } func loadCostingRecipients(pg *sql.DB, firstGroupCode string) ([]string, error) { rows, err := pg.Query(` SELECT DISTINCT TRIM(m.email) AS email FROM mk_costing_first_group_mail f JOIN mk_mail m ON m.id = f.mail_id WHERE m.is_active = true AND COALESCE(TRIM(m.email), '') <> '' AND UPPER(TRIM(f.urun_ilk_grubu)) = UPPER(TRIM($1)) ORDER BY email `, strings.TrimSpace(firstGroupCode)) if err != nil { return nil, err } defer rows.Close() out := make([]string, 0, 16) for rows.Next() { var email string if err := rows.Scan(&email); err != nil { return nil, err } email = strings.TrimSpace(email) if email != "" { out = append(out, email) } } return out, rows.Err() } func sendCostingSummaryMail( ctx context.Context, pg *sql.DB, mssql *sql.DB, uretim *sql.DB, ml *mailer.GraphMailer, req models.ProductionProductCostingOnMLSaveRequest, nOnMLNo int, isUpdate bool, usdRate float64, eurRate float64, gbpRate float64, totalUSD float64, totalTRY float64, totalEUR float64, actor string, ) error { if ml == nil { return fmt.Errorf("mailer not initialized") } if pg == nil || mssql == nil { return fmt.Errorf("db not initialized") } // Ensure mapping tables exist (first save can happen before mapping screens are visited). if err := ensureFirstGroupMailMappingTables(pg); err != nil { return fmt.Errorf("mapping table bootstrap error: %w", err) } firstGroupCode, _, err := queries.GetProductFirstGroupCodeDescByUrunKodu(ctx, mssql, req.Header.UrunKodu) if err != nil { return fmt.Errorf("first group resolve error: %w", err) } if strings.TrimSpace(firstGroupCode) == "" { return fmt.Errorf("first group code not found for product") } recipients, err := loadCostingRecipients(pg, firstGroupCode) if err != nil { return fmt.Errorf("recipient query error: %w", err) } if len(recipients) == 0 { // Don't hard fail; mapping might be intentionally empty. return fmt.Errorf("no costing mail mapping for first group: %s", firstGroupCode) } // Pull the same header payload used by UI (best-effort) so the mail can show every header label. var uiHeader models.ProductionHasCostDetailHeader uiHeaderLoaded := false if uretim != nil && nOnMLNo > 0 { row, err := queries.GetProductionHasCostDetailHeaderByOnMLNo(ctx, uretim, nOnMLNo) if err == nil && row != nil { // Keep scan fields aligned with GetProductionHasCostDetailHeaderHandler if err := row.Scan( &uiHeader.UretimiYapanFirma, &uiHeader.SonIsEmriVeren, &uiHeader.FirmaKodu, &uiHeader.NFirmaID, &uiHeader.NOnMLNo, &uiHeader.UrunKodu, &uiHeader.UrunAdi, &uiHeader.UretimSekliID, &uiHeader.UretimSekli, &uiHeader.MaliyetTarihi, &uiHeader.DteKayitTarihi, &uiHeader.SKullaniciAdi, &uiHeader.LTutarTL, &uiHeader.LTutarUSD, &uiHeader.LTutarEURO, &uiHeader.LTutarGBP, &uiHeader.SDovizCinsi, &uiHeader.LTutarDoviz, &uiHeader.DteGuncellemeTarihi, &uiHeader.SGuncellemeKullaniciAdi, &uiHeader.NUrtReceteID, ); err == nil { uiHeaderLoaded = true if mssql != nil { ilk, ana, alt, _ := queries.GetProductIlkAnaAltGrupByUrunKodu(ctx, mssql, uiHeader.UrunKodu) uiHeader.UrunIlkGrubu = ilk uiHeader.UrunAnaGrubu = ana uiHeader.UrunAltGrubu = alt } } } } // Enrich header (fallback) if UI header wasn't loadable. ilkGrup, anaGrup, altGrup := "", "", "" if uiHeaderLoaded { ilkGrup, anaGrup, altGrup = strings.TrimSpace(uiHeader.UrunIlkGrubu), strings.TrimSpace(uiHeader.UrunAnaGrubu), strings.TrimSpace(uiHeader.UrunAltGrubu) } else if mssql != nil { ilkGrup, anaGrup, altGrup, _ = queries.GetProductIlkAnaAltGrupByUrunKodu(ctx, mssql, req.Header.UrunKodu) } // Resolve MT bolum titles so we can bucket rows into CEKET/PANTOLON/YELEK/AKSESUAR. mtTitleByID := map[int]string{} if uretim != nil { ids := make([]int, 0, len(req.Detail.Upserts)) seen := map[int]struct{}{} for _, row := range req.Detail.Upserts { if row.NUrtMTBolumID <= 0 { continue } if _, ok := seen[row.NUrtMTBolumID]; ok { continue } seen[row.NUrtMTBolumID] = struct{}{} ids = append(ids, row.NUrtMTBolumID) } if len(ids) > 0 { vals := make([]string, 0, len(ids)) args := make([]any, 0, len(ids)) for i, id := range ids { vals = append(vals, fmt.Sprintf("(@p%d)", i+1)) args = append(args, id) } q := fmt.Sprintf(` WITH X AS (SELECT CONVERT(int, V.id) AS id FROM (VALUES %s) AS V(id)) SELECT X.id, LTRIM(RTRIM(ISNULL(M.sAdi,''))) AS title FROM X LEFT JOIN dbo.spUrtMTBolum M WITH (NOLOCK) ON M.nUrtMTBolumID = X.id `, strings.Join(vals, ",")) rows, err := uretim.QueryContext(ctx, q, args...) if err == nil { for rows.Next() { var id int var title string if err := rows.Scan(&id, &title); err != nil { continue } mtTitleByID[id] = strings.TrimSpace(title) } _ = rows.Close() } } } // Dynamic part list derived from detail rows. preferred := []string{"CEKET", "PANTOLON", "YELEK", "AKSESUAR", "YAKA"} seen := map[string]struct{}{} dynamic := make([]string, 0, 16) for _, row := range req.Detail.Upserts { group := strings.ToUpper(strings.TrimSpace(row.SAciklama3)) included := strings.Contains(group, "CM2") || strings.Contains(group, "CM1") || row.MaliyeteDahil == 1 if !included { continue } part := "" if t := mtTitleByID[row.NUrtMTBolumID]; t != "" { part = normalizePartFromMtBolumTitle(t) } part = strings.TrimSpace(part) if part == "" { continue } if _, ok := seen[part]; ok { continue } seen[part] = struct{}{} dynamic = append(dynamic, part) } parts := make([]string, 0, len(dynamic)+len(preferred)) for _, p := range preferred { if _, ok := seen[p]; ok { parts = append(parts, p) } } for _, p := range dynamic { isPreferred := false for _, pref := range preferred { if pref == p { isPreferred = true break } } if !isPreferred { parts = append(parts, p) } } if len(parts) == 0 { parts = []string{"TANIMSIZ"} } labor := map[string]*mailBucket{} material := map[string]*mailBucket{} fabric := map[string]*mailBucket{} for _, p := range parts { labor[p] = &mailBucket{} material[p] = &mailBucket{} fabric[p] = &mailBucket{} } for _, row := range req.Detail.Upserts { group := strings.ToUpper(strings.TrimSpace(row.SAciklama3)) cur := strings.ToUpper(strings.TrimSpace(row.FiyatDoviz)) qty := row.LMiktar if qty < 0 { qty = 0 } in := row.FiyatGirilen // Included rule: CM2 always; others only when maliyete_dahil = 1. included := strings.Contains(group, "CM2") || strings.Contains(group, "CM1") || row.MaliyeteDahil == 1 if !included { continue } // Part bucket: part := "" if t := mtTitleByID[row.NUrtMTBolumID]; t != "" { part = normalizePartFromMtBolumTitle(t) } if part == "" { continue } // Convert input to TRY unit unitTRY := in switch cur { case "USD": unitTRY = in * usdRate case "EUR": unitTRY = in * eurRate case "GBP": unitTRY = in * gbpRate case "TRY", "TL", "": unitTRY = in default: unitTRY = in } unitUSD := 0.0 if usdRate > 0 { unitUSD = unitTRY / usdRate } amountTRY := unitTRY * qty amountUSD := unitUSD * qty // input totals for display: inputPrice * qty in input currency inputAmount := in * qty if strings.Contains(group, "CM2") || strings.Contains(group, "CM1") { b := labor[part] b.USD += amountUSD b.TRY += amountTRY addInputAmount(b, cur, inputAmount) // UI rule: tick only when cm_price_type_id == 2 (malzemeli). Nil/empty defaults to 1 (unticked). if row.CMPriceTypeID != nil && *row.CMPriceTypeID == 2 { b.HasCMT = true } continue } if group == "DT" || strings.Contains(group, " DT") || group == "TP" || strings.Contains(group, " TP") { b := material[part] b.USD += amountUSD b.TRY += amountTRY addInputAmount(b, cur, inputAmount) continue } if group == "FABRIC" || strings.Contains(group, "FABRIC") { b := fabric[part] b.USD += amountUSD b.TRY += amountTRY addInputAmount(b, cur, inputAmount) // UI parity: fabric summary shows metraj and a representative unit input price (first non-zero). if qty > 0 { b.MeterQty += qty if strings.TrimSpace(b.MeterUom) == "" { if u := strings.TrimSpace(row.SBirim); u != "" { b.MeterUom = u } } } if b.UnitIn <= 0 && in > 0 { b.UnitIn = in b.UnitCur = cur } } } maliyetTarihi := strings.TrimSpace(req.Header.MaliyetTarihi) if uiHeaderLoaded { // Prefer the UI header date (can differ from "today"). if v := strings.TrimSpace(uiHeader.MaliyetTarihi); v != "" { maliyetTarihi = v } } // Display format: dd.MM.yyyy (mail). Keep the original YYYY-MM-DD for subject readability if present. maliyetTarihiLabel := maliyetTarihi if parsed, err := time.Parse("2006-01-02", maliyetTarihi); err == nil { maliyetTarihiLabel = formatDateTR2(parsed) } else if maliyetTarihi == "" { maliyetTarihi = time.Now().Format("2006-01-02") maliyetTarihiLabel = formatDateTR2(time.Now()) } titleLabel := "MALIYETI GIRIS YAPILAN URUN" if isUpdate { titleLabel = "MALIYETI GUNCELLENEN URUN" } subject := fmt.Sprintf("%s | %s | %s | OnML:%d", strings.TrimSpace(req.Header.UrunKodu), titleLabel, maliyetTarihi, nOnMLNo) if strings.TrimSpace(actor) != "" { subject = fmt.Sprintf("%s tarafindan %s", strings.TrimSpace(actor), subject) } // Build HTML with 4 tables. var b strings.Builder b.WriteString(`
`) b.WriteString(fmt.Sprintf(`

%s

`, htmlEsc(titleLabel))) b.WriteString(``) b.WriteString(``) // Prefer resolved UI header (more complete), fallback to request header. urunKodu := strings.TrimSpace(req.Header.UrunKodu) urunAdi := strings.TrimSpace(req.Header.UrunAdi) if uiHeaderLoaded { if v := strings.TrimSpace(uiHeader.UrunKodu); v != "" { urunKodu = v } if v := strings.TrimSpace(uiHeader.UrunAdi); v != "" { urunAdi = v } } b.WriteString(fmt.Sprintf(``, htmlEsc(urunKodu))) if urunAdi != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(urunAdi))) } if strings.TrimSpace(ilkGrup) != "" || strings.TrimSpace(anaGrup) != "" || strings.TrimSpace(altGrup) != "" { if strings.TrimSpace(ilkGrup) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(ilkGrup))) } if strings.TrimSpace(anaGrup) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(anaGrup))) } if strings.TrimSpace(altGrup) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(altGrup))) } } b.WriteString(fmt.Sprintf(``, htmlEsc(maliyetTarihiLabel))) // Mirror UI header labels when available: if uiHeaderLoaded { if strings.TrimSpace(uiHeader.UretimSekli) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.UretimSekli))) } else if strings.TrimSpace(uiHeader.UretimSekliID) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.UretimSekliID))) } if strings.TrimSpace(uiHeader.UretimiYapanFirma) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.UretimiYapanFirma))) } if strings.TrimSpace(uiHeader.SonIsEmriVeren) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.SonIsEmriVeren))) } if strings.TrimSpace(uiHeader.NOnMLNo) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.NOnMLNo))) } else { b.WriteString(fmt.Sprintf(``, nOnMLNo)) } if strings.TrimSpace(uiHeader.SKullaniciAdi) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.SKullaniciAdi))) } if strings.TrimSpace(uiHeader.DteKayitTarihi) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(formatAnyDateTimeTR2(uiHeader.DteKayitTarihi)))) } if strings.TrimSpace(uiHeader.DteGuncellemeTarihi) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(formatAnyDateTimeTR2(uiHeader.DteGuncellemeTarihi)))) } if strings.TrimSpace(uiHeader.SGuncellemeKullaniciAdi) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.SGuncellemeKullaniciAdi))) } if strings.TrimSpace(uiHeader.NUrtReceteID) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(uiHeader.NUrtReceteID))) } } else { b.WriteString(fmt.Sprintf(``, nOnMLNo)) if req.Header.NUrtReceteID > 0 { b.WriteString(fmt.Sprintf(``, req.Header.NUrtReceteID)) } if req.Header.UretimSekliID > 0 { b.WriteString(fmt.Sprintf(``, req.Header.UretimSekliID)) } } // Free text description (from request). if strings.TrimSpace(req.Header.SAciklama) != "" { b.WriteString(fmt.Sprintf(``, htmlEsc(req.Header.SAciklama))) } b.WriteString(`
AlanDeger
UrunKodu%s
UrunAdi%s
Urun Ilk Grubu%s
Urun Ana Grubu%s
Urun Alt Grubu%s
Maliyet Tarihi%s
Uretim Sekli%s
Uretim Sekli ID%s
Uretimi Yapan Firma%s
2.Firma%s
nOnMLNo%s
nOnMLNo%d
sKullaniciAdi%s
Kayit Tarihi%s
Son Guncelleme Tarihi%s
sGuncellemeKullaniciAdi%s
nUrtReceteID%s
nOnMLNo%d
nUrtReceteID%d
Uretim Sekli ID%d
Aciklama%s
`) // 1) Header totals b.WriteString(`

Maliyetlere Islenen Toplam Tutar

`) b.WriteString(``) gbpTotal := 0.0 if gbpRate > 0 { gbpTotal = totalTRY / gbpRate } // UI format (2 rows, key/value pairs) b.WriteString(fmt.Sprintf(``, formatMoney2(totalUSD), formatMoney2(totalTRY))) b.WriteString(fmt.Sprintf(``, formatMoney2(totalEUR), formatMoney2(gbpTotal))) b.WriteString(`
USD%sTRY%s
EUR%sGBP%s
`) renderLaborTable := func(title string, m map[string]*mailBucket) { b.WriteString(fmt.Sprintf(`

%s

`, htmlEsc(title))) b.WriteString(``) b.WriteString(``) b.WriteString(``) b.WriteString(``) totalUSD := 0.0 totalTRY := 0.0 for _, p := range parts { row := m[p] inAmt, inCur := summarizeInputByCurrency(row) tick := "" if row != nil && row.HasCMT { tick = "✓" } if row != nil { totalUSD += row.USD totalTRY += row.TRY } b.WriteString(``) b.WriteString(fmt.Sprintf(``, htmlEsc(p))) b.WriteString(fmt.Sprintf(``, htmlEsc(inAmt))) b.WriteString(fmt.Sprintf(``, htmlEsc(inCur))) b.WriteString(fmt.Sprintf(``, formatMoney2(row.USD))) b.WriteString(fmt.Sprintf(``, formatMoney2(row.TRY))) b.WriteString(fmt.Sprintf(``, tick)) b.WriteString(``) } b.WriteString(``) b.WriteString(``) b.WriteString(fmt.Sprintf(``, formatMoney2(totalUSD))) b.WriteString(fmt.Sprintf(``, formatMoney2(totalTRY))) b.WriteString(``) b.WriteString(``) b.WriteString(`
ParcaGirisPr.Br.USD TutarTRY TutarCMT/Malzemeli
%s%s%s%s%s%s
TOPLAM%s%s
`) } renderMaterialTable := func(title string, m map[string]*mailBucket) { b.WriteString(fmt.Sprintf(`

%s

`, htmlEsc(title))) b.WriteString(``) b.WriteString(``) totalUSD := 0.0 totalTRY := 0.0 for _, p := range parts { row := m[p] if row != nil { totalUSD += row.USD totalTRY += row.TRY } b.WriteString(``) b.WriteString(fmt.Sprintf(``, htmlEsc(p))) b.WriteString(fmt.Sprintf(``, formatMoney2(row.USD))) b.WriteString(fmt.Sprintf(``, formatMoney2(row.TRY))) b.WriteString(``) } b.WriteString(``) b.WriteString(``) b.WriteString(fmt.Sprintf(``, formatMoney2(totalUSD))) b.WriteString(fmt.Sprintf(``, formatMoney2(totalTRY))) b.WriteString(``) b.WriteString(`
ParcaUSD TutarTRY Tutar
%s%s%s
TOPLAM%s%s
`) } renderFabricTable := func(title string, m map[string]*mailBucket) { b.WriteString(fmt.Sprintf(`

%s

`, htmlEsc(title))) b.WriteString(``) b.WriteString(``) b.WriteString(``) b.WriteString(``) totalUSD := 0.0 totalTRY := 0.0 totalMeter := 0.0 for _, p := range parts { row := m[p] inAmt, inCur := summarizeInputByCurrency(row) _ = inAmt unitLabel := "-" curLabel := inCur if row != nil && row.UnitIn > 0 { unitLabel = formatMoney2(row.UnitIn) if strings.TrimSpace(row.UnitCur) != "" { curLabel = strings.ToUpper(strings.TrimSpace(row.UnitCur)) } } if strings.TrimSpace(curLabel) == "" { curLabel = "-" } if row != nil { totalUSD += row.USD totalTRY += row.TRY totalMeter += row.MeterQty } b.WriteString(``) b.WriteString(fmt.Sprintf(``, htmlEsc(p))) b.WriteString(fmt.Sprintf(``, htmlEsc(formatMeterLabel(row)))) b.WriteString(fmt.Sprintf(``, htmlEsc(unitLabel))) b.WriteString(fmt.Sprintf(``, htmlEsc(curLabel))) b.WriteString(fmt.Sprintf(``, formatMoney2(row.USD))) b.WriteString(fmt.Sprintf(``, formatMoney2(row.TRY))) b.WriteString(``) } totalMeterLabel := "-" if totalMeter > 0 { totalMeterLabel = fmt.Sprintf("%s MT", formatQty2(totalMeter)) } b.WriteString(``) b.WriteString(``) b.WriteString(fmt.Sprintf(``, htmlEsc(totalMeterLabel))) b.WriteString(``) b.WriteString(fmt.Sprintf(``, formatMoney2(totalUSD))) b.WriteString(fmt.Sprintf(``, formatMoney2(totalTRY))) b.WriteString(``) b.WriteString(`
ParcaMetrajMT Giris FiyatPr.Br.USD TutarTRY Tutar
%s%s%s%s%s%s
TOPLAM%s%s%s
`) } renderLaborTable("Iscilik Fiyatlari (CM2)", labor) renderMaterialTable("Malzeme Fiyatlari (DT/TP, maliyete dahil)", material) renderFabricTable("Kumas Fiyatlari (FABRIC, maliyete dahil)", fabric) b.WriteString(`

Bu mail BaggiSS App uzerinden otomatik gonderilmistir.

`) b.WriteString(`
`) msg := mailer.Message{ To: recipients, Subject: subject, BodyHTML: b.String(), } // Graph send if err := ml.Send(context.Background(), msg); err != nil { return err } return nil }