da12a9ec1c
- 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>
199 lines
8.2 KiB
Go
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 ""
|
|
}
|