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 && conf > 0 { // тест «боевой ЛК → TEST3»: согласие контрагента — ещё не исполнение; «Исполнена» — по MS010 (realtest.go) 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 }