Files
Bridge-and-Join-s/internal/lkgateway/incoming_notify.go
T
zuevav da12a9ec1c feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 19:41:12 +03:00

199 lines
8.2 KiB
Go

package lkgateway
import (
"encoding/json"
"fmt"
"log"
"net"
"os"
"strings"
)
// incoming_notify.go — уведомление депозитария о входящих ценных бумагах.
// Когда по M2M нам приходит перевод (мы — принимающая сторона), кроме показа во
// «Входящих переводах» bj шлёт письмо в бэк-офис депозитария: «к вам переведена
// такая-то бумага, требуется учесть». Пока это ТОЛЬКО уведомление; позже здесь
// же будем формировать пакет для загрузки в учётную систему.
// notifyDepositoryIncoming отправляет письмо-уведомление о новом входящем
// переводе на адреса депозитария из настроек. Best-effort (ошибки — в лог).
func notifyDepositoryIncoming(rc *RuntimeConfig, it *IncomingTransfer) {
if rc == nil || it == nil {
return
}
cfg := rc.Snapshot()
inc := cfg.Incoming
if !inc.Enabled || strings.TrimSpace(inc.Recipients("incoming")) == "" {
return
}
smtp := cfg.Alerts.SMTP
if !smtp.Enabled || strings.TrimSpace(smtp.Host) == "" {
log.Printf("lk-gateway: уведомление депозитарию о входящем GUID=%s не отправлено — SMTP не настроен", it.GUID)
return
}
subject := "MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт"
body := buildIncomingEmailBody(it)
s := smtp
s.To = inc.Recipients("incoming") // получатели по маршруту «входящие переводы»
if err := sendSMTP(s, subject, body); err != nil {
log.Printf("lk-gateway: уведомление депозитарию о входящем GUID=%s не доставлено: %v", it.GUID, err)
return
}
log.Printf("lk-gateway: уведомление депозитарию о входящем GUID=%s отправлено (%d бумаг) на %s",
it.GUID, len(it.Securities), inc.Recipients("incoming"))
}
// notifyDepositoryAccepted отправляет письмо депозитарию о том, что входящий
// перевод обработан и ценные бумаги приняты (зачислены). Best-effort.
func notifyDepositoryAccepted(rc *RuntimeConfig, it *IncomingTransfer, confirmed, rejected int) {
if rc == nil || it == nil {
return
}
cfg := rc.Snapshot()
inc := cfg.Incoming
if !inc.Enabled || strings.TrimSpace(inc.Recipients("incoming")) == "" {
return
}
smtp := cfg.Alerts.SMTP
if !smtp.Enabled || strings.TrimSpace(smtp.Host) == "" {
return
}
subject := "MOEX МОСТ (M2M): перевод обработан — ценные бумаги приняты (зачислены)"
var b strings.Builder
b.WriteString("Перевод по сервису MOEX МОСТ (M2M) обработан нашим депозитарием.\n\n")
if it.Auto {
b.WriteString("Проверка по учётной системе (Fancy) пройдена — перевод обработан автоматически.\n\n")
}
senderName := handbookNameByCode(it.SenderCode)
from := it.SenderCode
if it.SenderINN != "" {
from += ", ИНН " + it.SenderINN
}
if senderName != "" {
from = senderName + " (" + from + ")"
}
b.WriteString("Передающий депозитарий: " + from + "\n")
if fio := strings.TrimSpace(it.InvestorLast + " " + it.InvestorFirst + " " + it.InvestorMiddle); fio != "" {
b.WriteString("Инвестор: " + fio + "\n")
}
b.WriteString("GUID перевода: " + it.GUID + "\n")
b.WriteString(fmt.Sprintf("Итог: принято %d, отклонено %d.\n\n", confirmed, rejected))
b.WriteString("Ценные бумаги:\n")
for i, sec := range it.Securities {
code := sec.ISIN
if code == "" {
code = sec.SecurityCode
}
st := "принято (зачислено)"
if sec.Decision == "rejected" {
st = "отклонено (" + sec.RejectCode + ")"
}
b.WriteString(fmt.Sprintf(" %d) %s — %s шт. — %s\n", i+1, code, sec.Quantity, st))
}
b.WriteString("\nПосмотреть перевод:\n " + adminBaseURL() + "/admin/incoming/" + it.ID + "\n")
b.WriteString("\nЭто уведомление сформировано автоматически системой Bridge-and-Join-s (bj).\n")
m := smtp
m.To = inc.Recipients("incoming")
if err := sendSMTP(m, subject, b.String()); err != nil {
log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s не доставлено: %v", it.GUID, err)
return
}
log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s отправлено на %s", it.GUID, inc.Recipients("incoming"))
}
// buildIncomingEmailBody собирает текст письма депозитарию.
func buildIncomingEmailBody(it *IncomingTransfer) string {
var b strings.Builder
b.WriteString("Уведомление о переводе ценных бумаг через сервис MOEX МОСТ (M2M).\n\n")
b.WriteString("К вам (принимающий депозитарий) переведены ценные бумаги. Требуется учесть\n")
b.WriteString("их в учёте депозитария.\n\n")
senderName := handbookNameByCode(it.SenderCode)
from := it.SenderCode
if it.SenderINN != "" {
from += ", ИНН " + it.SenderINN
}
if senderName != "" {
from = senderName + " (" + from + ")"
}
b.WriteString("Передающий депозитарий: " + from + "\n")
b.WriteString("Идентификатор перевода (GUID): " + it.GUID + "\n")
b.WriteString("Получено: " + it.ReceivedAt.Format("02.01.2006 15:04:05") + " МСК\n\n")
b.WriteString(fmt.Sprintf("Ценные бумаги (%d):\n", len(it.Securities)))
for i, s := range it.Securities {
code := s.ISIN
if code == "" {
code = s.SecurityCode
}
b.WriteString(fmt.Sprintf(" %d) %s — %s шт.\n", i+1, code, s.Quantity))
}
link := adminBaseURL() + "/admin/incoming/" + it.ID
b.WriteString("\nПосмотреть входящее сообщение и ответить (принять/отклонить):\n")
b.WriteString(" " + link + "\n")
b.WriteString("\nЭто уведомление сформировано автоматически системой Bridge-and-Join-s (bj).\n")
b.WriteString("Пакет для загрузки в учётную систему будет сформирован позже.\n")
return b.String()
}
// adminBaseURL — базовый URL веб-админки bj для ссылок в письмах. Приоритет:
// переменная BJ_ADMIN_URL; иначе http://<LAN-IP сервера>:<порт админки>.
func adminBaseURL() string {
if u := strings.TrimSpace(os.Getenv("BJ_ADMIN_URL")); u != "" {
return strings.TrimRight(u, "/")
}
host := primaryIPv4()
if host == "" {
host = "localhost"
}
port := "8080"
if a := strings.TrimSpace(os.Getenv("BJ_HTTP_ADDR")); a != "" {
if i := strings.LastIndex(a, ":"); i >= 0 && i+1 < len(a) {
port = a[i+1:]
}
}
return "http://" + host + ":" + port
}
// primaryIPv4 возвращает первый непетлевой IPv4-адрес сервера (для ссылок).
func primaryIPv4() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ip4 := ipnet.IP.To4(); ip4 != nil {
return ip4.String()
}
}
}
return ""
}
// handbookNameByCode ищет наименование участника по MC-коду в сохранённом
// справочнике (m2m-handbook.json). Пусто, если справочника нет/не найдено.
func handbookNameByCode(code string) string {
if code == "" {
return ""
}
raw, err := os.ReadFile(handbookFile)
if err != nil {
return ""
}
var hb struct {
Participants []HandbookEntry `json:"participants"`
}
if json.Unmarshal(raw, &hb) != nil {
return ""
}
for _, p := range hb.Participants {
if p.Code == code {
return p.Name
}
}
return ""
}