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
+289
View File
@@ -0,0 +1,289 @@
package lkgateway
// Имитация НРД для заявок ЛК ЕСИА Финанс. Заявка проходит весь путь, как в бою:
//
// ЛК (depo_m2m) → журнал исходящих заявок bj → проверка шлюза (сверка списания с DEPO)
// отказ → сделка «Отклонена» → отказ в Fancy (DEPO) → ответ Fancy → ЛК «Отклонена» (lkflow.go)
// принять → блокировка в QUIK МНП → DEPO (BJ_SENT) → «отправка» в имитатор НРД
// → через lkEmuDecisionDelay решение контрагента → тот же обработчик, что у
// настоящего M2MTransferDecision (сделка, ЛК «Исполнена», QUIK, DEPO)
//
// В НРД ничего не уходит. Включается флагом LK.EmulateNSD и принудительно, если активен
// боевой ЛК, а профиль ИШ тестовый: настоящие заявки клиентов в тестовый НРД не шлём.
import (
"context"
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock"
)
const lkEmuDecisionDelay = 30 * time.Second
// lkEmuNote дописывается к статусу в ЛК, чтобы было видно, что НРД не участвовал.
const lkEmuNote = " (тестовый режим: в НРД не отправлялось)"
// lkEmuSender — имитатор НРД для заявок ЛК; создаётся в Server.Run.
var lkEmuSender *mock.Sender
// lkEmulateNSD — идут ли заявки ЛК через имитатор НРД и почему.
func lkEmulateNSD(rc *RuntimeConfig) (bool, string) {
s := rc.Snapshot()
if s.LK.EmulateNSD {
return true, "включена в настройках"
}
if s.LK.ActiveEnv == "prod" && strings.HasPrefix(strings.ToLower(s.NSD.Profile), "test") {
return true, "боевой ЛК при тестовом профиле ИШ " + s.NSD.Profile
}
return false, ""
}
// lkProcessEmulated — путь заявки ЛК без отправки в НРД. Возвращает "rejected" или
// "sent"; ошибка — заявку нужно повторить на следующем проходе (DEPO недоступна и т.п.).
func lkProcessEmulated(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKEndpoint, id int, claimReq CreateClaimRequest) (string, error) {
if lkEmuSender == nil {
return "", fmt.Errorf("имитатор НРД не запущен")
}
inv := claimReq.Investor
// Формат полей заявки не решает, берём ли её: решает проверка шлюза.
var secs []depoOutSec
acc, section := "", ""
for _, sec := range claimReq.Securities {
qty := sec.Quantity.Fractional
if qty == "" {
qty = strconv.FormatUint(sec.Quantity.Whole, 10)
}
secs = append(secs, depoOutSec{ISIN: sec.SecurityDetails.ISIN, Code: sec.SecurityCode, Qty: qty})
if len(sec.SettlementAccounts) > 0 && acc == "" {
loc := sec.SettlementAccounts[0].SettlementLocation
acc, section = loc.AccountID, loc.SectionID
}
}
res := runDepoCheckOutAccount(inv.LastName, inv.FirstName, inv.MiddleName, inv.Document.Series, inv.Document.Number, inv.BirthDate, secs, acc, section, claimReq.ClientAccount)
if !res.Ran {
return "", fmt.Errorf("сверка с DEPO не выполнена: %s", res.Err)
}
// Пакет собираем и при некорректных данных: заявка должна попасть в журнал и в Fancy.
req, err := svc.BuildRequestUnchecked(ctx, claimReq)
if err != nil {
return "", fmt.Errorf("сборка пакета: %w", err)
}
guid := string(req.Header.GUID)
log.Printf("lk-gateway: ЛК #%d (%s): сверка с DEPO — %s (инвестор %s, счёт %s)", id, shortGUID(guid), res.Summary, pick(res.Investor, "не найден"), pick(res.Account, "—"))
globalLKFlows.step(guid, id, "db", "Заявка из ЛК ЕСИА Финанс", fmt.Sprintf("Забрана коннектором bj из личного кабинета (заявка ЛК #%d).", id))
code, short, reason := "", "", ""
if res.Recommend != "confirm" {
code, short, reason = lkDepoRejectText(res)
} else if verr := req.Validate(); verr != nil {
short, reason = "Отказ: данные заявки не прошли проверку", "Отказ: данные заявки не прошли проверку формата НРД — "+verr.Error()
}
if reason != "" {
globalLKFlows.step(guid, id, "reject", "Проверка шлюза bj: отказ", reason)
lkGatewayReject(ctx, rc, svc, id, inv.ID, req, code, short, reason)
log.Printf("lk-gateway: ЛК #%d (%s): отказ шлюза, передан в Fancy — %s", id, shortGUID(guid), reason)
return "rejected", nil
}
globalLKFlows.step(guid, id, "crane", "Проверка шлюза bj: можно исполнить",
fmt.Sprintf("Сверка с DEPO: %s (инвестор %s, счёт %s).", res.Summary, pick(res.Investor, "—"), pick(res.Account, "—")))
if _, err := svc.SendBuiltRequestVia(ctx, lkEmuSender, req, inv.ID, nil); err != nil {
if strings.Contains(err.Error(), "QUIK") {
reason := "Отказ: не удалось заблокировать бумаги клиента — " + err.Error()
globalLKFlows.step(guid, id, "reject", "Блокировка в QUIK не удалась", err.Error())
lkGatewayReject(ctx, rc, svc, id, inv.ID, req, "QUIK", "Отказ: не удалось заблокировать ценные бумаги", reason)
return "rejected", nil
}
return "", fmt.Errorf("имитация отправки: %w", err)
}
lkMarkSent(id)
lkLinkGUID(guid, ep, id)
globalLKFlows.update(guid, func(f *LKFlow) { f.Stage = "sent" })
globalLKFlows.step(guid, id, "gateway", "Бумаги заблокированы, заявка передана имитатору НРД",
lkQuikSummary(guid)+fmt.Sprintf(" В НРД не отправлялась; ответ контрагента — через %s.", lkEmuDecisionDelay))
if pc, pb := lkPatch(ctx, ep, fmt.Sprintf("/claims/%d", id), []byte(`{"claim":{"status":"in_process"}}`)); pc >= 400 {
log.Printf("lk-gateway: ЛК #%d: PATCH «В работе» не прошёл: HTTP %d %s", id, pc, pb)
}
log.Printf("lk-gateway: ЛК #%d (%s): сверка пройдена, бумаги заблокированы, заявка передана имитатору НРД — решение контрагента через %s", id, shortGUID(guid), lkEmuDecisionDelay)
return "sent", nil
}
// lkQuikSummary — что сделал QUIK МНП по переводу (для шага пути).
func lkQuikSummary(guid string) string {
links := globalQuikLinks.ForGUID(guid)
if len(links) == 0 {
return "QUIK: блокировка не выполнялась (цикл QUIK выключен или клиент не найден в DEPO)."
}
var parts []string
for _, l := range links {
p := fmt.Sprintf("%s × %v — %s", l.ISIN, l.Qty, l.StateRu())
if l.InstrNo != "" {
p += ", поручение №" + l.InstrNo
}
if l.Err != "" {
p += ": " + l.Err
}
parts = append(parts, p)
}
return "QUIK: " + strings.Join(parts, "; ") + "."
}
// lkDepoRejectText — код отказа по результату сверки: short — короткий текст для клиента в ЛК
// («Отказ по результатам проверки депозитария: <причина> (код)»), text — с подробностями
// проверок для журнала bj и Fancy.
func lkDepoRejectText(res depoResult) (code, short, text string) {
var codes, labels, details []string
for _, s := range res.Secs {
for _, c := range s.Checks {
if c.OK {
continue
}
if c.Code != "" && !containsStr(codes, c.Code) {
codes = append(codes, c.Code)
labels = append(labels, m2mCodeLabel(c.Code))
}
details = append(details, c.Name+": "+c.Detail)
}
}
if len(codes) > 0 {
code = codes[0]
}
short = "Отказ по результатам проверки депозитария"
if len(labels) > 0 {
short += ": " + labels[0]
}
text = "Отказ по результатам проверки депозитария"
if len(labels) > 0 {
text += ": " + strings.Join(labels, "; ")
}
if len(details) > 0 {
text += ". Причина: " + strings.Join(details, "; ")
}
if r := []rune(text); len(r) > 1000 {
text = string(r[:1000]) + "…"
}
return code, short, text
}
func containsStr(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// lkPatchRejected — заявка ЛК «Отклонена» с причиной в client_notation; помечается
// обработанной, чтобы поллер не брал её повторно. Возвращает HTTP-код и ответ ЛК.
func lkPatchRejected(ctx context.Context, ep LKEndpoint, id int, reason string) (int, string) {
lkMarkSent(id)
body, _ := json.Marshal(map[string]any{"claim": map[string]any{"status": "rejected", "client_notation": reason}})
pc, pb := lkPatch(ctx, ep, fmt.Sprintf("/claims/%d", id), body)
if pc >= 400 || pc == 0 {
log.Printf("lk-gateway: ЛК #%d: PATCH «Отклонена» не прошёл: HTTP %d %s", id, pc, pb)
} else {
log.Printf("lk-gateway: ЛК #%d: отклонена — %s", id, reason)
}
return pc, pb
}
// lkDataError — заявку ЛК нельзя отправлять из-за её данных: повтор не поможет, отказываем.
type lkDataError struct{ Code, Text string }
func (e *lkDataError) Error() string { return e.Text }
// lkRejectInvalid — отказ по данным заявки в реальном режиме (без имитации, до отправки в НРД).
func lkRejectInvalid(ctx context.Context, rc *RuntimeConfig, ep LKEndpoint, id int, de *lkDataError) {
reason := "Отказ: заявка не может быть исполнена"
if de.Code != "" {
reason = "Отказ: " + m2mCodeLabel(de.Code)
}
log.Printf("lk-gateway: ЛК #%d: отказ по данным заявки — %s (%s)", id, de.Text, de.Code)
if pc, pb := lkPatchRejected(ctx, ep, id, reason); pc >= 400 || pc == 0 {
raiseSystemAlert(rc, "ЛК: не удалось отклонить заявку", fmt.Sprintf("Заявка ЛК #%d отклонена (%s), но статус в ЛК не изменён: HTTP %d %s", id, reason, pc, pb))
}
}
// consumeEmulatedDecisions — решения контрагента от имитатора НРД идут тем же путём,
// что настоящий M2MTransferDecision из ИШ.
func (s *Server) consumeEmulatedDecisions(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case d := <-lkEmuSender.Decisions():
if d == nil {
continue
}
guid := string(d.Header.GUID)
log.Printf("lk-gateway: имитатор НРД: решение контрагента по %s", shortGUID(guid))
conf, rej := 0, 0
for _, sec := range d.Data.Securities {
if sec.TransferDecision.Confirmation != nil {
conf++
} else {
rej++
}
}
globalLKFlows.stepIfExists(guid, "response", "Решение контрагента (имитатор НРД)", fmt.Sprintf("Принято %d, отклонено %d.", conf, rej))
s.applyDecisionEffects(ctx, d, "имитатор НРД", lkEmuNote)
}
}
}
// applyDecisionEffects — M2MTransferDecision контрагента: сделка, статус в таблице
// обмена Fancy и в ЛК, снятие блокировок QUIK по отказанным бумагам, событие DEPO.
// note дописывается к статусу в ЛК.
func (s *Server) applyDecisionEffects(ctx context.Context, decision *m2m.M2MTransferDecision, source, note string) {
if err := s.svc.ApplyDecision(ctx, decision); err != nil {
// Детерминированные ошибки (сделка уже в терминальном статусе — напр.
// повторный/встречный Decision, «недопустимый переход FSM») повтором
// не лечатся. Логируем и помечаем обработанным, иначе поллер зациклится.
log.Printf("lk-gateway: %s: Decision GUID=%s не применён (пропуск): %v", source, decision.Header.GUID, err)
return
}
// Разбираем исход по бумагам: сколько принято/отклонено и с какими кодами.
conf, rej := 0, 0
var rejCodes []string
for _, sec := range decision.Data.Securities {
if sec.TransferDecision.Confirmation != nil {
conf++
} else if sec.TransferDecision.Rejection != nil {
rej++
rejCodes = append(rejCodes, sec.TransferDecision.Rejection.Codes...)
}
}
lkStatus, lkReason, fancyStatus, fancyVerdict := "done", "Перевод исполнен", "done", "approved"
switch {
case conf == 0 && rej > 0: // полный отказ принимающей стороны
lkStatus, fancyStatus, fancyVerdict = "rejected", "declined", "rejected"
lkReason = "Отказ принимающей стороны: " + rejectCodesLabel(rejCodes)
case rej > 0: // частичный приём
lkReason = fmt.Sprintf("Частично исполнено: принято %d, отклонено %d (%s)", conf, rej, rejectCodesLabel(rejCodes))
default: // всё принято
lkReason = "Перевод исполнен"
}
// Итог для заявки модуля ЛК (bj-lk), если перевод оттуда.
if e := lkFinalizeByGUID(ctx, s.rc, string(decision.Header.GUID), fancyStatus, fancyVerdict, lkReason); e != nil {
log.Printf("lk-gateway: %s: finalize(%s) GUID=%s: %v", source, fancyStatus, decision.Header.GUID, e)
}
_ = note // клиенту в ЛК — только короткий итог, подробности в журнале bj
OnLKOutcome(ctx, s.rc, string(decision.Header.GUID), lkStatus, lkReason)
quikOnDecision(ctx, s.rc, s.svc, decision)
go depoOnCounterpartyDecision(s.rc, decision)
}
func newLKEmuSender() *mock.Sender {
cfg := mock.DefaultConfig()
cfg.DecisionDelay = lkEmuDecisionDelay
cfg.NSDSenderCode = "MC0010300000"
return mock.NewSender(cfg)
}