diff --git a/svc/internal/mailer/graph_mailer.go b/svc/internal/mailer/graph_mailer.go index 0d1c9da..5cef4fb 100644 --- a/svc/internal/mailer/graph_mailer.go +++ b/svc/internal/mailer/graph_mailer.go @@ -3,6 +3,7 @@ package mailer import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -145,6 +146,36 @@ func (g *GraphMailer) Send(ctx context.Context, msg Message) error { message["replyTo"] = replyToRecipients } + if len(msg.Attachments) > 0 { + atts := make([]map[string]any, 0, len(msg.Attachments)) + for _, a := range msg.Attachments { + if len(a.Data) == 0 { + continue + } + + name := strings.TrimSpace(a.FileName) + if name == "" { + name = "attachment.bin" + } + + contentType := strings.TrimSpace(a.ContentType) + if contentType == "" { + contentType = "application/octet-stream" + } + + atts = append(atts, map[string]any{ + "@odata.type": "#microsoft.graph.fileAttachment", + "name": name, + "contentType": contentType, + "contentBytes": base64.StdEncoding.EncodeToString(a.Data), + }) + } + + if len(atts) > 0 { + message["attachments"] = atts + } + } + payload := map[string]any{ "message": message, "saveToSentItems": true, diff --git a/svc/internal/mailer/mailer.go b/svc/internal/mailer/mailer.go index c7f32b5..9ca372b 100644 --- a/svc/internal/mailer/mailer.go +++ b/svc/internal/mailer/mailer.go @@ -18,10 +18,17 @@ type Mailer struct { } type Message struct { - To []string - Subject string - Body string - BodyHTML string + To []string + Subject string + Body string + BodyHTML string + Attachments []Attachment +} + +type Attachment struct { + FileName string + ContentType string + Data []byte } func New(cfg Config) *Mailer { diff --git a/svc/main.go b/svc/main.go index 02823d2..371e379 100644 --- a/svc/main.go +++ b/svc/main.go @@ -527,6 +527,7 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router {"/api/order/check/{id}", "GET", "view", routes.OrderExistsHandler(mssql)}, {"/api/order/validate", "POST", "insert", routes.ValidateOrderHandler(mssql)}, {"/api/order/pdf/{id}", "GET", "export", routes.OrderPDFHandler(mssql)}, + {"/api/order/send-market-mail", "POST", "update", routes.SendOrderMarketMailHandler(pgDB, mssql, ml)}, {"/api/order-inventory", "GET", "view", http.HandlerFunc(routes.GetOrderInventoryHandler)}, {"/api/orderpricelistb2b", "GET", "view", routes.GetOrderPriceListB2BHandler(pgDB, mssql)}, {"/api/min-price", "GET", "view", routes.GetOrderPriceListB2BHandler(pgDB, mssql)}, diff --git a/svc/routes/order_mail.go b/svc/routes/order_mail.go new file mode 100644 index 0000000..a6e4793 --- /dev/null +++ b/svc/routes/order_mail.go @@ -0,0 +1,258 @@ +package routes + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + + "bssapp-backend/internal/mailer" +) + +type sendOrderMarketMailPayload struct { + OrderHeaderID string `json:"orderHeaderID"` +} + +func SendOrderMarketMailHandler(pg *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + if ml == nil { + http.Error(w, "mailer not initialized", http.StatusServiceUnavailable) + return + } + if pg == nil || mssql == nil { + http.Error(w, "database not initialized", http.StatusInternalServerError) + return + } + + var payload sendOrderMarketMailPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + orderID := strings.TrimSpace(payload.OrderHeaderID) + if orderID == "" { + http.Error(w, "orderHeaderID is required", http.StatusBadRequest) + return + } + + orderNo, currAccCode, marketCode, marketTitle, err := resolveOrderMailContext(mssql, orderID) + if err != nil { + http.Error(w, "order context error: "+err.Error(), http.StatusInternalServerError) + return + } + if strings.TrimSpace(marketCode) == "" && strings.TrimSpace(marketTitle) == "" { + http.Error(w, "market not found for order/cari", http.StatusBadRequest) + return + } + + recipients, err := loadMarketRecipients(pg, marketCode, marketTitle) + if err != nil { + http.Error(w, "recipient query error: "+err.Error(), http.StatusInternalServerError) + return + } + if len(recipients) == 0 { + http.Error(w, "no active email mapping for market", http.StatusBadRequest) + return + } + + pdfBytes, header, err := buildOrderPDFBytesForMail(mssql, orderID) + if err != nil { + http.Error(w, "pdf build error: "+err.Error(), http.StatusInternalServerError) + return + } + + number := strings.TrimSpace(orderNo) + if number == "" && header != nil { + number = strings.TrimSpace(header.OrderNumber) + } + if number == "" { + number = orderID + } + + marketLabel := strings.TrimSpace(marketTitle) + if marketLabel == "" { + marketLabel = strings.TrimSpace(marketCode) + } + + subject := fmt.Sprintf("Sipariş %s - %s", number, marketLabel) + bodyHTML := fmt.Sprintf( + `

Sipariş kaydı oluşturuldu/güncellendi.

Sipariş No: %s
Cari: %s
Piyasa: %s

PDF ektedir.

`, + htmlEsc(number), + htmlEsc(currAccCode), + htmlEsc(marketLabel), + ) + + fileNo := sanitizeFileName(number) + if fileNo == "" { + fileNo = orderID + } + + msg := mailer.Message{ + To: recipients, + Subject: subject, + BodyHTML: bodyHTML, + Attachments: []mailer.Attachment{ + { + FileName: "ORDER_" + fileNo + ".pdf", + ContentType: "application/pdf", + Data: pdfBytes, + }, + }, + } + + if err := ml.Send(context.Background(), msg); err != nil { + http.Error(w, "mail send error: "+err.Error(), http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "orderHeaderID": orderID, + "orderNumber": number, + "marketCode": marketCode, + "marketTitle": marketTitle, + "recipients": recipients, + "sentCount": len(recipients), + }) + } +} + +func resolveOrderMailContext(db *sql.DB, orderID string) (orderNo, currAccCode, marketCode, marketTitle string, err error) { + row := db.QueryRow(` +SELECT TOP (1) + ISNULL(h.OrderNumber, ''), + ISNULL(h.CurrAccCode, ''), + ISNULL(LTRIM(RTRIM(f.CustomerAtt01)), '') AS MarketCode, + ISNULL(py.AttributeDescription, '') AS MarketTitle +FROM BAGGI_V3.dbo.trOrderHeader h WITH (NOLOCK) +LEFT JOIN BAGGI_V3.dbo.CustomerAttributesFilter f WITH (NOLOCK) + ON f.CurrAccCode = h.CurrAccCode +LEFT JOIN BAGGI_V3.dbo.cdCurrAccAttributeDesc py WITH (NOLOCK) + ON py.CurrAccTypeCode = h.CurrAccTypeCode + AND py.AttributeTypeCode = 1 + AND py.AttributeCode = f.CustomerAtt01 + AND py.LangCode = 'TR' +WHERE CAST(h.OrderHeaderID AS varchar(36)) = @p1 +`, orderID) + + var no, cari, pCode, pTitle string + if err = row.Scan(&no, &cari, &pCode, &pTitle); err != nil { + return "", "", "", "", err + } + + no = strings.TrimSpace(no) + cari = strings.TrimSpace(cari) + pCode = strings.TrimSpace(pCode) + pTitle = strings.TrimSpace(pTitle) + + return no, cari, pCode, pTitle, nil +} + +func loadMarketRecipients(pg *sql.DB, marketCode, marketTitle string) ([]string, error) { + rows, err := pg.Query(` +SELECT DISTINCT TRIM(m.email) AS email +FROM mk_sales_piy p +JOIN mk_market_mail mm + ON mm.market_id = p.id +JOIN mk_mail m + ON m.id = mm.mail_id +WHERE p.is_active = true + AND m.is_active = true + AND ( + UPPER(TRIM(p.code)) = UPPER(TRIM($1)) + OR ($2 <> '' AND UPPER(TRIM(p.title)) = UPPER(TRIM($2))) + ) + AND COALESCE(TRIM(m.email), '') <> '' +ORDER BY email +`, strings.TrimSpace(marketCode), strings.TrimSpace(marketTitle)) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]string, 0, 8) + 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) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func buildOrderPDFBytesForMail(db *sql.DB, orderID string) ([]byte, *OrderHeader, error) { + header, err := getOrderHeaderFromDB(db, orderID) + if err != nil { + return nil, nil, err + } + + lines, err := getOrderLinesFromDB(db, orderID) + if err != nil { + return nil, nil, err + } + + hasVat := false + var vatRate float64 + for _, l := range lines { + if l.VatRate.Valid && l.VatRate.Float64 > 0 { + hasVat = true + vatRate = l.VatRate.Float64 + break + } + } + + rows := normalizeOrderLinesForPdf(lines) + + pdf, err := newOrderPdf() + if err != nil { + return nil, nil, err + } + + renderOrderGrid(pdf, header, rows, hasVat, vatRate) + if err := pdf.Error(); err != nil { + return nil, nil, err + } + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, nil, err + } + + return buf.Bytes(), header, nil +} + +func sanitizeFileName(v string) string { + s := strings.TrimSpace(v) + if s == "" { + return "" + } + invalid := []string{`\\`, `/`, `:`, `*`, `?`, `"`, `<`, `>`, `|`} + for _, bad := range invalid { + s = strings.ReplaceAll(s, bad, "_") + } + return strings.TrimSpace(s) +} + +func htmlEsc(s string) string { + r := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + ) + return r.Replace(s) +} diff --git a/ui/quasar.config.js.temporary.compiled.1773759477667.mjs b/ui/quasar.config.js.temporary.compiled.1774248846219.mjs similarity index 100% rename from ui/quasar.config.js.temporary.compiled.1773759477667.mjs rename to ui/quasar.config.js.temporary.compiled.1774248846219.mjs diff --git a/ui/src/pages/AccountAgingStatement.vue b/ui/src/pages/AccountAgingStatement.vue index 578a9de..5868bb3 100644 --- a/ui/src/pages/AccountAgingStatement.vue +++ b/ui/src/pages/AccountAgingStatement.vue @@ -1,4 +1,4 @@ -