Files
Bridge-and-Join-s/internal/lkgateway/incoming_notify.go
T
fontvielle 32d88fc42a ЛК: итог «Исполнена» по MS010, GUID без учёта регистра, заявка при отказе МНП берётся один раз; тестовый контур не трогает боевой ЛК
Перевод 97EAD00C (заявка ЛК #71741, 23.10.2026) исполнен в QUIK и НРД (MS010),
но в ЛК статус не изменился, а сделка осталась «Подтверждена»:
- связки guid → заявка ЛК искались с учётом регистра: мы шлём GUID строчными,
  НРД отвечает заглавными — OnLKOutcome молча не находил заявку. Ключи —
  строчными (lkGUIDKey), старые записи приводятся при загрузке; итог модуля
  ЛК (lk.transfer_requests) — lower(guid);
- после MS010 по всем строкам поручения 16 сделка → «Исполнена» (CompleteSUB16),
  итог — в ЛК (outOnExecuted, каждый проход order-watch, повтор безвреден);
  в бою согласие контрагента — ещё не «Исполнена»: статус ждёт MS010, как в
  тесте «боевой ЛК → TEST3»; имитатор НРД — итог сразу.

Тестовый 28 с боевым ЛК взял ту же заявку и после отказа МНП («запрещен для
операции шорт» — бумагу уже заблокировал 9) брал её каждые 10 секунд: 108
сделок с новыми GUID и столько же писем.
- Сделка заведена, а отправка не прошла (МНП не заблокировал) — заявка
  помечается взятой и связывается с переводом: дальше — оператор в карточке.
- Боевой ЛК при тестовом профиле ИШ (lkProdOnTestNSD): заявки клиентов не
  берутся (кроме теста «боевой ЛК → тестовый НРД»), портфели не заполняются,
  предупреждение в настройках ЛК.
- Письма: в теме и первой строке — сервер bj и контур НРД («[bj ТЕСТ …]»
  у тестового); письма о входящих — метка только у тестового.
- История FSM — по МСК с датой, «текущее состояние» вместо «сейчас».
- Карточка теста «боевой ЛК → тестовый НРД» — только в режиме разработки
  (включённый тест виден всегда, чтобы его выключить).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-23 16:04:44 +03:00

208 lines
8.6 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(notifyRecipients(cfg, "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)
if !nsdProfileProd(cfg.NSD.Profile) { // письмо тестового сервера не принять за боевое
tag, line := alertServer(cfg.NSD.Profile)
subject, body = tag+subject, line+body
}
s := smtp
s.To = notifyRecipients(cfg, "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), notifyRecipients(cfg, "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(notifyRecipients(cfg, "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 = notifyRecipients(cfg, "incoming")
text := b.String()
if !nsdProfileProd(cfg.NSD.Profile) { // письмо тестового сервера не принять за боевое
tag, line := alertServer(cfg.NSD.Profile)
subject, text = tag+subject, line+text
}
if err := sendSMTP(m, subject, text); err != nil {
log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s не доставлено: %v", it.GUID, err)
return
}
log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s отправлено на %s", it.GUID, notifyRecipients(cfg, "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 := readHandbook()
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 ""
}