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