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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user