Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)},
|
||||
|
||||
258
svc/routes/order_mail.go
Normal file
258
svc/routes/order_mail.go
Normal file
@@ -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(
|
||||
`<p>Sipariş kaydı oluşturuldu/güncellendi.</p><p><b>Sipariş No:</b> %s<br/><b>Cari:</b> %s<br/><b>Piyasa:</b> %s</p><p>PDF ektedir.</p>`,
|
||||
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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<q-page v-if="canReadFinance" class="q-px-md q-pb-md q-pt-xs page-col statement-page">
|
||||
<q-slide-transition>
|
||||
<div v-show="!filtersCollapsed" class="local-filter-bar compact-filter q-pa-sm q-mb-xs">
|
||||
@@ -541,7 +541,6 @@ function formatAmount(value, fraction = 2) {
|
||||
}
|
||||
|
||||
.currency-groups {
|
||||
position: relative;
|
||||
padding: 6px;
|
||||
background: #f8faff;
|
||||
}
|
||||
@@ -630,20 +629,18 @@ function formatAmount(value, fraction = 2) {
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
|
||||
.detail-subtable :deep(.q-table__container),
|
||||
.detail-subtable :deep(.q-table__middle) {
|
||||
overflow: visible !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.detail-subtable :deep(.q-table__top) {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.detail-subtable :deep(thead th) {
|
||||
.detail-subtable :deep(thead) {
|
||||
position: sticky;
|
||||
top: calc(var(--lvl3-top) - var(--lvl3-shift-up));
|
||||
z-index: 35;
|
||||
z-index: 31;
|
||||
}
|
||||
|
||||
.detail-subtable :deep(thead th) {
|
||||
position: static;
|
||||
background: #1f3b5b;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
|
||||
@@ -400,6 +400,26 @@ export const useOrderEntryStore = defineStore('orderentry', {
|
||||
}
|
||||
,
|
||||
|
||||
async sendOrderToMarketMails(orderId) {
|
||||
const id = String(orderId || this.header?.OrderHeaderID || '').trim()
|
||||
if (!id) {
|
||||
throw new Error('Sipariş ID bulunamadı')
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.post('/order/send-market-mail', {
|
||||
orderHeaderID: id
|
||||
})
|
||||
return res?.data || {}
|
||||
} catch (err) {
|
||||
const detail = await extractApiErrorDetail(err)
|
||||
const status = err?.status || err?.response?.status || '-'
|
||||
console.error(`❌ sendOrderToMarketMails hata [${status}] order=${id}: ${detail}`)
|
||||
throw new Error(detail)
|
||||
}
|
||||
}
|
||||
,
|
||||
|
||||
async downloadOrderPdf(id = null) {
|
||||
try {
|
||||
const orderId = id || this.header?.OrderHeaderID
|
||||
@@ -2845,6 +2865,23 @@ export const useOrderEntryStore = defineStore('orderentry', {
|
||||
productCache
|
||||
})
|
||||
|
||||
// 📧 Piyasa eşleşen alıcılara sipariş PDF gönderimi (kayıt başarılı olduktan sonra)
|
||||
try {
|
||||
const mailRes = await this.sendOrderToMarketMails(serverOrderId)
|
||||
const sentCount = Number(mailRes?.sentCount || 0)
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: sentCount > 0
|
||||
? `Sipariş PDF mail gönderildi (${sentCount} alıcı)`
|
||||
: 'Sipariş PDF mail gönderimi tamamlandı'
|
||||
})
|
||||
} catch (mailErr) {
|
||||
$q.notify({
|
||||
type: 'warning',
|
||||
message: `Sipariş kaydedildi, mail gönderilemedi: ${mailErr?.message || 'Bilinmeyen hata'}`
|
||||
})
|
||||
}
|
||||
|
||||
/* =======================================================
|
||||
❓ USER NEXT STEP
|
||||
======================================================= */
|
||||
|
||||
Reference in New Issue
Block a user