Files
Bridge-and-Join-s/internal/lkgateway/lkemulate.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

384 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/m2mcore"
"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
// nsdEmuMode — режим эмуляции НРД: "off" | "substitute" | "imitate". Выбор контура НРД
// (NSD.Profile — test3 / боевой) от режима НЕ зависит: при "off" заявка уходит в выбранный
// контур как есть. Режимы взаимоисключающие (ведутся флагами EmulateNSD / RealNSDTest.Enabled).
func nsdEmuMode(rc *RuntimeConfig) string {
s := rc.Snapshot()
switch {
case s.LK.EmulateNSD:
return "imitate"
case s.LK.RealNSDTest.Enabled:
return "substitute"
}
return "off"
}
// lkEmulateNSD — обрабатывается ли заявка ЛК слоем эмуляции (имитация или подмена личности).
// "off" — заявка идёт в выбранный контур НРД с реальными данными клиента (обычный путь).
func lkEmulateNSD(rc *RuntimeConfig) (bool, string) {
switch nsdEmuMode(rc) {
case "imitate":
return true, "полная имитация — в НРД ничего не отправляется, ответ формирует bj"
case "substitute":
return true, "подмена личности — в НРД уходит тест-инвестор вместо клиента"
}
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, "—")))
// Выбор отправителя по режиму эмуляции НРД:
// imitate — внутренний имитатор (в НРД ничего не уходит, ответ формирует bj);
// substitute — реальная отправка в выбранный контур НРД; клиент из списка идёт с подменой
// блока инвестора на тест-инвестора, остальные — с настоящими данными.
sender := m2mcore.NSDSender(lkEmuSender) // imitate (по умолчанию в этом пути)
realNSD := false
if nsdEmuMode(rc) == "substitute" && svc.sender != nil {
realNSD = true
if realTestMatch(rc, inv.Document.Series, inv.Document.Number) {
asInv, asText := realTestInvestor(rc, req.Data.InvestorInformation.IdentityDocument.DocumentType)
realTestPut(RealTestRec{GUID: guid, ClaimID: id, At: time.Now(), As: asText,
Client: strings.TrimSpace(fmt.Sprintf("%s %s %s, %s %s", inv.LastName, inv.FirstName, inv.MiddleName, inv.Document.Series, inv.Document.Number))})
sender = substituteSender{real: svc.sender, inv: asInv}
globalLKFlows.step(guid, id, "gateway", "Подмена личности: в НРД ("+rc.Snapshot().NSD.Profile+") с данными тест-инвестора",
"У нас заявка проходит как клиент ("+inv.LastName+"): DEPO, QUIK МНП (боевая запись), статусы в ЛК. В НРД блок инвестора заменён на "+asText+"; GUID и референсы — настоящие.")
log.Printf("lk-gateway: ЛК #%d (%s): подмена личности — в НРД как %s", id, shortGUID(guid), asText)
} else {
// подмена включена, но клиент не в списке — отправляем в НРД с настоящими данными
sender = svc.sender
globalLKFlows.step(guid, id, "gateway", "В НРД ("+rc.Snapshot().NSD.Profile+") с настоящими данными",
"Подмена включена, но этот клиент не в списке — запрос уходит в НРД как есть.")
log.Printf("lk-gateway: ЛК #%d (%s): отправка в НРД с настоящими данными (вне списка подмены)", id, shortGUID(guid))
}
}
if _, err := svc.SendBuiltRequestVia(ctx, sender, 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
}
if realNSD {
// НРД не принял пакет — заявка «Не отправлена», повтор автоматически (sendfail.go); в ЛК — «В работе»
globalLKFlows.step(guid, id, "reject", "НРД не принял пакет — повтор автоматически", err.Error())
lkMarkSent(id)
lkLinkGUID(guid, ep, id)
return "sent", nil
}
return "", fmt.Errorf("имитация отправки: %w", err)
}
lkMarkSent(id)
lkLinkGUID(guid, ep, id)
globalLKFlows.update(guid, func(f *LKFlow) { f.Stage = "sent" })
sentTitle := "Бумаги заблокированы, заявка обработана шлюзом bj без отправки в НРД"
if devMode(rc) {
sentTitle = "Бумаги заблокированы, заявка передана имитатору НРД"
}
sentDetail := lkQuikSummary(guid) + fmt.Sprintf(" В НРД не отправлялась; ответ контрагента — через %s.", lkEmuDecisionDelayNow(rc))
if realNSD {
sentTitle, sentDetail = "Бумаги заблокированы, запрос перевода отправлен в НРД (TEST3)", lkQuikSummary(guid)+" Ждём решения контрагента от НРД."
}
globalLKFlows.step(guid, id, "gateway", sentTitle, sentDetail)
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)
}
if realNSD {
log.Printf("lk-gateway: ЛК #%d (%s): сверка пройдена, бумаги заблокированы, отправлено в НРД TEST3 с подменой инвестора", id, shortGUID(guid))
return "sent", nil
}
log.Printf("lk-gateway: ЛК #%d (%s): сверка пройдена, бумаги заблокированы, заявка передана имитатору НРД — решение контрагента через %s", id, shortGUID(guid), lkEmuDecisionDelayNow(rc))
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++
}
}
respTitle := "Решение по заявке (без отправки в НРД)"
if devMode(s.rc) {
respTitle = "Решение контрагента (имитатор НРД)"
}
globalLKFlows.stepIfExists(guid, "response", respTitle, 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
if (realTestGet(string(decision.Header.GUID)) != nil || source == "поллер") && conf > 0 {
// согласие контрагента — ещё не исполнение: «Исполнена» в ЛК — по MS010 на наше поручение 16
// (outOnExecuted, тест «боевой ЛК → TEST3» — realtest.go); имитатор НРД MS010 не шлёт — итог сразу
globalLKFlows.stepIfExists(string(decision.Header.GUID), "response", "Контрагент согласился — ждём исполнения НРД",
fmt.Sprintf("Принято %d, отклонено %d. Статус в ЛК — «В работе» до отчёта НРД об исполнении (MS010).", conf, rej))
} else {
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)
}
// applyNSDEmuSettings переносит настройки эмулятора НРД (страница «Эмуляторы»)
// в конфиг работающего сендера — без пересоздания: канал Decisions() привязан
// к экземпляру. Вызывается при старте и после сохранения настроек.
func applyNSDEmuSettings(rc *RuntimeConfig) {
if lkEmuSender == nil {
return
}
s := rc.Snapshot().NSDEmu.Defaults()
cfg := mock.DefaultConfig()
cfg.NSDSenderCode = "MC0010300000"
cfg.DecisionDelay = time.Duration(s.DelaySec) * time.Second
switch s.Outcome {
case "reject":
cfg.DefaultOutcome = mock.OutcomeReject
case "timeout":
cfg.DefaultOutcome = mock.OutcomeTimeout
}
cfg.RejectionCode = s.RejectCode
lkEmuSender.Reconfigure(cfg)
}
// lkEmuDecisionDelayNow — текущая задержка решения имитатора (для текстов в ЛК).
func lkEmuDecisionDelayNow(rc *RuntimeConfig) time.Duration {
return time.Duration(rc.Snapshot().NSDEmu.Defaults().DelaySec) * time.Second
}