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>
This commit is contained in:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+152 -2
View File
@@ -8,7 +8,9 @@ import (
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
// pollerStateFile — постоянная память id применённых входящих пакетов ИШ.
@@ -60,7 +62,7 @@ func saveProcessed(m map[int]bool) {
// Дедупликация по id обработанных пакетов: ИШ возвращает их повторно,
// пока мы не подтвердим, поэтому держим множество уже обработанных.
func (s *Server) pollIncoming(ctx context.Context) {
const interval = 30 * time.Second
const interval = 10 * time.Second // синхронно с fetchInterval ИШ (10с)
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -79,6 +81,7 @@ func (s *Server) pollIncoming(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
healthBeat("poll-incoming")
s.fetchAndApply(ctx, processed)
}
}
@@ -155,7 +158,8 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
log.Printf("lk-gateway: поллер Decision id=%d: разбор: %v", p.ID, err)
return nil
}
return s.svc.ApplyDecision(ctx, decision)
s.applyDecisionEffects(ctx, decision, "поллер", "")
return nil
case strings.Contains(doc, "M2MTransferResponse"):
resp, err := igw.ParseResponse(unpacked.DocXML)
if err != nil {
@@ -177,9 +181,155 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
// логируем, но помечаем обработанным, чтобы не зациклиться.
log.Printf("lk-gateway: поллер ApplyServiceResponse id=%d GUID=%s: %v", p.ID, resp.GUID, err)
}
// ERROR сервиса МОСТ — отказ: замыкаем на ЛК с человекочитаемой причиной.
if strings.EqualFold(string(resp.StatusCode), "ERROR") {
reason := "Отклонено сервисом MOEX МОСТ"
if c := strings.TrimSpace(codes); c != "" {
parts := make([]string, 0)
for _, code := range strings.Fields(c) {
parts = append(parts, m2mCodeLabel(code))
}
reason = strings.Join(parts, "; ")
}
// Итог для заявки модуля ЛК (lk.transfer_requests): 'declined' — отказ после отправки в НРД.
if string(resp.GUID) != "" && string(resp.GUID) != zeroGUID {
if e := lkFinalizeByGUID(ctx, s.rc, string(resp.GUID), "declined", "rejected", reason); e != nil {
log.Printf("lk-gateway: поллер: итог заявки модуля ЛК GUID=%s: %v", resp.GUID, e)
}
}
OnLKOutcome(ctx, s.rc, string(resp.GUID), "rejected", reason)
quikOnServiceError(ctx, s.rc, string(resp.GUID), reason)
go depoOnServiceError(s.rc, string(resp.GUID), codes, reason)
}
return nil
case strings.Contains(doc, "M2MTransferRequest"):
// Входящий запрос на перевод — другая сторона (робот в сценарии 3333)
// выступает ПЕРЕДАЮЩЕЙ. Мы должны ответить M2MTransferDecision. Сохраняем
// во «входящие переводы»; решение оператор формирует в UI.
var req m2m.M2MTransferRequest
// Робот шлёт корень <M2MTransferRequest> без namespace (префиксы только
// на детях) — подставляем default namespace request для корня.
if err := nsdxml.UnmarshalDefaultNS(unpacked.DocXML, &req, "http://nsd.ru/schemas/m2m/request"); err != nil {
log.Printf("lk-gateway: поллер входящий Request id=%d: разбор: %v", p.ID, err)
return nil
}
it, existed := s.incoming.AddFromRequest(&req, string(nsdxml.DecodeWindows1251(unpacked.DocXML)))
if existed {
log.Printf("lk-gateway: поллер входящий перевод GUID=%s уже сохранён (id=%s)", it.GUID, it.ID)
} else {
// Каждый новый GUID — ОТДЕЛЬНЫЙ перевод и требует решения оператора.
// Совпадение по «отпечатку» (тот же инвестор и бумаги) НЕ значит дубль:
// один и тот же человек может законно перевести 10 акций, затем ещё 10
// (разные GUID = разные отправки). Поэтому авто-ответа нет — только
// информируем, если запрос похож на ранее отвеченный.
if dup, ok := s.incoming.FindAnsweredDuplicate(it.Fingerprint, it.ID); ok && it.Fingerprint != "" {
log.Printf("lk-gateway: поллер входящий GUID=%s похож по составу на ранее отвеченный (id=%s) — "+
"это отдельный перевод, ждёт решения", it.GUID, dup.ID)
} else {
log.Printf("lk-gateway: поллер НОВЫЙ входящий перевод от %s GUID=%s id=%s — ждёт решения",
it.SenderCode, it.GUID, it.ID)
}
// Уведомляем депозитарий по почте (к вам переведены бумаги — учесть).
go notifyDepositoryIncoming(s.rc, it)
// DEPO (bj2depo): шапка + строки + BJ_RECEIVED — поручение в DEPO создаётся сразу.
go depoOnIncomingReceived(s.rc, it)
// Сверка с DEPO — только рекомендация для оператора (эмуляция ответа),
// в статус попадает бейдж «сверка: принять / отказ M2Mxx». Не отвечаем.
go func(item *IncomingTransfer) {
if r := runDepoCheck(item); r.Ran {
s.incoming.SetDepo(item.ID, r.Recommend, r.Summary)
log.Printf("lk-gateway: сверка DEPO по входящему GUID=%s: %s (не отправлено)", item.GUID, r.Summary)
} else {
log.Printf("lk-gateway: сверка DEPO по входящему GUID=%s не выполнена: %s", item.GUID, r.Err)
}
}(it)
}
return nil
case strings.Contains(doc, "M2MTransferHandbook") && !strings.Contains(doc, "M2MTransferHandbookRequest"):
// Справочник участников M2M (ответ НРД на #M2MHR). Разбираем и сохраняем
// — это карта ИНН → MC-код участника и перечень мест расчётов.
var hb m2m.M2MTransferHandbook
if err := nsdxml.Unmarshal(unpacked.DocXML, &hb); err != nil {
log.Printf("lk-gateway: поллер справочник M2M id=%d: разбор: %v", p.ID, err)
return nil
}
saveHandbook(&hb)
go func() {
if err := depoWriteHandbook(s.rc); err != nil {
log.Printf("depo-events: справочник после получения: %v", err)
}
}()
log.Printf("lk-gateway: поллер СПРАВОЧНИК M2M получен id=%d: участников %d, мест расчётов %d",
p.ID, len(hb.Participants.Participants), len(hb.SettlementPlaces.Places))
return nil
default:
log.Printf("lk-gateway: поллер пакет id=%d (%s) — неизвестный M2M-документ, пропуск", p.ID, p.Type)
return nil
}
}
// rejectCodesLabel собирает человекочитаемые причины отказа по кодам M2M.
func rejectCodesLabel(codes []string) string {
if len(codes) == 0 {
return "без кода"
}
seen := map[string]bool{}
var parts []string
for _, c := range codes {
if c == "" || seen[c] {
continue
}
seen[c] = true
parts = append(parts, m2mCodeLabel(c))
}
return strings.Join(parts, "; ")
}
// handbookFile — последний полученный справочник участников M2M (для UI/поиска
// ИНН→MC-код контрагента).
const handbookFile = "/var/lib/bj/.bj/m2m-handbook.json"
// HandbookEntry — плоская запись справочника для сохранения/показа.
type HandbookEntry struct {
INN string `json:"inn"`
Code string `json:"code"` // MC-код участника (депозитарий/брокер)
Kind string `json:"kind"` // "depo" | "broker" | "place"
Name string `json:"name"` // краткое (рус.)
FullName string `json:"full_name,omitempty"`
DisplayName string `json:"display_name,omitempty"`
EngName string `json:"eng_name,omitempty"`
}
// saveHandbook раскладывает справочник в плоский список и пишет в файл.
func saveHandbook(hb *m2m.M2MTransferHandbook) {
out := struct {
At string `json:"at"`
Participants []HandbookEntry `json:"participants"`
Places []HandbookEntry `json:"settlement_places"`
}{At: time.Now().Format(time.RFC3339)}
for _, p := range hb.Participants.Participants {
e := HandbookEntry{INN: string(p.INN), Name: p.Names.Rus.ShortName, FullName: p.Names.Rus.FullName, DisplayName: p.Names.Rus.DisplayName}
if p.Names.Eng != nil {
e.EngName = p.Names.Eng.DisplayName
if e.EngName == "" {
e.EngName = p.Names.Eng.FullName
}
}
if e.Name == "" {
e.Name = e.DisplayName
}
if p.DepositoryPlace != nil {
e.Code, e.Kind = string(p.DepositoryPlace.ParticipantCode), "depo"
} else if p.BrokerPlace != nil {
e.Code, e.Kind = string(p.BrokerPlace.ParticipantCode), "broker"
}
out.Participants = append(out.Participants, e)
log.Printf("lk-gateway: справочник M2M: ИНН %s → %s [%s] %s", e.INN, e.Code, e.Kind, e.Name)
}
for _, pl := range hb.SettlementPlaces.Places {
out.Places = append(out.Places, HandbookEntry{INN: string(pl.INN), Name: pl.ShortName, DisplayName: pl.DisplayName, Kind: "place"})
}
if raw, err := json.Marshal(out); err == nil {
_ = os.WriteFile(handbookFile, raw, 0o600)
}
}