Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -3,6 +3,7 @@ package mailer
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -145,6 +146,36 @@ func (g *GraphMailer) Send(ctx context.Context, msg Message) error {
|
|||||||
message["replyTo"] = replyToRecipients
|
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{
|
payload := map[string]any{
|
||||||
"message": message,
|
"message": message,
|
||||||
"saveToSentItems": true,
|
"saveToSentItems": true,
|
||||||
|
|||||||
@@ -18,10 +18,17 @@ type Mailer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
To []string
|
To []string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
BodyHTML string
|
BodyHTML string
|
||||||
|
Attachments []Attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
FileName string
|
||||||
|
ContentType string
|
||||||
|
Data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config) *Mailer {
|
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/check/{id}", "GET", "view", routes.OrderExistsHandler(mssql)},
|
||||||
{"/api/order/validate", "POST", "insert", routes.ValidateOrderHandler(mssql)},
|
{"/api/order/validate", "POST", "insert", routes.ValidateOrderHandler(mssql)},
|
||||||
{"/api/order/pdf/{id}", "GET", "export", routes.OrderPDFHandler(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/order-inventory", "GET", "view", http.HandlerFunc(routes.GetOrderInventoryHandler)},
|
||||||
{"/api/orderpricelistb2b", "GET", "view", routes.GetOrderPriceListB2BHandler(pgDB, mssql)},
|
{"/api/orderpricelistb2b", "GET", "view", routes.GetOrderPriceListB2BHandler(pgDB, mssql)},
|
||||||
{"/api/min-price", "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-page v-if="canReadFinance" class="q-px-md q-pb-md q-pt-xs page-col statement-page">
|
||||||
<q-slide-transition>
|
<q-slide-transition>
|
||||||
<div v-show="!filtersCollapsed" class="local-filter-bar compact-filter q-pa-sm q-mb-xs">
|
<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 {
|
.currency-groups {
|
||||||
position: relative;
|
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
background: #f8faff;
|
background: #f8faff;
|
||||||
}
|
}
|
||||||
@@ -630,20 +629,18 @@ function formatAmount(value, fraction = 2) {
|
|||||||
line-height: 1.2 !important;
|
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) {
|
.detail-subtable :deep(.q-table__top) {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-subtable :deep(thead th) {
|
.detail-subtable :deep(thead) {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(var(--lvl3-top) - var(--lvl3-shift-up));
|
top: calc(var(--lvl3-top) - var(--lvl3-shift-up));
|
||||||
z-index: 35;
|
z-index: 31;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-subtable :deep(thead th) {
|
||||||
|
position: static;
|
||||||
background: #1f3b5b;
|
background: #1f3b5b;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
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) {
|
async downloadOrderPdf(id = null) {
|
||||||
try {
|
try {
|
||||||
const orderId = id || this.header?.OrderHeaderID
|
const orderId = id || this.header?.OrderHeaderID
|
||||||
@@ -2845,6 +2865,23 @@ export const useOrderEntryStore = defineStore('orderentry', {
|
|||||||
productCache
|
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
|
❓ USER NEXT STEP
|
||||||
======================================================= */
|
======================================================= */
|
||||||
|
|||||||
Reference in New Issue
Block a user