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
+205 -9
View File
@@ -7,6 +7,7 @@ import (
"log"
"net/http"
"sort"
"strings"
"sync"
"time"
@@ -62,12 +63,29 @@ func NewService(cfg Config) *Service {
// CreateClaim принимает DTO заявки, формирует M2MTransferRequest,
// создаёт сделку и отправляет в НРД.
// quikSendHook — вызывается перед отправкой M2MTR в НРД (регистрация «Вывод ЦБ (M2M)» в QUIK МНП).
// Ошибка хука отменяет отправку. Назначается в server.go.
var quikSendHook func(ctx context.Context, req *m2m.M2MTransferRequest) error
// sentHook — вызывается после успешной отправки M2MTR (запись в трансферные таблицы DEPO).
var sentHook func(req *m2m.M2MTransferRequest)
func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (CreateClaimResponse, error) {
req, err := s.BuildRequest(ctx, in)
if err != nil {
return CreateClaimResponse{}, err
}
return s.SendBuiltRequest(ctx, req, in.Investor.ID, []byte(in.SignedDocument))
}
// BuildRequest формирует обогащённый M2MTransferRequest из DTO (GUID,
// ReferenceID, реквизиты) БЕЗ отправки и без создания заявки. Нужно, чтобы
// сначала положить запрос в БД Fancy на проверку, а отправить позже.
func (s *Service) BuildRequest(ctx context.Context, in CreateClaimRequest) (*m2m.M2MTransferRequest, error) {
domainClaim, err := dtoToClaim(in)
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
return nil, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
}
receiver := s.defaultReceiver
if in.ReceiverCodeOverride != "" {
receiver = m2m.DeponentCode(in.ReceiverCodeOverride)
@@ -77,10 +95,43 @@ func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (Creat
ReceiverCode: receiver,
})
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: EnrichRequest: %w", err)
return nil, fmt.Errorf("lkgateway: EnrichRequest: %w", err)
}
return req, nil
}
deal, err := m2mcore.NewDeal(req.Header.GUID, in.Investor.ID, []byte(in.SignedDocument))
// BuildRequestUnchecked — BuildRequest без финальной проверки формата НРД: пакет нужен
// для журнала и отказа через Fancy даже при некорректных данных заявки (lkemulate.go).
func (s *Service) BuildRequestUnchecked(ctx context.Context, in CreateClaimRequest) (*m2m.M2MTransferRequest, error) {
domainClaim, err := dtoToClaim(in)
if err != nil {
return nil, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
}
receiver := s.defaultReceiver
if in.ReceiverCodeOverride != "" {
receiver = m2m.DeponentCode(in.ReceiverCodeOverride)
}
req, err := m2mcore.EnrichRequestUnchecked(ctx, s.store, domainClaim, m2mcore.SenderReceiver{
SenderCode: s.defaultSender,
ReceiverCode: receiver,
})
if err != nil {
return nil, fmt.Errorf("lkgateway: EnrichRequest: %w", err)
}
return req, nil
}
// SendBuiltRequest создаёт заявку (Deal) по готовому запросу, отправляет его в
// НРД и сохраняет ответ. Используется и обычным CreateClaim, и поллером Fancy
// (когда «кран» одобрил запрос в БД).
func (s *Service) SendBuiltRequest(ctx context.Context, req *m2m.M2MTransferRequest, investorID string, signedDoc []byte) (CreateClaimResponse, error) {
return s.SendBuiltRequestVia(ctx, s.sender, req, investorID, signedDoc)
}
// SendBuiltRequestVia — SendBuiltRequest через указанный отправитель (имитатор НРД
// для заявок ЛК в тестовом режиме, lkemulate.go). Хуки QUIK и DEPO срабатывают так же.
func (s *Service) SendBuiltRequestVia(ctx context.Context, sender m2mcore.NSDSender, req *m2m.M2MTransferRequest, investorID string, signedDoc []byte) (CreateClaimResponse, error) {
deal, err := m2mcore.NewDeal(req.Header.GUID, investorID, signedDoc)
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: NewDeal: %w", err)
}
@@ -88,17 +139,20 @@ func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (Creat
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Create: %w", err)
}
if err := saved.Validate(ctx, req); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Validate: %w", err)
}
resp, err := s.sender.Send(ctx, req)
// QUIK МНП: блокировка бумаг клиента до отправки в НРД (если цикл включён).
if quikSendHook != nil {
if err := quikSendHook(ctx, req); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: %w", err)
}
}
resp, err := sender.Send(ctx, req)
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: sender.Send: %w", err)
}
saved.Response = resp
if err := saved.Submit(ctx); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Submit: %w", err)
}
@@ -109,7 +163,9 @@ func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (Creat
s.mu.Lock()
s.claimToID[saved.ID] = saved.ID
s.mu.Unlock()
if sentHook != nil {
go sentHook(req)
}
return CreateClaimResponse{
ID: saved.ID,
Status: string(saved.State),
@@ -127,6 +183,35 @@ func (s *Service) GetClaim(ctx context.Context, id string) (ClaimView, error) {
return dealToView(deal), nil
}
// ClaimRawXML возвращает ТОЧНЫЕ байты (windows-1251) отправленного запроса и
// ответа НРД по заявке — для скачивания и пересылки в поддержку M2MOST@nsd.ru.
// kind: "request" (M2MTransferRequest) или "response" (M2MTransferResponse).
func (s *Service) ClaimRawXML(ctx context.Context, id, kind string) ([]byte, error) {
deal, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
switch kind {
case "request":
if deal.Request == nil {
return nil, errors.New("lkgateway: у заявки нет отправленного запроса")
}
return nsdxml.Marshal(deal.Request)
case "response":
if len(deal.RawResponse) == 0 {
return nil, errors.New("lkgateway: по заявке ещё нет ответа НРД")
}
return deal.RawResponse, nil
case "decision":
if deal.Decision == nil {
return nil, errors.New("lkgateway: по заявке нет решения НРД (Decision)")
}
return nsdxml.Marshal(deal.Decision)
default:
return nil, fmt.Errorf("lkgateway: неизвестный тип XML %q", kind)
}
}
// ListClaims возвращает страницу заявок.
func (s *Service) ListClaims(ctx context.Context, filter m2mcore.Filter) (ClaimsPage, error) {
if filter.Limit == 0 {
@@ -355,16 +440,23 @@ func dealToView(d *m2mcore.Deal) ClaimView {
UpdatedAt: d.UpdatedAt,
M2MGUID: d.GUID,
}
// Идентификатор инвестора (UUID) — для выгрузки всех заявок клиента.
out.Investor.ID = d.InvestorID
for _, st := range d.Stages {
out.Stages = append(out.Stages, StageView{
State: string(st.State), EnteredAt: st.EnteredAt, LeftAt: st.LeftAt, Reason: st.Reason,
})
}
if d.Request != nil {
// Точный отправленный пакет (windows-1251 → UTF-8) для просмотра в карточке.
if b, err := nsdxml.Marshal(d.Request); err == nil {
out.RequestRawXML = string(nsdxml.DecodeWindows1251(b))
}
out.TransferringDepositoryINN = string(d.Request.Data.TransferringDepository.INN)
out.ReceivingDepositoryINN = string(d.Request.Data.ReceivingDepository.INN)
ii := d.Request.Data.InvestorInformation
out.Investor = Investor{
ID: d.InvestorID,
LastName: ii.LastName, FirstName: ii.FirstName, MiddleName: ii.MiddleName,
Document: Document{
DocumentType: string(ii.IdentityDocument.DocumentType),
@@ -388,6 +480,32 @@ func dealToView(d *m2mcore.Deal) ClaimView {
BrokerINN: string(d.Request.Header.IIAAgreementDetails.BrokerINN),
}
}
// Ценные бумаги из отправленного запроса — для блока «ЦБ» в карточке
// и колонки ЦБ в журнале (раньше не заполнялось → показывало 0).
for _, sec := range d.Request.Data.TransferredSecurities.Securities {
cs := ClaimSec{SecurityCode: string(sec.SecurityCode)}
if sec.SecurityDetails.ISIN != nil {
cs.SecurityDetails.ISIN = string(*sec.SecurityDetails.ISIN)
}
// Количество: целое (Whole) или дробное (Fractional, у паёв). Раньше
// читали только Whole → паи показывались пустыми/«0».
if sec.Quantity.Whole != nil {
cs.Quantity.Whole = *sec.Quantity.Whole
} else if sec.Quantity.Fractional != nil {
cs.Quantity.Fractional = string(*sec.Quantity.Fractional)
}
for _, sa := range sec.SettlementAccount {
cs.SettlementAccounts = append(cs.SettlementAccounts, SettlementAccount{
SettlementRequisitesINN: string(sa.SettlementRequisites.INN),
SettlementLocation: SettlementLocation{
DeponentCode: sa.SettlementLocation.DeponentCode,
AccountID: string(sa.SettlementLocation.AccountID),
SectionID: sa.SettlementLocation.SectionID,
},
})
}
out.Securities = append(out.Securities, cs)
}
}
if d.Response != nil {
out.M2MResponse = responseToView(d.Response)
@@ -403,9 +521,87 @@ func dealToView(d *m2mcore.Deal) ClaimView {
cb := callbackForDeal(d)
out.LastCallback = &cb
}
// Самопроверка тестовой заявки роботу: серия документа = код сценария.
out.TestResult = buildTestResult(out.Investor.Document.Series, out.M2MDecision, out.M2MResponse)
return out
}
// buildTestResult формирует «ожидали/получили/вердикт» для тестовой заявки
// роботу НРД. Сценарий определяется по серии документа инвестора (= код
// сценария 1111/2001/2002/3333). Для нетестовых заявок возвращает nil.
func buildTestResult(series string, dec *NSDDecisionView, resp *NSDResponseView) *TestResultView {
expected := map[string][2]string{
"1111": {"Ответ с отказом", "M2MTransferDecision: все бумаги отклонены (Rejection) с кодом ошибки из номера документа"},
"2001": {"Принять все бумаги", "M2MTransferDecision: все бумаги приняты (Confirmation)"},
"2002": {"Принять бумаги частично", "M2MTransferDecision: часть бумаг принята, часть отклонена (M2M05)"},
"3333": {"Робот — принимающая сторона", "M2MTransferDecision: отказ M2M05 + отдельное сообщение — новый перевод от робота"},
}
exp, ok := expected[series]
if !ok {
return nil // не тестовая заявка
}
tr := &TestResultView{Scenario: series, Title: exp[0], Expected: exp[1]}
// Сначала смотрим решение (Decision) — целевой ответ для всех сценариев.
if dec != nil && len(dec.Securities) > 0 {
conf, rej := 0, 0
codes := map[string]bool{}
for _, s := range dec.Securities {
if s.Outcome == "confirmed" {
conf++
} else {
rej++
}
for _, c := range s.RejectCodes {
codes[c] = true
}
}
codeList := make([]string, 0, len(codes))
for c := range codes {
codeList = append(codeList, c)
}
sort.Strings(codeList)
tr.Got = fmt.Sprintf("M2MTransferDecision: принято %d, отклонено %d", conf, rej)
if len(codeList) > 0 {
tr.Got += " (коды отказа: " + strings.Join(codeList, ", ") + ")"
}
// Оценка по сценарию.
switch series {
case "2001":
tr.statusFromBool(conf > 0 && rej == 0, "все бумаги приняты — как и ожидалось", "ожидался приём всех бумаг")
case "1111":
tr.statusFromBool(rej > 0 && conf == 0, "все бумаги отклонены — как и ожидалось", "ожидался отказ по всем бумагам")
case "2002":
tr.statusFromBool(conf > 0 && rej > 0, "часть принята, часть отклонена — как и ожидалось", "ожидался частичный приём")
case "3333":
tr.statusFromBool(rej > 0, "робот отклонил перевод (M2M05) и должен прислать свой перевод отдельным сообщением", "ожидался отказ M2M05")
}
return tr
}
// Решения нет — но мог прийти ответ сервиса МОСТ (ошибка формата/бумаг).
if resp != nil {
var codes []string
for _, e := range resp.Responses {
if e.Code != "" {
codes = append(codes, e.Code)
}
}
tr.Got = "Ответ сервиса МОСТ: " + resp.StatusCode
if len(codes) > 0 {
tr.Got += " (" + strings.Join(codes, ", ") + ")"
}
tr.Status = "error"
tr.Note = "решение не получено — сервис НРД вернул ошибку до приёмки (см. коды)"
return tr
}
tr.Got = "ответ робота ещё не получен"
tr.Status = "waiting"
tr.Note = "ждём ответ робота (обычно 30–60 сек)"
return tr
}
func responseToView(r *m2m.M2MTransferResponse) *NSDResponseView {
v := &NSDResponseView{
GUID: string(r.GUID),