Files
bssapp/svc/routes/product_pricing_change_mail.go
2026-06-17 21:57:02 +03:00

266 lines
7.8 KiB
Go

package routes
import (
"context"
"database/sql"
"fmt"
"log"
"sort"
"strings"
"time"
"bssapp-backend/db"
"bssapp-backend/internal/mailer"
"bssapp-backend/models"
"bssapp-backend/queries"
)
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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
"'", "&#39;",
)
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(firstGroupCode string, rows []models.ProductPricing, actor string, at time.Time) string {
// Keep it simple: wide, scrollable table.
var b strings.Builder
// NOTE: Mail clients often render small fonts; keep this comfortably readable.
// Use large inline sizes (some clients still downscale); keep everything inline for maximum compatibility.
b.WriteString(`<div style="font-family:Segoe UI, Arial, sans-serif; font-size:18px; line-height:1.35; -webkit-text-size-adjust:100%;">`)
b.WriteString(`<div style="margin-bottom:10px;">`)
b.WriteString(`<div style="font-size:22px; margin-bottom:4px;"><b>Fiyat Degisikligi</b></div>`)
b.WriteString(`<div>Urun Ilk Grubu: <b>` + htmlEscapeMini(firstGroupCode) + `</b></div>`)
if strings.TrimSpace(actor) != "" {
b.WriteString(`<div>Islem Yapan: <b>` + htmlEscapeMini(actor) + `</b></div>`)
}
b.WriteString(`<div>Tarih: <b>` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `</b></div>`)
b.WriteString(`<div>Urun Sayisi: <b>` + fmt.Sprintf("%d", len(rows)) + `</b></div>`)
b.WriteString(`</div>`)
b.WriteString(`<div style="max-width:100%; overflow-x:auto;">`)
b.WriteString(`<table style="border-collapse:collapse; font-size:16px; white-space:nowrap;">`)
b.WriteString(`<thead><tr>`)
heads := []string{
"MARKA GRUBU", "MARKA", "BRAND CODE", "URUN KODU",
"STOK ADET", "STOK GIRIS", "SON MALIYET", "SON FIYAT",
"ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU", "ICERIK", "KARISIM",
"MALIYET FIYATI", "TABAN USD", "TABAN TRY",
"USD1", "USD2", "USD3", "USD4", "USD5", "USD6",
"EUR1", "EUR2", "EUR3", "EUR4", "EUR5", "EUR6",
"TRY1", "TRY2", "TRY3", "TRY4", "TRY5", "TRY6",
}
for _, h := range heads {
b.WriteString(`<th style="border:1px solid #d0d0d0; background:#f3f3f3; padding:8px 10px; text-align:left; font-size:16px;">` + htmlEscapeMini(h) + `</th>`)
}
b.WriteString(`</tr></thead><tbody>`)
for _, r := range rows {
b.WriteString(`<tr>`)
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.UrunIlkGrubu,
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),
}
for i, c := range cells {
align := "left"
// right align numeric-ish cells
if i >= 4 {
switch i {
case 4, 15, 16, 17,
18, 19, 20, 21, 22, 23,
24, 25, 26, 27, 28, 29,
30, 31, 32, 33, 34, 35:
align = "right"
}
}
b.WriteString(`<td style="border:1px solid #e0e0e0; padding:8px 10px; text-align:` + align + `;">` + htmlEscapeMini(strings.TrimSpace(c)) + `</td>`)
}
b.WriteString(`</tr>`)
}
b.WriteString(`</tbody></table></div>`)
b.WriteString(`<div style="margin-top:12px; font-size:14px; color:#666;">Bu e-posta BSSApp sistemi tarafindan otomatik olusturulmustur.</div>`)
b.WriteString(`</div>`)
return b.String()
}
// 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 | %s | %d urun", group, now.Format("02.01.2006 15:04"), len(list))
html := buildPricingChangeMailHTML(group, list, actor, now)
// 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,
})
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))
}
}
}