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