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) }