Merge remote-tracking branch 'origin/master'
This commit is contained in:
30
svc/main.go
30
svc/main.go
@@ -785,6 +785,36 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
|||||||
"order", "view",
|
"order", "view",
|
||||||
http.HandlerFunc(routes.GetProductImageContentHandler(pgDB)),
|
http.HandlerFunc(routes.GetProductImageContentHandler(pgDB)),
|
||||||
)
|
)
|
||||||
|
bindV3(r, pgDB,
|
||||||
|
"/api/order/price-list/products", "GET",
|
||||||
|
"order", "view",
|
||||||
|
wrapV3(http.HandlerFunc(routes.GetProductPricingListHandler)),
|
||||||
|
)
|
||||||
|
bindV3(r, pgDB,
|
||||||
|
"/api/order/price-list/options", "GET",
|
||||||
|
"order", "view",
|
||||||
|
wrapV3(http.HandlerFunc(routes.GetProductPricingFilterOptionsHandler)),
|
||||||
|
)
|
||||||
|
bindV3(r, pgDB,
|
||||||
|
"/api/order/price-list/campaigns", "GET",
|
||||||
|
"order", "view",
|
||||||
|
wrapV3(routes.GetWholesaleCampaignsHandler(pgDB)),
|
||||||
|
)
|
||||||
|
bindV3(r, pgDB,
|
||||||
|
"/api/order/price-list/variant-rows", "GET",
|
||||||
|
"order", "view",
|
||||||
|
wrapV3(routes.GetWholesaleCampaignVariantRowsHandler(pgDB, mssql)),
|
||||||
|
)
|
||||||
|
bindV3(r, pgDB,
|
||||||
|
"/api/order/price-list/export-excel", "POST",
|
||||||
|
"order", "view",
|
||||||
|
wrapV3(http.HandlerFunc(routes.ExportProductPriceListExcelHandler(pgDB))),
|
||||||
|
)
|
||||||
|
bindV3(r, pgDB,
|
||||||
|
"/api/order/price-list/export-pdf", "POST",
|
||||||
|
"order", "view",
|
||||||
|
wrapV3(http.HandlerFunc(routes.ExportProductPriceListPDFHandler(pgDB))),
|
||||||
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/product-size-match/rules", "GET",
|
"/api/product-size-match/rules", "GET",
|
||||||
"order", "view",
|
"order", "view",
|
||||||
|
|||||||
51
svc/routes/mail_pdf_table.go
Normal file
51
svc/routes/mail_pdf_table.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jung-kurt/gofpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writePDFTableHeader(pdf *gofpdf.Fpdf, font string, heads []string, widths []float64) {
|
||||||
|
pdf.SetFont(font, "B", 5.9)
|
||||||
|
pdf.SetFillColor(235, 235, 235)
|
||||||
|
for i, h := range heads {
|
||||||
|
w := 12.0
|
||||||
|
if i < len(widths) {
|
||||||
|
w = widths[i]
|
||||||
|
}
|
||||||
|
pdf.CellFormat(w, 4.8, fitPDFCellText(pdf, h, w-1.2), "1", 0, "L", true, 0, "")
|
||||||
|
}
|
||||||
|
pdf.Ln(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePDFTableRow(pdf *gofpdf.Fpdf, cells []string, widths []float64, aligns []string, height float64) {
|
||||||
|
for i, c := range cells {
|
||||||
|
w := 12.0
|
||||||
|
if i < len(widths) {
|
||||||
|
w = widths[i]
|
||||||
|
}
|
||||||
|
align := "L"
|
||||||
|
if i < len(aligns) && strings.TrimSpace(aligns[i]) != "" {
|
||||||
|
align = aligns[i]
|
||||||
|
}
|
||||||
|
pdf.CellFormat(w, height, fitPDFCellText(pdf, strings.TrimSpace(c), w-1.2), "1", 0, align, false, 0, "")
|
||||||
|
}
|
||||||
|
pdf.Ln(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fitPDFCellText(pdf *gofpdf.Fpdf, s string, maxWidth float64) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" || pdf.GetStringWidth(s) <= maxWidth {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
r := []rune(s)
|
||||||
|
for len(r) > 0 {
|
||||||
|
candidate := string(r) + "..."
|
||||||
|
if pdf.GetStringWidth(candidate) <= maxWidth {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
r = r[:len(r)-1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -13,6 +14,8 @@ import (
|
|||||||
"bssapp-backend/internal/mailer"
|
"bssapp-backend/internal/mailer"
|
||||||
"bssapp-backend/models"
|
"bssapp-backend/models"
|
||||||
"bssapp-backend/queries"
|
"bssapp-backend/queries"
|
||||||
|
|
||||||
|
"github.com/jung-kurt/gofpdf"
|
||||||
)
|
)
|
||||||
|
|
||||||
func loadPricingRecipients(pg *sql.DB, firstGroupCode string) ([]string, error) {
|
func loadPricingRecipients(pg *sql.DB, firstGroupCode string) ([]string, error) {
|
||||||
@@ -79,42 +82,77 @@ func fmtDateTRFromISO(d string) string {
|
|||||||
return day + "." + m + "." + y
|
return day + "." + m + "." + y
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPricingChangeMailHTML(firstGroupCode string, rows []models.ProductPricing, actor string, at time.Time) string {
|
func buildPricingChangeMailHTML(rows []models.ProductPricing, actor string, at time.Time) string {
|
||||||
// Keep it simple: wide, scrollable table.
|
|
||||||
var b strings.Builder
|
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="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="margin-bottom:10px;">`)
|
||||||
b.WriteString(`<div style="font-size:22px; margin-bottom:4px;"><b>Fiyat Degisikligi</b></div>`)
|
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) != "" {
|
if strings.TrimSpace(actor) != "" {
|
||||||
b.WriteString(`<div>Islem Yapan: <b>` + htmlEscapeMini(actor) + `</b></div>`)
|
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>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>Urun Sayisi: <b>` + fmt.Sprintf("%d", len(rows)) + `</b></div>`)
|
||||||
|
b.WriteString(`<div style="margin-top:10px;">Detaylar ekteki PDF dosyasindadir.</div>`)
|
||||||
b.WriteString(`</div>`)
|
b.WriteString(`</div>`)
|
||||||
|
b.WriteString(`</div>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
b.WriteString(`<div style="max-width:100%; overflow-x:auto;">`)
|
func buildPricingChangeMailPDF(rows []models.ProductPricing, actor string, at time.Time) ([]byte, error) {
|
||||||
b.WriteString(`<table style="border-collapse:collapse; font-size:16px; white-space:nowrap;">`)
|
pdf := gofpdf.New("L", "mm", "A2", "")
|
||||||
b.WriteString(`<thead><tr>`)
|
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{
|
heads := []string{
|
||||||
"MARKA GRUBU", "MARKA", "BRAND CODE", "URUN KODU",
|
"MARKA GRUBU", "MARKA", "BRAND", "URUN KODU",
|
||||||
"STOK ADET", "STOK GIRIS", "SON MALIYET", "SON FIYAT",
|
"STOK", "STOK GIRIS", "SON MALIYET", "SON FIYAT",
|
||||||
"ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU", "ICERIK", "KARISIM",
|
"ASKILI", "KATEGORI", "URUN ANA GRUBU", "URUN ALT GRUBU", "ICERIK", "KARISIM",
|
||||||
"MALIYET FIYATI", "TABAN USD", "TABAN TRY",
|
"MALIYET", "TABAN USD", "TABAN TRY",
|
||||||
"USD1", "USD2", "USD3", "USD4", "USD5", "USD6",
|
"USD1", "USD2", "USD3", "USD4", "USD5", "USD6",
|
||||||
"EUR1", "EUR2", "EUR3", "EUR4", "EUR5", "EUR6",
|
"EUR1", "EUR2", "EUR3", "EUR4", "EUR5", "EUR6",
|
||||||
"TRY1", "TRY2", "TRY3", "TRY4", "TRY5", "TRY6",
|
"TRY1", "TRY2", "TRY3", "TRY4", "TRY5", "TRY6",
|
||||||
}
|
}
|
||||||
for _, h := range heads {
|
widths := []float64{
|
||||||
b.WriteString(`<th style="border:1px solid #d0d0d0; background:#f3f3f3; padding:8px 10px; text-align:left; font-size:16px;">` + htmlEscapeMini(h) + `</th>`)
|
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,
|
||||||
}
|
}
|
||||||
b.WriteString(`</tr></thead><tbody>`)
|
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 {
|
for _, r := range rows {
|
||||||
b.WriteString(`<tr>`)
|
if pdf.GetY() > 405 {
|
||||||
|
pdf.AddPage()
|
||||||
|
writePDFTableHeader(pdf, font, heads, widths)
|
||||||
|
pdf.SetFont(font, "", 5.7)
|
||||||
|
}
|
||||||
cells := []string{
|
cells := []string{
|
||||||
r.BrandGroupSec,
|
r.BrandGroupSec,
|
||||||
r.Marka,
|
r.Marka,
|
||||||
@@ -126,7 +164,6 @@ func buildPricingChangeMailHTML(firstGroupCode string, rows []models.ProductPric
|
|||||||
fmtDateTRFromISO(r.LastPricingDate),
|
fmtDateTRFromISO(r.LastPricingDate),
|
||||||
r.AskiliYan,
|
r.AskiliYan,
|
||||||
r.Kategori,
|
r.Kategori,
|
||||||
r.UrunIlkGrubu,
|
|
||||||
r.UrunAnaGrubu,
|
r.UrunAnaGrubu,
|
||||||
r.UrunAltGrubu,
|
r.UrunAltGrubu,
|
||||||
r.Icerik,
|
r.Icerik,
|
||||||
@@ -138,27 +175,14 @@ func buildPricingChangeMailHTML(firstGroupCode string, rows []models.ProductPric
|
|||||||
fmtMoneyMail(r.EUR1), fmtMoneyMail(r.EUR2), fmtMoneyMail(r.EUR3), fmtMoneyMail(r.EUR4), fmtMoneyMail(r.EUR5), fmtMoneyMail(r.EUR6),
|
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),
|
fmtMoneyMail(r.TRY1), fmtMoneyMail(r.TRY2), fmtMoneyMail(r.TRY3), fmtMoneyMail(r.TRY4), fmtMoneyMail(r.TRY5), fmtMoneyMail(r.TRY6),
|
||||||
}
|
}
|
||||||
for i, c := range cells {
|
writePDFTableRow(pdf, cells, widths, aligns, 4.4)
|
||||||
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>`)
|
var buf bytes.Buffer
|
||||||
b.WriteString(`<div style="margin-top:12px; font-size:14px; color:#666;">Bu e-posta BSSApp sistemi tarafindan otomatik olusturulmustur.</div>`)
|
if err := pdf.Output(&buf); err != nil {
|
||||||
b.WriteString(`</div>`)
|
return nil, err
|
||||||
return b.String()
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendPricingChangeMails sends one mail per UrunIlkGrubu (group) based on mk_pricing_first_group_mail mapping.
|
// sendPricingChangeMails sends one mail per UrunIlkGrubu (group) based on mk_pricing_first_group_mail mapping.
|
||||||
@@ -233,8 +257,18 @@ func sendPricingChangeMails(bg context.Context, ml *mailer.GraphMailer, productC
|
|||||||
return strings.TrimSpace(list[i].ProductCode) < strings.TrimSpace(list[j].ProductCode)
|
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))
|
subject := fmt.Sprintf("Fiyat Degisikligi | %s | %d urun", now.Format("02.01.2006 15:04"), len(list))
|
||||||
html := buildPricingChangeMailHTML(group, list, actor, now)
|
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.
|
// Retry 2 times with backoff.
|
||||||
backoff := []time.Duration{800 * time.Millisecond, 2500 * time.Millisecond}
|
backoff := []time.Duration{800 * time.Millisecond, 2500 * time.Millisecond}
|
||||||
@@ -248,6 +282,7 @@ func sendPricingChangeMails(bg context.Context, ml *mailer.GraphMailer, productC
|
|||||||
To: recipients,
|
To: recipients,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
BodyHTML: html,
|
BodyHTML: html,
|
||||||
|
Attachments: attachments,
|
||||||
})
|
})
|
||||||
stepCancel()
|
stepCancel()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"bssapp-backend/models"
|
"bssapp-backend/models"
|
||||||
"bssapp-backend/queries"
|
"bssapp-backend/queries"
|
||||||
|
|
||||||
|
"github.com/jung-kurt/gofpdf"
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,22 +33,43 @@ type wholesaleCampaignMailRow struct {
|
|||||||
DiscountRate float64
|
DiscountRate float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildWholesaleCampaignChangeMailHTML(firstGroupCode string, rows []wholesaleCampaignMailRow, actor string, at time.Time) string {
|
func buildWholesaleCampaignChangeMailHTML(rows []wholesaleCampaignMailRow, actor string, at time.Time) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
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="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="margin-bottom:10px;">`)
|
||||||
b.WriteString(`<div style="font-size:22px; margin-bottom:4px;"><b>Kampanya Degisikligi</b></div>`)
|
b.WriteString(`<div style="font-size:22px; margin-bottom:4px;"><b>Kampanya Degisikligi</b></div>`)
|
||||||
b.WriteString(`<div>Urun Ilk Grubu: <b>` + htmlEscapeMini(firstGroupCode) + `</b></div>`)
|
|
||||||
if strings.TrimSpace(actor) != "" {
|
if strings.TrimSpace(actor) != "" {
|
||||||
b.WriteString(`<div>Islem Yapan: <b>` + htmlEscapeMini(actor) + `</b></div>`)
|
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>Tarih: <b>` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `</b></div>`)
|
||||||
b.WriteString(`<div>Varyant Sayisi: <b>` + fmt.Sprintf("%d", len(rows)) + `</b></div>`)
|
b.WriteString(`<div>Varyant Sayisi: <b>` + fmt.Sprintf("%d", len(rows)) + `</b></div>`)
|
||||||
|
b.WriteString(`<div style="margin-top:10px;">Detaylar ekteki PDF dosyasindadir.</div>`)
|
||||||
b.WriteString(`</div>`)
|
b.WriteString(`</div>`)
|
||||||
|
b.WriteString(`</div>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWholesaleCampaignChangeMailPDF(rows []wholesaleCampaignMailRow, actor string, at time.Time) ([]byte, error) {
|
||||||
|
pdf := gofpdf.New("L", "mm", "A4", "")
|
||||||
|
font := "Arial"
|
||||||
|
if err := registerDejavuFonts(pdf, "dejavu"); err == nil {
|
||||||
|
font = "dejavu"
|
||||||
|
} else {
|
||||||
|
log.Printf("[campaign-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, "Kampanya 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("Varyant Sayisi: %d", len(rows)), "", 1, "L", false, 0, "")
|
||||||
|
pdf.Ln(2)
|
||||||
|
|
||||||
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{
|
heads := []string{
|
||||||
"MARKA GRUBU",
|
"MARKA GRUBU",
|
||||||
"MARKA",
|
"MARKA",
|
||||||
@@ -56,13 +79,17 @@ func buildWholesaleCampaignChangeMailHTML(firstGroupCode string, rows []wholesal
|
|||||||
"KAMPANYA",
|
"KAMPANYA",
|
||||||
"IND %",
|
"IND %",
|
||||||
}
|
}
|
||||||
for _, h := range heads {
|
widths := []float64{35, 26, 35, 20, 20, 118, 16}
|
||||||
b.WriteString(`<th style="border:1px solid #d0d0d0; background:#f3f3f3; padding:8px 10px; text-align:left; font-size:16px;">` + htmlEscapeMini(h) + `</th>`)
|
aligns := []string{"L", "L", "L", "R", "R", "L", "R"}
|
||||||
}
|
writePDFTableHeader(pdf, font, heads, widths)
|
||||||
b.WriteString(`</tr></thead><tbody>`)
|
|
||||||
|
|
||||||
|
pdf.SetFont(font, "", 7)
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
b.WriteString(`<tr>`)
|
if pdf.GetY() > 190 {
|
||||||
|
pdf.AddPage()
|
||||||
|
writePDFTableHeader(pdf, font, heads, widths)
|
||||||
|
pdf.SetFont(font, "", 7)
|
||||||
|
}
|
||||||
campaignLabel := strings.TrimSpace(r.CampaignCode)
|
campaignLabel := strings.TrimSpace(r.CampaignCode)
|
||||||
if t := strings.TrimSpace(r.CampaignTitle); t != "" {
|
if t := strings.TrimSpace(r.CampaignTitle); t != "" {
|
||||||
if campaignLabel != "" {
|
if campaignLabel != "" {
|
||||||
@@ -85,20 +112,14 @@ func buildWholesaleCampaignChangeMailHTML(firstGroupCode string, rows []wholesal
|
|||||||
campaignLabel,
|
campaignLabel,
|
||||||
fmt.Sprintf("%.2f", r.DiscountRate),
|
fmt.Sprintf("%.2f", r.DiscountRate),
|
||||||
}
|
}
|
||||||
for i, c := range cells {
|
writePDFTableRow(pdf, cells, widths, aligns, 5)
|
||||||
align := "left"
|
|
||||||
if i == 3 || i == 4 || i == 6 {
|
|
||||||
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>`)
|
var buf bytes.Buffer
|
||||||
b.WriteString(`<div style="margin-top:12px; font-size:14px; color:#666;">Bu e-posta BSSApp sistemi tarafindan otomatik olusturulmustur.</div>`)
|
if err := pdf.Output(&buf); err != nil {
|
||||||
b.WriteString(`</div>`)
|
return nil, err
|
||||||
return b.String()
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendWholesaleCampaignChangeMails sends one mail per UrunIlkGrubu using existing pricing mail mapping tables.
|
// sendWholesaleCampaignChangeMails sends one mail per UrunIlkGrubu using existing pricing mail mapping tables.
|
||||||
@@ -334,14 +355,25 @@ ORDER BY dim_id, updated_at DESC;
|
|||||||
return list[i].Dim3 < list[j].Dim3
|
return list[i].Dim3 < list[j].Dim3
|
||||||
})
|
})
|
||||||
|
|
||||||
subject := fmt.Sprintf("Kampanya Degisikligi | %s | %s | %d varyant", group, now.Format("02.01.2006 15:04"), len(list))
|
subject := fmt.Sprintf("Kampanya Degisikligi | %s | %d varyant", now.Format("02.01.2006 15:04"), len(list))
|
||||||
html := buildWholesaleCampaignChangeMailHTML(group, list, actor, now)
|
html := buildWholesaleCampaignChangeMailHTML(list, actor, now)
|
||||||
|
attachments := []mailer.Attachment(nil)
|
||||||
|
if data, err := buildWholesaleCampaignChangeMailPDF(list, actor, now); err != nil {
|
||||||
|
log.Printf("[campaign-mail] pdf build failed group=%s err=%v", group, err)
|
||||||
|
} else {
|
||||||
|
attachments = append(attachments, mailer.Attachment{
|
||||||
|
FileName: fmt.Sprintf("kampanya-degisikligi-%s.pdf", now.Format("20060102-1504")),
|
||||||
|
ContentType: "application/pdf",
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
stepCtx, stepCancel := context.WithTimeout(bg, 25*time.Second)
|
stepCtx, stepCancel := context.WithTimeout(bg, 25*time.Second)
|
||||||
err = ml.Send(stepCtx, mailer.Message{
|
err = ml.Send(stepCtx, mailer.Message{
|
||||||
To: recipients,
|
To: recipients,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
BodyHTML: html,
|
BodyHTML: html,
|
||||||
|
Attachments: attachments,
|
||||||
})
|
})
|
||||||
stepCancel()
|
stepCancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -294,6 +294,11 @@ const menuItems = [
|
|||||||
to: '/app/order-gateway',
|
to: '/app/order-gateway',
|
||||||
permission: 'order:view'
|
permission: 'order:view'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Fiyat Listesi',
|
||||||
|
to: '/app/order-price-list',
|
||||||
|
permission: 'order:view'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Üretime Verilen Siparişleri Güncelle',
|
label: 'Üretime Verilen Siparişleri Güncelle',
|
||||||
to: '/app/orderproductionupdate',
|
to: '/app/orderproductionupdate',
|
||||||
|
|||||||
757
ui/src/pages/OrderPriceList.vue
Normal file
757
ui/src/pages/OrderPriceList.vue
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
<template>
|
||||||
|
<q-page class="q-pa-xs pricing-page order-price-list-page">
|
||||||
|
<teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="pageBusy"
|
||||||
|
class="page-busy-overlay"
|
||||||
|
@click.stop
|
||||||
|
@mousedown.stop
|
||||||
|
@mouseup.stop
|
||||||
|
@touchstart.stop
|
||||||
|
@wheel.stop
|
||||||
|
>
|
||||||
|
<q-spinner-gears size="56px" color="primary" />
|
||||||
|
<div class="page-busy-label">Yukleniyor...</div>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
|
||||||
|
<div class="top-bar row items-center justify-between q-mb-xs">
|
||||||
|
<div class="text-subtitle1 text-weight-bold">Fiyat Listesi</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<div class="row items-center q-gutter-xs top-actions-row top-actions-row--filters">
|
||||||
|
<q-select
|
||||||
|
v-model="topUrunIlkGrubu"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
clearable
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
use-input
|
||||||
|
:options="topUrunIlkGrubuOptions"
|
||||||
|
:loading="Boolean(serverFilterLoading.urunIlkGrubu)"
|
||||||
|
:disable="pageBusy"
|
||||||
|
label="Urun Ilk Grubu"
|
||||||
|
style="min-width: 220px"
|
||||||
|
@filter="onTopFilterSearchUrunIlkGrubu"
|
||||||
|
@update:model-value="onTopUrunIlkGrubuChange"
|
||||||
|
/>
|
||||||
|
<q-select
|
||||||
|
v-model="topUrunAnaGrubu"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
clearable
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
use-input
|
||||||
|
:options="topUrunAnaGrubuOptions"
|
||||||
|
:loading="Boolean(serverFilterLoading.urunAnaGrubu)"
|
||||||
|
:disable="pageBusy"
|
||||||
|
label="Urun Ana Grubu"
|
||||||
|
style="min-width: 240px"
|
||||||
|
@filter="onTopFilterSearchUrunAnaGrubu"
|
||||||
|
@update:model-value="onTopUrunAnaGrubuChange"
|
||||||
|
/>
|
||||||
|
<q-select
|
||||||
|
v-model="selectedProductCodes"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
multiple
|
||||||
|
use-chips
|
||||||
|
use-input
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
:options="productCodeOptions"
|
||||||
|
:loading="Boolean(serverFilterLoading.productCode)"
|
||||||
|
:disable="pageBusy"
|
||||||
|
label="Urun Kodu"
|
||||||
|
style="min-width: 320px"
|
||||||
|
@filter="onProductCodeSearch"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
icon="filter_alt"
|
||||||
|
label="Listeyi Getir"
|
||||||
|
:disable="pageBusy || !canFetch"
|
||||||
|
:loading="loading"
|
||||||
|
@click="reloadData({ page: 1 })"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="grey-7"
|
||||||
|
icon="restart_alt"
|
||||||
|
label="Secimleri Sifirla"
|
||||||
|
:disable="pageBusy"
|
||||||
|
@click="resetSelections"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row items-center q-gutter-xs top-actions-row top-actions-row--actions">
|
||||||
|
<q-btn-dropdown dense color="secondary" outline icon="view_module" label="Fiyat Secimi" :auto-close="false" :disable="pageBusy">
|
||||||
|
<q-list dense class="currency-menu-list">
|
||||||
|
<q-item clickable @click="selectAllPrices">
|
||||||
|
<q-item-section>Tumunu Sec</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable @click="clearAllPrices">
|
||||||
|
<q-item-section>Tumunu Temizle</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-separator />
|
||||||
|
<q-item v-for="option in priceOptions" :key="option.value" clickable @click="togglePriceOption(option.value)">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-checkbox
|
||||||
|
dense
|
||||||
|
:model-value="selectedPriceSet.has(option.value)"
|
||||||
|
:disable="pageBusy"
|
||||||
|
@click.stop
|
||||||
|
@update:model-value="() => togglePriceOption(option.value)"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>{{ option.label }}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-btn-dropdown>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
color="grey-8"
|
||||||
|
icon="view_sidebar"
|
||||||
|
:label="leftDetailsExpanded ? 'Detaylari Gizle' : 'Detaylari Goster'"
|
||||||
|
:disable="pageBusy"
|
||||||
|
@click="leftDetailsExpanded = !leftDetailsExpanded"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-btn-dropdown dense color="primary" outline icon="download" label="Cikti Al" :disable="pageBusy || filteredRows.length === 0">
|
||||||
|
<q-list dense style="min-width: 220px;">
|
||||||
|
<q-item clickable @click="exportVisibleExcel">
|
||||||
|
<q-item-section avatar><q-icon name="grid_on" /></q-item-section>
|
||||||
|
<q-item-section>Excel'e Aktar</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable @click="printVisibleRows">
|
||||||
|
<q-item-section avatar><q-icon name="picture_as_pdf" /></q-item-section>
|
||||||
|
<q-item-section>PDF / Yazdir</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-btn-dropdown>
|
||||||
|
|
||||||
|
<q-space />
|
||||||
|
|
||||||
|
<q-pagination
|
||||||
|
v-model="currentPage"
|
||||||
|
color="primary"
|
||||||
|
:max="Math.max(1, totalPages || 1)"
|
||||||
|
:max-pages="8"
|
||||||
|
boundary-links
|
||||||
|
direction-links
|
||||||
|
:disable="pageBusy"
|
||||||
|
@update:model-value="onPageChange"
|
||||||
|
/>
|
||||||
|
<div class="text-caption text-grey-8">
|
||||||
|
Sayfa {{ currentPage }} / {{ Math.max(1, totalPages || 1) }} - {{ filteredRows.length }} satir
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap" :style="{ '--sticky-scroll-comp': `${stickyScrollComp}px` }">
|
||||||
|
<div v-if="showGuidanceOverlay" class="empty-overlay">
|
||||||
|
<div class="empty-overlay-inner">
|
||||||
|
<div class="text-subtitle1 text-weight-bold">Liste Icin Filtre Secin</div>
|
||||||
|
<div class="text-body2 q-mt-xs">
|
||||||
|
Urun Ilk Grubu, Urun Ana Grubu veya Urun Kodu secip <b>LISTEYI GETIR</b>'e basin.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
class="pane-table pricing-table order-price-list-table"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
row-key="rowKey"
|
||||||
|
:rows="filteredRows"
|
||||||
|
:columns="visibleColumns"
|
||||||
|
:loading="loading"
|
||||||
|
:rows-per-page-options="[0]"
|
||||||
|
:pagination="{ rowsPerPage: 0 }"
|
||||||
|
hide-bottom
|
||||||
|
:table-style="tableStyle"
|
||||||
|
>
|
||||||
|
<template #body-cell-image="props">
|
||||||
|
<q-td :props="props" class="image-cell">
|
||||||
|
<q-img
|
||||||
|
v-if="props.row.imageUrl"
|
||||||
|
:src="props.row.imageUrl"
|
||||||
|
class="product-thumb"
|
||||||
|
fit="cover"
|
||||||
|
no-spinner
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-cell-campaignLabel="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-badge v-if="props.row.campaignLabel" color="primary" outline :label="props.row.campaignLabel" />
|
||||||
|
<span v-else class="text-grey-6">-</span>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-for="name in priceColumnNames" #[`body-cell-${name}`]="props" :key="name">
|
||||||
|
<q-td :props="props" class="text-right">
|
||||||
|
{{ formatPrice(props.row[name]) }}
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import api from 'src/services/api'
|
||||||
|
|
||||||
|
const PAGE_LIMIT = 250
|
||||||
|
const GUIDANCE_MSG = 'Liste icin filtre secin.'
|
||||||
|
|
||||||
|
const priceOptions = ['USD', 'EUR', 'TRY'].flatMap((cur) => [1, 2, 3, 4, 5, 6].map((lv) => ({
|
||||||
|
label: `${cur} ${lv}`,
|
||||||
|
value: `${cur.toLowerCase()}${lv}`
|
||||||
|
})))
|
||||||
|
const campaignPairs = priceOptions.map((x) => ({ base: x.value, derived: `${x.value}Campaign` }))
|
||||||
|
const priceColumnNames = campaignPairs.flatMap((p) => [p.base, p.derived])
|
||||||
|
|
||||||
|
const topUrunIlkGrubu = ref(null)
|
||||||
|
const topUrunAnaGrubu = ref(null)
|
||||||
|
const selectedProductCodes = ref([])
|
||||||
|
const selectedPriceOptions = ref(priceOptions.map((x) => x.value))
|
||||||
|
const leftDetailsExpanded = ref(true)
|
||||||
|
|
||||||
|
const rows = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const renderPending = ref(false)
|
||||||
|
const error = ref(GUIDANCE_MSG)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const totalPages = ref(1)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
|
||||||
|
const serverFilterOptionMap = ref({})
|
||||||
|
const serverFilterLoading = ref({})
|
||||||
|
const serverFilterLastQuery = ref({})
|
||||||
|
const filterSearch = ref({ productCode: '', urunIlkGrubu: '', urunAnaGrubu: '' })
|
||||||
|
const imageCache = new Map()
|
||||||
|
|
||||||
|
const selectedPriceSet = computed(() => new Set(selectedPriceOptions.value || []))
|
||||||
|
const pageBusy = computed(() => loading.value || renderPending.value)
|
||||||
|
const canFetch = computed(() => Boolean(topUrunIlkGrubu.value || topUrunAnaGrubu.value || selectedProductCodes.value.length > 0))
|
||||||
|
const showGuidanceOverlay = computed(() => !loading.value && rows.value.length === 0 && error.value === GUIDANCE_MSG)
|
||||||
|
|
||||||
|
const topUrunIlkGrubuOptions = computed(() => serverFilterOptionMap.value.urunIlkGrubu || [])
|
||||||
|
const topUrunAnaGrubuOptions = computed(() => serverFilterOptionMap.value.urunAnaGrubu || [])
|
||||||
|
const productCodeOptions = computed(() => serverFilterOptionMap.value.productCode || [])
|
||||||
|
|
||||||
|
function toText (value) {
|
||||||
|
return String(value ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber (value) {
|
||||||
|
const n = Number(String(value ?? '0').replace(/\./g, '').replace(',', '.'))
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2 (value) {
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) ? Number(n.toFixed(2)) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice (value) {
|
||||||
|
const n = Number(value)
|
||||||
|
if (!Number.isFinite(n) || n === 0) return ''
|
||||||
|
return n.toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStock (value) {
|
||||||
|
const n = Number(value)
|
||||||
|
if (!Number.isFinite(n)) return ''
|
||||||
|
return n.toLocaleString('tr-TR', { maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProductRow (raw, index) {
|
||||||
|
const row = {
|
||||||
|
id: index + 1,
|
||||||
|
productCode: toText(raw?.ProductCode),
|
||||||
|
stockQty: toNumber(raw?.StockQty),
|
||||||
|
stockEntryDate: toText(raw?.StockEntryDate),
|
||||||
|
lastPricingDate: toText(raw?.LastPricingDate),
|
||||||
|
askiliYan: toText(raw?.AskiliYan),
|
||||||
|
kategori: toText(raw?.Kategori),
|
||||||
|
urunIlkGrubu: toText(raw?.UrunIlkGrubu),
|
||||||
|
urunAnaGrubu: toText(raw?.UrunAnaGrubu),
|
||||||
|
urunAltGrubu: toText(raw?.UrunAltGrubu),
|
||||||
|
icerik: toText(raw?.Icerik),
|
||||||
|
karisim: toText(raw?.Karisim),
|
||||||
|
marka: toText(raw?.Marka),
|
||||||
|
brandGroupSelection: toText(raw?.BrandGroupSec)
|
||||||
|
}
|
||||||
|
for (const p of priceOptions) row[p.value] = toNumber(raw?.[p.value.toUpperCase()])
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCampaignPrices (row) {
|
||||||
|
const rate = Number(row?.campaignRate || 0)
|
||||||
|
for (const p of campaignPairs) {
|
||||||
|
const base = Number(row?.[p.base] || 0)
|
||||||
|
row[p.derived] = rate > 0 && base > 0 ? round2(base * (1 - rate / 100)) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCampaignLabel (variant) {
|
||||||
|
const code = toText(variant?.campaign_code)
|
||||||
|
const title = toText(variant?.campaign_title)
|
||||||
|
if (code && title) return `${code} - ${title}`
|
||||||
|
return code || title
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRows (products, variants) {
|
||||||
|
const byCode = new Map()
|
||||||
|
for (const v of variants || []) {
|
||||||
|
const code = toText(v?.product_code)
|
||||||
|
if (!code) continue
|
||||||
|
if (!byCode.has(code)) byCode.set(code, [])
|
||||||
|
byCode.get(code).push(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = []
|
||||||
|
for (const p of products) {
|
||||||
|
const list = byCode.get(p.productCode) || []
|
||||||
|
if (list.length === 0) {
|
||||||
|
const row = {
|
||||||
|
...p,
|
||||||
|
rowKey: `${p.productCode}|0|0`,
|
||||||
|
imageUrl: '',
|
||||||
|
variantCodes: '',
|
||||||
|
variantStocks: formatStock(p.stockQty),
|
||||||
|
campaignLabel: '',
|
||||||
|
campaignRate: null,
|
||||||
|
lastCampaignDate: ''
|
||||||
|
}
|
||||||
|
applyCampaignPrices(row)
|
||||||
|
out.push(row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list.sort((a, b) => toText(a?.variant_code).localeCompare(toText(b?.variant_code), 'tr'))
|
||||||
|
for (const v of list) {
|
||||||
|
const d1 = Number(v?.dim1 || 0)
|
||||||
|
const d3 = v?.dim3 == null ? 0 : Number(v?.dim3 || 0)
|
||||||
|
const row = {
|
||||||
|
...p,
|
||||||
|
rowKey: `${p.productCode}|${d1}|${d3}`,
|
||||||
|
imageUrl: '',
|
||||||
|
dim1: d1,
|
||||||
|
dim3: d3,
|
||||||
|
variantCodes: toText(v?.variant_code),
|
||||||
|
variantStocks: formatStock(v?.stock_qty),
|
||||||
|
stockQty: Number(v?.stock_qty ?? 0),
|
||||||
|
campaignLabel: buildCampaignLabel(v),
|
||||||
|
campaignRate: Number(v?.discount_rate || 0) || null,
|
||||||
|
lastCampaignDate: toText(v?.campaign_last_dttm)
|
||||||
|
}
|
||||||
|
applyCampaignPrices(row)
|
||||||
|
out.push(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchServerFilterOptions (field, q = '') {
|
||||||
|
const query = toText(q)
|
||||||
|
const last = toText(serverFilterLastQuery.value[field])
|
||||||
|
const cached = Array.isArray(serverFilterOptionMap.value[field]) && serverFilterOptionMap.value[field].length > 0
|
||||||
|
if (cached && last === query) return
|
||||||
|
serverFilterLoading.value = { ...serverFilterLoading.value, [field]: true }
|
||||||
|
serverFilterLastQuery.value = { ...serverFilterLastQuery.value, [field]: query }
|
||||||
|
try {
|
||||||
|
const params = { field, q: query, limit: field === 'productCode' ? 200 : 160 }
|
||||||
|
if (field === 'urunAnaGrubu' && topUrunIlkGrubu.value) params.urun_ilk_grubu = topUrunIlkGrubu.value
|
||||||
|
const res = await api.get('/order/price-list/options', { params })
|
||||||
|
const items = Array.isArray(res?.data?.items) ? res.data.items : []
|
||||||
|
serverFilterOptionMap.value = {
|
||||||
|
...serverFilterOptionMap.value,
|
||||||
|
[field]: items.map((x) => ({
|
||||||
|
label: toText(x?.label ?? x?.value),
|
||||||
|
value: toText(x?.value ?? x?.label)
|
||||||
|
})).filter((x) => x.value)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
serverFilterLoading.value = { ...serverFilterLoading.value, [field]: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTopFilterSearchUrunIlkGrubu (val, update) {
|
||||||
|
update(() => {
|
||||||
|
filterSearch.value.urunIlkGrubu = toText(val)
|
||||||
|
void fetchServerFilterOptions('urunIlkGrubu', val)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTopFilterSearchUrunAnaGrubu (val, update) {
|
||||||
|
update(() => {
|
||||||
|
filterSearch.value.urunAnaGrubu = toText(val)
|
||||||
|
void fetchServerFilterOptions('urunAnaGrubu', val)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProductCodeSearch (val, update) {
|
||||||
|
update(() => {
|
||||||
|
filterSearch.value.productCode = toText(val)
|
||||||
|
void fetchServerFilterOptions('productCode', val)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTopUrunIlkGrubuChange () {
|
||||||
|
topUrunAnaGrubu.value = null
|
||||||
|
void fetchServerFilterOptions('urunAnaGrubu', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTopUrunAnaGrubuChange () {}
|
||||||
|
|
||||||
|
function requestParams (page) {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
limit: Math.min(500, Math.max(PAGE_LIMIT, selectedProductCodes.value.length || 0)),
|
||||||
|
include_total: 1
|
||||||
|
}
|
||||||
|
if (topUrunIlkGrubu.value) params.urun_ilk_grubu = topUrunIlkGrubu.value
|
||||||
|
if (topUrunAnaGrubu.value) params.urun_ana_grubu = topUrunAnaGrubu.value
|
||||||
|
if (selectedProductCodes.value.length > 0) params.product_code = selectedProductCodes.value.join(',')
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadData ({ page = 1 } = {}) {
|
||||||
|
if (!canFetch.value) {
|
||||||
|
rows.value = []
|
||||||
|
error.value = GUIDANCE_MSG
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
renderPending.value = true
|
||||||
|
try {
|
||||||
|
const productRes = await api.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/order/price-list/products',
|
||||||
|
params: requestParams(page),
|
||||||
|
timeout: 180000
|
||||||
|
})
|
||||||
|
const products = (Array.isArray(productRes?.data) ? productRes.data : []).map(mapProductRow)
|
||||||
|
totalCount.value = Number(productRes?.headers?.['x-total-count'] || products.length || 0)
|
||||||
|
totalPages.value = Math.max(1, Number(productRes?.headers?.['x-total-pages'] || 1))
|
||||||
|
currentPage.value = Math.max(1, Number(productRes?.headers?.['x-page'] || page))
|
||||||
|
|
||||||
|
const codes = products.map((x) => x.productCode).filter(Boolean)
|
||||||
|
let variants = []
|
||||||
|
if (codes.length > 0) {
|
||||||
|
const variantRes = await api.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/order/price-list/variant-rows',
|
||||||
|
params: { product_code: codes.join(',') },
|
||||||
|
timeout: 180000
|
||||||
|
})
|
||||||
|
variants = Array.isArray(variantRes?.data) ? variantRes.data : []
|
||||||
|
}
|
||||||
|
rows.value = buildRows(products, variants)
|
||||||
|
error.value = ''
|
||||||
|
void loadImagesForRows(rows.value.slice(0, 120))
|
||||||
|
await nextTick()
|
||||||
|
} catch (err) {
|
||||||
|
rows.value = []
|
||||||
|
error.value = err?.response?.data || err?.message || 'Fiyat listesi alinamadi'
|
||||||
|
Notify.create({ type: 'negative', message: error.value })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
setTimeout(() => { renderPending.value = false }, 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImagesForRows (list) {
|
||||||
|
const targets = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const row of list) {
|
||||||
|
const key = `${row.productCode}|${row.dim1 || 0}|${row.dim3 || 0}`
|
||||||
|
if (!row.productCode || seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
targets.push({ row, key })
|
||||||
|
}
|
||||||
|
await Promise.all(targets.map(async ({ row, key }) => {
|
||||||
|
if (imageCache.has(key)) {
|
||||||
|
row.imageUrl = imageCache.get(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api.get('/product-images', {
|
||||||
|
params: {
|
||||||
|
code: row.productCode,
|
||||||
|
dim1_id: row.dim1 || '',
|
||||||
|
dim3_id: row.dim3 || ''
|
||||||
|
},
|
||||||
|
timeout: 15000
|
||||||
|
})
|
||||||
|
const first = Array.isArray(res?.data) ? res.data[0] : null
|
||||||
|
const url = toText(first?.thumb_url || first?.content_url || first?.full_url)
|
||||||
|
imageCache.set(key, url)
|
||||||
|
row.imageUrl = url
|
||||||
|
} catch {
|
||||||
|
imageCache.set(key, '')
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSelections () {
|
||||||
|
topUrunIlkGrubu.value = null
|
||||||
|
topUrunAnaGrubu.value = null
|
||||||
|
selectedProductCodes.value = []
|
||||||
|
rows.value = []
|
||||||
|
error.value = GUIDANCE_MSG
|
||||||
|
currentPage.value = 1
|
||||||
|
totalPages.value = 1
|
||||||
|
totalCount.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange (page) {
|
||||||
|
void reloadData({ page })
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePriceOption (value) {
|
||||||
|
const set = new Set(selectedPriceOptions.value || [])
|
||||||
|
if (set.has(value)) set.delete(value)
|
||||||
|
else set.add(value)
|
||||||
|
selectedPriceOptions.value = priceOptions.map((x) => x.value).filter((x) => set.has(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllPrices () {
|
||||||
|
selectedPriceOptions.value = priceOptions.map((x) => x.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllPrices () {
|
||||||
|
selectedPriceOptions.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function col (name, label, field, width, extra = {}) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
align: extra.align || 'left',
|
||||||
|
sortable: !!extra.sortable,
|
||||||
|
style: `width:${width}px;min-width:${width}px;max-width:${width}px;`,
|
||||||
|
headerStyle: `width:${width}px;min-width:${width}px;max-width:${width}px;`,
|
||||||
|
classes: extra.classes || '',
|
||||||
|
headerClasses: extra.headerClasses || extra.classes || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allColumns = [
|
||||||
|
col('image', '', 'imageUrl', 58, { align: 'center', classes: 'image-col sticky-col' }),
|
||||||
|
col('brandGroupSelection', 'MARKA GRUBU', 'brandGroupSelection', 86, { classes: 'ps-col sticky-col' }),
|
||||||
|
col('marka', 'MARKA', 'marka', 62, { sortable: true, classes: 'ps-col sticky-col' }),
|
||||||
|
col('productCode', 'URUN KODU', 'productCode', 112, { sortable: true, classes: 'ps-col product-code-col sticky-col' }),
|
||||||
|
col('variantCodes', 'VARYANT', 'variantCodes', 112, { classes: 'ps-col variant-col sticky-col' }),
|
||||||
|
col('variantStocks', 'STOK', 'stockQty', 62, { align: 'right', sortable: true, classes: 'ps-col variant-stock-col sticky-col' }),
|
||||||
|
col('campaignLabel', 'KAMPANYA', 'campaignLabel', 150, { classes: 'ps-col campaign-col sticky-col' }),
|
||||||
|
col('campaignRate', 'IND %', 'campaignRate', 58, { align: 'right', classes: 'ps-col campaign-rate-col sticky-col' }),
|
||||||
|
col('stockEntryDate', 'STOK GIRIS', 'stockEntryDate', 86, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
|
||||||
|
col('lastPricingDate', 'SON FIYAT', 'lastPricingDate', 86, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
|
||||||
|
col('lastCampaignDate', 'SON KAMPANYA', 'lastCampaignDate', 98, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
|
||||||
|
col('askiliYan', 'ASKILI YAN', 'askiliYan', 58, { sortable: true, classes: 'ps-col' }),
|
||||||
|
col('kategori', 'KATEGORI', 'kategori', 58, { sortable: true, classes: 'ps-col' }),
|
||||||
|
col('urunIlkGrubu', 'URUN ILK GRUBU', 'urunIlkGrubu', 70, { sortable: true, classes: 'ps-col' }),
|
||||||
|
col('urunAnaGrubu', 'URUN ANA GRUBU', 'urunAnaGrubu', 74, { sortable: true, classes: 'ps-col' }),
|
||||||
|
col('urunAltGrubu', 'URUN ALT GRUBU', 'urunAltGrubu', 74, { sortable: true, classes: 'ps-col' }),
|
||||||
|
col('icerik', 'ICERIK', 'icerik', 66, { sortable: true, classes: 'ps-col' }),
|
||||||
|
col('karisim', 'KARISIM', 'karisim', 66, { sortable: true, classes: 'ps-col' }),
|
||||||
|
...campaignPairs.flatMap((p) => [
|
||||||
|
col(p.base, p.base.toUpperCase().replace(/([A-Z]+)(\d)/, '$1 $2'), p.base, 78, { align: 'right', classes: `${p.base.slice(0, 3)}-col` }),
|
||||||
|
col(p.derived, `${p.base.toUpperCase().replace(/([A-Z]+)(\d)/, '$1 $2')} KMP`, p.derived, 88, { align: 'right', classes: `${p.base.slice(0, 3)}-col campaign-price-col` })
|
||||||
|
])
|
||||||
|
]
|
||||||
|
|
||||||
|
const hideableLeftDetailColumnNames = new Set([
|
||||||
|
'stockEntryDate',
|
||||||
|
'lastPricingDate',
|
||||||
|
'lastCampaignDate',
|
||||||
|
'askiliYan',
|
||||||
|
'kategori',
|
||||||
|
'urunIlkGrubu',
|
||||||
|
'urunAnaGrubu',
|
||||||
|
'urunAltGrubu',
|
||||||
|
'icerik',
|
||||||
|
'karisim'
|
||||||
|
])
|
||||||
|
|
||||||
|
const visibleColumns = computed(() => allColumns.filter((c) => {
|
||||||
|
if (/^(usd|eur|try)[1-6]$/.test(c.name)) return selectedPriceSet.value.has(c.name)
|
||||||
|
if (/^(usd|eur|try)[1-6]Campaign$/.test(c.name)) return selectedPriceSet.value.has(c.name.replace(/Campaign$/, ''))
|
||||||
|
if (!leftDetailsExpanded.value && hideableLeftDetailColumnNames.has(c.name)) return false
|
||||||
|
return true
|
||||||
|
}))
|
||||||
|
|
||||||
|
const filteredRows = computed(() => rows.value || [])
|
||||||
|
const tableMinWidth = computed(() => visibleColumns.value.reduce((sum, c) => sum + extractWidth(c.style), 0))
|
||||||
|
const tableStyle = computed(() => ({
|
||||||
|
width: `${tableMinWidth.value}px`,
|
||||||
|
minWidth: `${tableMinWidth.value}px`,
|
||||||
|
tableLayout: 'fixed'
|
||||||
|
}))
|
||||||
|
const stickyScrollComp = computed(() => 650)
|
||||||
|
|
||||||
|
function extractWidth (style) {
|
||||||
|
const m = String(style || '').match(/width:(\d+)px/)
|
||||||
|
return m ? Number(m[1]) : 80
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCell (row, col) {
|
||||||
|
if (col.name === 'image') return row.imageUrl || ''
|
||||||
|
if (priceColumnNames.includes(col.name)) return formatPrice(row[col.field])
|
||||||
|
if (col.name === 'variantStocks') return formatStock(row.stockQty)
|
||||||
|
if (col.name === 'campaignRate') return row.campaignRate ? formatPrice(row.campaignRate) : ''
|
||||||
|
return toText(row[col.field])
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportVisibleExcel () {
|
||||||
|
const cols = visibleColumns.value
|
||||||
|
const body = filteredRows.value.map((row) => `<tr>${cols.map((c) => {
|
||||||
|
if (c.name === 'image' && row.imageUrl) return `<td><img src="${row.imageUrl}" width="54" height="54"></td>`
|
||||||
|
return `<td>${escapeHtml(exportCell(row, c))}</td>`
|
||||||
|
}).join('')}</tr>`).join('')
|
||||||
|
const html = `<!doctype html><html><head><meta charset="utf-8"></head><body><table border="1"><thead><tr>${cols.map((c) => `<th>${escapeHtml(c.label || 'Gorsel')}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table></body></html>`
|
||||||
|
const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `fiyat_listesi_${new Date().toISOString().slice(0, 10)}.xls`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function printVisibleRows () {
|
||||||
|
const cols = visibleColumns.value
|
||||||
|
const body = filteredRows.value.map((row) => `<tr>${cols.map((c) => {
|
||||||
|
if (c.name === 'image' && row.imageUrl) return `<td><img src="${row.imageUrl}" class="thumb"></td>`
|
||||||
|
return `<td class="${priceColumnNames.includes(c.name) ? 'num' : ''}">${escapeHtml(exportCell(row, c))}</td>`
|
||||||
|
}).join('')}</tr>`).join('')
|
||||||
|
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Fiyat Listesi</title><style>
|
||||||
|
@page { size: A3 landscape; margin: 8mm; }
|
||||||
|
body { font-family: Arial, sans-serif; font-size: 8px; }
|
||||||
|
h1 { font-size: 16px; margin: 0 0 8px; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
th { background: #957116; color: #fff; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 3px; vertical-align: middle; }
|
||||||
|
.num { text-align: right; }
|
||||||
|
.thumb { width: 42px; height: 42px; object-fit: cover; }
|
||||||
|
</style></head><body><h1>Fiyat Listesi</h1><table><thead><tr>${cols.map((c) => `<th>${escapeHtml(c.label || 'Gorsel')}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table><script>window.onload=function(){window.print()}<\/script></body></html>`
|
||||||
|
const win = window.open('', '_blank')
|
||||||
|
if (!win) return
|
||||||
|
win.document.open()
|
||||||
|
win.document.write(html)
|
||||||
|
win.document.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml (value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedProductCodes, (list) => {
|
||||||
|
const clean = Array.isArray(list) ? [...new Set(list.map(toText).filter(Boolean))] : []
|
||||||
|
if (clean.join('\u0001') !== (Array.isArray(list) ? list.join('\u0001') : '')) {
|
||||||
|
selectedProductCodes.value = clean
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void fetchServerFilterOptions('urunIlkGrubu', '')
|
||||||
|
void fetchServerFilterOptions('urunAnaGrubu', '')
|
||||||
|
void fetchServerFilterOptions('productCode', '')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.order-price-list-page {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions-row {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
position: relative;
|
||||||
|
overflow: auto;
|
||||||
|
height: calc(100vh - 150px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-thumb {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f4f4f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-cell {
|
||||||
|
padding: 2px 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-price-list-table :deep(td),
|
||||||
|
.order-price-list-table :deep(th) {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-price-list-table :deep(.campaign-price-col) {
|
||||||
|
background: #f6fbf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-busy-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 30000;
|
||||||
|
background: rgba(255, 255, 255, 0.68);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-busy-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4c4c4c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-overlay-inner {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -317,6 +317,12 @@ const routes = [
|
|||||||
component: () => import('pages/OrderBulkClose.vue'),
|
component: () => import('pages/OrderBulkClose.vue'),
|
||||||
meta: { permission: 'order:update' }
|
meta: { permission: 'order:update' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'order-price-list',
|
||||||
|
name: 'order-price-list',
|
||||||
|
component: () => import('pages/OrderPriceList.vue'),
|
||||||
|
meta: { permission: 'order:view' }
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: 'order-pdf/:id',
|
path: 'order-pdf/:id',
|
||||||
|
|||||||
Reference in New Issue
Block a user