package routes import ( "bytes" "context" "database/sql" "fmt" "log" "sort" "strings" "time" "bssapp-backend/db" "bssapp-backend/internal/mailer" "bssapp-backend/models" "bssapp-backend/queries" "github.com/jung-kurt/gofpdf" ) func loadPricingRecipients(pg *sql.DB, firstGroupCode string) ([]string, error) { rows, err := pg.Query(` SELECT DISTINCT TRIM(m.email) AS email FROM mk_pricing_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 htmlEscapeMini(s string) string { // Minimal safe escaping for our templated cells. r := strings.NewReplacer( "&", "&", "<", "<", ">", ">", "\"", """, "'", "'", ) return r.Replace(s) } func fmtMoneyMail(v float64) string { return fmt.Sprintf("%.2f", v) } func fmtQtyMail(v float64) string { return fmt.Sprintf("%.2f", v) } func fmtDateTRFromISO(d string) string { d = strings.TrimSpace(d) if len(d) >= 10 { d = d[:10] } parts := strings.Split(d, "-") if len(parts) != 3 { if d == "" { return "-" } return d } y, m, day := parts[0], parts[1], parts[2] if y == "" || m == "" || day == "" { return d } return day + "." + m + "." + y } func buildPricingChangeMailHTML(rows []models.ProductPricing, actor string, at time.Time) string { var b strings.Builder b.WriteString(`
`) b.WriteString(`
`) b.WriteString(`
Fiyat Degisikligi
`) if strings.TrimSpace(actor) != "" { b.WriteString(`
Islem Yapan: ` + htmlEscapeMini(actor) + `
`) } b.WriteString(`
Tarih: ` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `
`) b.WriteString(`
Urun Sayisi: ` + fmt.Sprintf("%d", len(rows)) + `
`) b.WriteString(`
Detaylar ekteki PDF dosyasindadir.
`) b.WriteString(`
`) b.WriteString(`
`) return b.String() } func buildPricingChangeMailPDF(rows []models.ProductPricing, actor string, at time.Time) ([]byte, error) { pdf := gofpdf.New("L", "mm", "A2", "") font := "Arial" if err := registerDejavuFonts(pdf, "dejavu"); err == nil { font = "dejavu" } else { log.Printf("[pricing-mail] pdf font fallback: %v", err) } pdf.SetMargins(7, 8, 7) pdf.SetAutoPageBreak(true, 10) pdf.AddPage() pdf.SetFont(font, "B", 13) pdf.CellFormat(0, 7, "Fiyat Degisikligi", "", 1, "L", false, 0, "") pdf.SetFont(font, "", 8) if strings.TrimSpace(actor) != "" { pdf.CellFormat(0, 5, "Islem Yapan: "+strings.TrimSpace(actor), "", 1, "L", false, 0, "") } pdf.CellFormat(0, 5, "Tarih: "+at.Format("02.01.2006 15:04"), "", 1, "L", false, 0, "") pdf.CellFormat(0, 5, fmt.Sprintf("Urun Sayisi: %d", len(rows)), "", 1, "L", false, 0, "") pdf.Ln(2) heads := []string{ "MARKA GRUBU", "MARKA", "BRAND", "URUN KODU", "STOK", "STOK GIRIS", "SON MALIYET", "SON FIYAT", "ASKILI", "KATEGORI", "URUN ANA GRUBU", "URUN ALT GRUBU", "ICERIK", "KARISIM", "MALIYET", "TABAN USD", "TABAN TRY", "USD1", "USD2", "USD3", "USD4", "USD5", "USD6", "EUR1", "EUR2", "EUR3", "EUR4", "EUR5", "EUR6", "TRY1", "TRY2", "TRY3", "TRY4", "TRY5", "TRY6", } widths := []float64{ 20, 18, 18, 28, 12, 18, 18, 18, 14, 18, 22, 22, 20, 20, 18, 18, 18, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, } aligns := make([]string, len(heads)) for i := range aligns { aligns[i] = "L" } for _, idx := range []int{4, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34} { aligns[idx] = "R" } writePDFTableHeader(pdf, font, heads, widths) pdf.SetFont(font, "", 5.7) for _, r := range rows { if pdf.GetY() > 405 { pdf.AddPage() writePDFTableHeader(pdf, font, heads, widths) pdf.SetFont(font, "", 5.7) } cells := []string{ r.BrandGroupSec, r.Marka, r.BrandCode, r.ProductCode, fmtQtyMail(r.StockQty), fmtDateTRFromISO(r.StockEntryDate), fmtDateTRFromISO(r.LastCostingDate), fmtDateTRFromISO(r.LastPricingDate), r.AskiliYan, r.Kategori, r.UrunAnaGrubu, r.UrunAltGrubu, r.Icerik, r.Karisim, fmtMoneyMail(r.CostPrice), fmtMoneyMail(r.BasePriceUsd), fmtMoneyMail(r.BasePriceTry), fmtMoneyMail(r.USD1), fmtMoneyMail(r.USD2), fmtMoneyMail(r.USD3), fmtMoneyMail(r.USD4), fmtMoneyMail(r.USD5), fmtMoneyMail(r.USD6), fmtMoneyMail(r.EUR1), fmtMoneyMail(r.EUR2), fmtMoneyMail(r.EUR3), fmtMoneyMail(r.EUR4), fmtMoneyMail(r.EUR5), fmtMoneyMail(r.EUR6), fmtMoneyMail(r.TRY1), fmtMoneyMail(r.TRY2), fmtMoneyMail(r.TRY3), fmtMoneyMail(r.TRY4), fmtMoneyMail(r.TRY5), fmtMoneyMail(r.TRY6), } writePDFTableRow(pdf, cells, widths, aligns, 4.4) } var buf bytes.Buffer if err := pdf.Output(&buf); err != nil { return nil, err } return buf.Bytes(), nil } // sendPricingChangeMails sends one mail per UrunIlkGrubu (group) based on mk_pricing_first_group_mail mapping. // It is designed to be called post-commit in a goroutine. func sendPricingChangeMails(bg context.Context, ml *mailer.GraphMailer, productCodes []string, actor string) { if ml == nil { return } pg := db.PgDB if pg == nil { log.Printf("[pricing-mail] skipped: pg not ready") return } // Ensure mapping tables exist. if err := ensureFirstGroupMailMappingTables(pg); err != nil { log.Printf("[pricing-mail] mapping bootstrap error: %v", err) return } ctx, cancel := context.WithTimeout(bg, 90*time.Second) defer cancel() codes := make([]string, 0, len(productCodes)) seen := map[string]struct{}{} for _, c := range productCodes { c = strings.TrimSpace(c) if c == "" { continue } if _, ok := seen[c]; ok { continue } seen[c] = struct{}{} codes = append(codes, c) } if len(codes) == 0 { return } rows, err := queries.GetAllProductPricingRows(ctx, 500, queries.ProductPricingFilters{ProductCode: codes}, "productCode", false) if err != nil { log.Printf("[pricing-mail] pricing rows query error: %v", err) return } if len(rows) == 0 { return } byGroup := map[string][]models.ProductPricing{} for _, r := range rows { g := strings.TrimSpace(r.UrunIlkGrubu) if g == "" { g = "UNKNOWN" } byGroup[g] = append(byGroup[g], r) } now := time.Now() for group, list := range byGroup { // No mapping = skip. recipients, err := loadPricingRecipients(pg, group) if err != nil { log.Printf("[pricing-mail] recipient query error group=%s err=%v", group, err) continue } if len(recipients) == 0 { log.Printf("[pricing-mail] no recipients mapped group=%s", group) continue } sort.Slice(list, func(i, j int) bool { return strings.TrimSpace(list[i].ProductCode) < strings.TrimSpace(list[j].ProductCode) }) subject := fmt.Sprintf("Fiyat Degisikligi | %s | %d urun", now.Format("02.01.2006 15:04"), len(list)) html := buildPricingChangeMailHTML(list, actor, now) attachments := []mailer.Attachment(nil) if data, err := buildPricingChangeMailPDF(list, actor, now); err != nil { log.Printf("[pricing-mail] pdf build failed group=%s err=%v", group, err) } else { attachments = append(attachments, mailer.Attachment{ FileName: fmt.Sprintf("fiyat-degisikligi-%s.pdf", now.Format("20060102-1504")), ContentType: "application/pdf", Data: data, }) } // Retry 2 times with backoff. backoff := []time.Duration{800 * time.Millisecond, 2500 * time.Millisecond} var lastErr error for attempt := 0; attempt < len(backoff)+1; attempt++ { if attempt > 0 { time.Sleep(backoff[attempt-1]) } stepCtx, stepCancel := context.WithTimeout(bg, 25*time.Second) err := ml.Send(stepCtx, mailer.Message{ To: recipients, Subject: subject, BodyHTML: html, Attachments: attachments, }) stepCancel() if err == nil { lastErr = nil break } lastErr = err } if lastErr != nil { log.Printf("[pricing-mail] send failed group=%s err=%v", group, lastErr) } else { log.Printf("[pricing-mail] sent group=%s to=%d products=%d", group, len(recipients), len(list)) } } }