diff --git a/svc/main.go b/svc/main.go index ce3c7fa..b1886c6 100644 --- a/svc/main.go +++ b/svc/main.go @@ -360,6 +360,21 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "system", "update", wrapV3(routes.SavePricingFirstGroupMailMappingHandler(pgDB)), ) + bindV3(r, pgDB, + "/api/system/order-price-list-mail-mappings/lookups", "GET", + "system", "update", + wrapV3(routes.GetOrderPriceListFirstGroupMailMappingLookupsHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/system/order-price-list-mail-mappings", "GET", + "system", "update", + wrapV3(routes.GetOrderPriceListFirstGroupMailMappingsHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/system/order-price-list-mail-mappings/{group}", "PUT", + "system", "update", + wrapV3(routes.SaveOrderPriceListFirstGroupMailMappingHandler(pgDB)), + ) bindV3(r, pgDB, "/api/language/translations", "GET", "language", "update", @@ -815,6 +830,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "order", "view", wrapV3(http.HandlerFunc(routes.ExportProductPriceListPDFHandler(pgDB))), ) + bindV3(r, pgDB, + "/api/order/price-list/export-notify", "POST", + "order", "view", + wrapV3(http.HandlerFunc(routes.NotifyOrderPriceListExportHandler(pgDB, ml))), + ) bindV3(r, pgDB, "/api/product-size-match/rules", "GET", "order", "view", diff --git a/svc/queries/first_group_mail_mapping.go b/svc/queries/first_group_mail_mapping.go index a6dd9b2..09d1977 100644 --- a/svc/queries/first_group_mail_mapping.go +++ b/svc/queries/first_group_mail_mapping.go @@ -45,3 +45,26 @@ const InsertPricingFirstGroupMailMapping = ` INSERT INTO mk_pricing_first_group_mail (urun_ilk_grubu, mail_id) VALUES ($1, $2) ` + +const GetOrderPriceListFirstGroupMailMappingRows = ` +SELECT + f.urun_ilk_grubu, + m.id::text, + m.email, + COALESCE(NULLIF(m.display_name, ''), m.email) AS display_name +FROM mk_order_price_list_first_group_mail f +JOIN mk_mail m + ON m.id = f.mail_id + AND m.is_active = true +ORDER BY f.urun_ilk_grubu, m.email +` + +const DeleteOrderPriceListFirstGroupMailsByGroup = ` +DELETE FROM mk_order_price_list_first_group_mail +WHERE urun_ilk_grubu = $1 +` + +const InsertOrderPriceListFirstGroupMailMapping = ` +INSERT INTO mk_order_price_list_first_group_mail (urun_ilk_grubu, mail_id) +VALUES ($1, $2) +` diff --git a/svc/routes/first_group_mail_mapping.go b/svc/routes/first_group_mail_mapping.go index e487ef7..c5fa724 100644 --- a/svc/routes/first_group_mail_mapping.go +++ b/svc/routes/first_group_mail_mapping.go @@ -48,6 +48,17 @@ CREATE TABLE IF NOT EXISTS mk_pricing_first_group_mail ( ) `, `CREATE INDEX IF NOT EXISTS ix_pricing_first_group_mail_group ON mk_pricing_first_group_mail (urun_ilk_grubu)`, + ` +CREATE TABLE IF NOT EXISTS mk_order_price_list_first_group_mail ( + urun_ilk_grubu TEXT NOT NULL, + mail_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (urun_ilk_grubu, mail_id), + CONSTRAINT fk_order_price_list_first_group_mail_mail + FOREIGN KEY (mail_id) REFERENCES mk_mail(id) ON DELETE CASCADE +) +`, + `CREATE INDEX IF NOT EXISTS ix_order_price_list_first_group_mail_group ON mk_order_price_list_first_group_mail (urun_ilk_grubu)`, } for _, s := range stmts { @@ -130,6 +141,11 @@ func GetPricingFirstGroupMailMappingLookupsHandler(pg *sql.DB) http.HandlerFunc return GetCostingFirstGroupMailMappingLookupsHandler(pg) } +func GetOrderPriceListFirstGroupMailMappingLookupsHandler(pg *sql.DB) http.HandlerFunc { + // same lookups as costing/pricing + return GetCostingFirstGroupMailMappingLookupsHandler(pg) +} + func GetCostingFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -492,3 +508,195 @@ func SavePricingFirstGroupMailMappingHandler(pg *sql.DB) http.HandlerFunc { }) } } + +func GetOrderPriceListFirstGroupMailMappingsHandler(pg *sql.DB) http.HandlerFunc { + return getFirstGroupMailMappingsByQuery(pg, queries.GetOrderPriceListFirstGroupMailMappingRows) +} + +func SaveOrderPriceListFirstGroupMailMappingHandler(pg *sql.DB) http.HandlerFunc { + return saveFirstGroupMailMappingByQueries( + pg, + queries.DeleteOrderPriceListFirstGroupMailsByGroup, + queries.InsertOrderPriceListFirstGroupMailMapping, + ) +} + +func getFirstGroupMailMappingsByQuery(pg *sql.DB, mappingQuery string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + mssql := db.GetDB() + if mssql == nil { + http.Error(w, "mssql connection not available", http.StatusServiceUnavailable) + return + } + if err := ensureFirstGroupMailMappingTables(pg); err != nil { + http.Error(w, "mapping table bootstrap error", http.StatusInternalServerError) + return + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + allCodes := make([]string, 0, 512) + titleByCode := make(map[string]string, 512) + fgRows, err := queries.ListProductFirstGroupCodeDescOptions(ctx, mssql, "", 5000) + if err != nil { + http.Error(w, "first group lookup error", http.StatusInternalServerError) + return + } + defer fgRows.Close() + for fgRows.Next() { + var code string + var title string + if err := fgRows.Scan(&code, &title); err != nil { + http.Error(w, "first group scan error", http.StatusInternalServerError) + return + } + code = strings.TrimSpace(code) + title = strings.TrimSpace(title) + if code != "" { + allCodes = append(allCodes, code) + if _, ok := titleByCode[code]; !ok { + titleByCode[code] = title + } + } + } + if err := fgRows.Err(); err != nil { + http.Error(w, "first group rows error", http.StatusInternalServerError) + return + } + allCodes = normalizeIDList(allCodes) + + rows, err := pg.Query(mappingQuery) + if err != nil { + http.Error(w, "mapping query error", http.StatusInternalServerError) + return + } + defer rows.Close() + + byGroup := map[string]*models.FirstGroupMailMappingRow{} + for _, code := range allCodes { + byGroup[code] = &models.FirstGroupMailMappingRow{ + UrunIlkGrubu: code, + GroupCode: code, + GroupTitle: titleByCode[code], + MailIDs: make([]string, 0, 8), + Mails: make([]models.FirstGroupMailOption, 0, 8), + } + } + + for rows.Next() { + var group sql.NullString + var mailID sql.NullString + var email sql.NullString + var displayName sql.NullString + if err := rows.Scan(&group, &mailID, &email, &displayName); err != nil { + http.Error(w, "mapping scan error", http.StatusInternalServerError) + return + } + code := strings.TrimSpace(group.String) + if code == "" { + continue + } + row, ok := byGroup[code] + if !ok { + row = &models.FirstGroupMailMappingRow{ + UrunIlkGrubu: code, + GroupCode: code, + GroupTitle: titleByCode[code], + MailIDs: make([]string, 0, 8), + Mails: make([]models.FirstGroupMailOption, 0, 8), + } + byGroup[code] = row + allCodes = append(allCodes, code) + } + if mailID.Valid && strings.TrimSpace(mailID.String) != "" { + id := strings.TrimSpace(mailID.String) + row.MailIDs = append(row.MailIDs, id) + label := strings.TrimSpace(displayName.String) + if label == "" { + label = strings.TrimSpace(email.String) + } + row.Mails = append(row.Mails, models.FirstGroupMailOption{ID: id, Label: label}) + } + } + if err := rows.Err(); err != nil { + http.Error(w, "mapping rows error", http.StatusInternalServerError) + return + } + + allCodes = normalizeIDList(allCodes) + out := make([]models.FirstGroupMailMappingRow, 0, len(allCodes)) + for _, code := range allCodes { + if r := byGroup[code]; r != nil { + r.MailIDs = normalizeIDList(r.MailIDs) + if strings.TrimSpace(r.GroupTitle) == "" { + r.GroupTitle = titleByCode[code] + } + out = append(out, *r) + } + } + _ = json.NewEncoder(w).Encode(out) + } +} + +func saveFirstGroupMailMappingByQueries(pg *sql.DB, deleteQuery string, insertQuery string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + group := strings.TrimSpace(mux.Vars(r)["group"]) + if group == "" { + http.Error(w, "invalid urun_ilk_grubu", http.StatusBadRequest) + return + } + + var payload FirstGroupMailSavePayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + mailIDs := normalizeIDList(payload.MailIDs) + for _, mailID := range mailIDs { + var mailExists bool + if err := pg.QueryRow(queries.ExistsActiveMailByID, mailID).Scan(&mailExists); err != nil { + http.Error(w, "mail validate error", http.StatusInternalServerError) + return + } + if !mailExists { + http.Error(w, "mail not found: "+mailID, http.StatusBadRequest) + return + } + } + + tx, err := pg.Begin() + if err != nil { + http.Error(w, "transaction start error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + if _, err := tx.Exec(deleteQuery, group); err != nil { + http.Error(w, "mapping delete error", http.StatusInternalServerError) + return + } + for _, mailID := range mailIDs { + if _, err := tx.Exec(insertQuery, group, mailID); err != nil { + http.Error(w, "mapping insert error", http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(); err != nil { + http.Error(w, "transaction commit error", http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "urun_ilk_grubu": group, + "mail_ids": mailIDs, + }) + } +} diff --git a/svc/routes/order_price_list_export_notify.go b/svc/routes/order_price_list_export_notify.go new file mode 100644 index 0000000..27ea308 --- /dev/null +++ b/svc/routes/order_price_list_export_notify.go @@ -0,0 +1,192 @@ +package routes + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "html" + "log" + "net/http" + "strings" + "time" + + "bssapp-backend/auth" + "bssapp-backend/internal/mailer" + + "github.com/lib/pq" +) + +type orderPriceListExportNotifyRequest struct { + Format string `json:"format"` + RowCount int `json:"row_count"` + PriceFields []string `json:"price_fields"` + ProductCodes []string `json:"product_codes"` + CampaignLabels []string `json:"campaign_labels"` + FirstGroups []string `json:"first_groups"` + UrunIlkGrubu string `json:"urun_ilk_grubu"` + UrunAnaGrubu string `json:"urun_ana_grubu"` +} + +func NotifyOrderPriceListExportHandler(pg *sql.DB, ml *mailer.GraphMailer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.GetClaimsFromContext(r.Context()) + if !ok || claims == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if ml == nil { + http.Error(w, "mailer unavailable", http.StatusServiceUnavailable) + return + } + if err := ensureFirstGroupMailMappingTables(pg); err != nil { + http.Error(w, "mapping table bootstrap error", http.StatusInternalServerError) + return + } + + var req orderPriceListExportNotifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + groups := normalizeExportNotifyList(req.FirstGroups, 100) + if len(groups) == 0 { + groups = normalizeExportNotifyList([]string{req.UrunIlkGrubu}, 100) + } + recipients, err := loadOrderPriceListRecipients(pg, groups) + if err != nil { + http.Error(w, "recipient lookup error", http.StatusInternalServerError) + return + } + if len(recipients) == 0 { + log.Printf("[order-price-list-export-notify] no recipients groups=%v", groups) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "sent": false, "reason": "no_recipients"}) + return + } + + actor := strings.TrimSpace(claims.Username) + if actor == "" { + actor = fmt.Sprintf("user-%d", claims.ID) + } + format := strings.ToUpper(strings.TrimSpace(req.Format)) + if format == "" { + format = "CIKTI" + } + now := time.Now() + htmlBody := buildOrderPriceListExportNotifyHTML(req, actor, format, groups, now) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := ml.Send(ctx, mailer.Message{ + To: recipients, + Subject: fmt.Sprintf("Fiyat Listesi Ciktisi Alindi | %s | %s", actor, now.Format("02.01.2006 15:04")), + BodyHTML: htmlBody, + }); err != nil { + log.Printf("[order-price-list-export-notify] send failed user=%s format=%s err=%v", actor, format, err) + http.Error(w, "mail send error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "sent": true, "recipient_count": len(recipients)}) + } +} + +func loadOrderPriceListRecipients(pg *sql.DB, groups []string) ([]string, error) { + groups = normalizeExportNotifyList(groups, 100) + if len(groups) == 0 { + return nil, nil + } + rows, err := pg.Query(` +SELECT DISTINCT TRIM(m.email) AS email +FROM mk_order_price_list_first_group_mail f +JOIN mk_mail m + ON m.id = f.mail_id +WHERE m.is_active = true + AND COALESCE(TRIM(m.email), '') <> '' + AND UPPER(TRIM(f.urun_ilk_grubu)) = ANY($1) +ORDER BY email +`, pq.Array(upperExportNotifyList(groups))) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]string, 0, 16) + for rows.Next() { + var email string + if err := rows.Scan(&email); err != nil { + return nil, err + } + email = strings.TrimSpace(email) + if email != "" { + out = append(out, email) + } + } + return out, rows.Err() +} + +func buildOrderPriceListExportNotifyHTML(req orderPriceListExportNotifyRequest, actor string, format string, groups []string, now time.Time) string { + return fmt.Sprintf(` +
| Islem Yapan | %s |
| Cikti Tipi | %s |
| Tarih | %s |
| Satir Sayisi | %d |
| Secili Fiyat Gruplari | %s |
| Urun Ilk Gruplari | %s |
| Urun Ana Grubu | %s |
| Urun Kodlari | %s |
| Kampanya Filtreleri | %s |