Files
Bridge-and-Join-s/internal/lkgateway/service.go
T
fontvielle 21d5f33394 1С: проверка поручений МНП перед отправкой в НРД
Часть модуля onec-back, по умолчанию выключена, включается в настройках
(«Проверка поручений в 1С»). Исходящий перевод после блокировки бумаг в
МНП ждёт решения 1С и в НРД не уходит, пока 1С не подтвердит каждое
поручение; при отказе bj снимает поручения в QUIK с причиной из 1С и
отклоняет перевод в журнале и кабинете клиента.

- onec.instr / onec.instr_queue / onec.set_decision в базе bj: 1С читает
  очередь и пишет решение под логином onec_bj (только эти права); чтение
  очереди отмечает связь с 1С.
- bj-onec-admin (root, через bj-keymedia): логин onec_bj, вход только с
  IP 1С по TLS (pg_hba.conf), listen_addresses, ufw; revoke закрывает вход.
- onecLoop: отправка подтверждённых, отказы, снятие клиентом в QUIK,
  поручения из терминала, сроки решения и потеря связи — оповещения.
- Карточка заявки: блок «Проверка 1С», решение оператора вместо 1С.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-22 20:13:59 +03:00

794 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"sort"
"strings"
"sync"
"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/nsdxml"
)
// Service — бизнес-логика lk-gateway: преобразует DTO в доменные сущности
// m2mcore, оркестрирует FSM сделки, эмитит callback'и в ЛК.
type Service struct {
repo m2mcore.Repository
sender m2mcore.NSDSender
store m2mcore.FansyStore
recorder *m2mcore.MemoryRecorder
defaultSender m2m.DeponentCode
defaultReceiver m2m.DeponentCode
callbackURL string
httpClient *http.Client
mu sync.RWMutex
claimToID map[string]string // claim public ID -> internal deal ID
}
// Config — параметры сервиса.
type Config struct {
Repository m2mcore.Repository
Sender m2mcore.NSDSender
Store m2mcore.FansyStore
Recorder *m2mcore.MemoryRecorder
DefaultSender m2m.DeponentCode
DefaultReceiver m2m.DeponentCode
CallbackURL string
}
// NewService собирает сервис.
func NewService(cfg Config) *Service {
if cfg.Recorder == nil {
cfg.Recorder = m2mcore.NewMemoryRecorder()
}
return &Service{
repo: cfg.Repository,
sender: cfg.Sender,
store: cfg.Store,
recorder: cfg.Recorder,
defaultSender: cfg.DefaultSender,
defaultReceiver: cfg.DefaultReceiver,
callbackURL: cfg.CallbackURL,
httpClient: &http.Client{Timeout: 5 * time.Second},
claimToID: make(map[string]string),
}
}
// 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)
// onecSendHook — проверка поручений в 1С (onec_check.go): true — перевод ждёт решения 1С и в НРД
// сейчас не уходит (заявка остаётся «Не отправлена», отправит onecLoop). Назначается в server.go.
var onecSendHook func(ctx context.Context, req *m2m.M2MTransferRequest) (bool, error)
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) {
if s.defaultSender == "" {
return nil, errors.New("организация не задана: укажите код участника НРД (мастер настройки, шаг «Организация») — без него перевод не сформировать")
}
in = withNSDSecCodes(in)
domainClaim, err := dtoToClaim(in)
if err != nil {
return nil, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
}
receiver := s.receiverFor(in)
if in.ReceiverCodeOverride == "" && handbookCodeByINN(in.ReceivingDepositoryINN) == "" && handbookLoaded() && !devMode(globalRC) {
// запасной код получателя из настроек — чужой участник: НРД отклонит перевод (M2M15). Лучше сказать сразу.
return nil, fmt.Errorf("%w: ИНН %s", errReceiverUnknown, in.ReceivingDepositoryINN)
}
req, err := m2mcore.EnrichRequest(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
}
// errReceiverUnknown — депозитария получателя нет в справочнике участников M2M НРД.
var errReceiverUnknown = errors.New("депозитарий получателя не найден в справочнике участников M2M НРД")
// handbookLoaded — справочник участников M2M получен (без него получателя не определить ни для кого).
func handbookLoaded() bool {
raw, err := readHandbook()
return err == nil && len(raw) > 200
}
// receiverFor — код получателя (зачисляющего депозитария) в заголовке запроса: явный (робот НРД,
// тест-кейсы) → по ИНН зачисляющего депозитария из справочника участников M2M → из настроек.
// Без справочника заявки ЛК уходили с кодом из настроек, и НРД отвечал M2M15 (29.09.2026).
func (s *Service) receiverFor(in CreateClaimRequest) m2m.DeponentCode {
if in.ReceiverCodeOverride != "" {
return m2m.DeponentCode(in.ReceiverCodeOverride)
}
if code := handbookCodeByINN(in.ReceivingDepositoryINN); code != "" {
return m2m.DeponentCode(code)
}
return s.defaultReceiver
}
// BuildRequestUnchecked — BuildRequest без финальной проверки формата НРД: пакет нужен
// для журнала и отказа через Fancy даже при некорректных данных заявки (lkemulate.go).
func (s *Service) BuildRequestUnchecked(ctx context.Context, in CreateClaimRequest) (*m2m.M2MTransferRequest, error) {
in = withNSDSecCodes(in)
domainClaim, err := dtoToClaim(in)
if err != nil {
return nil, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
}
receiver := s.receiverFor(in)
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) {
// Заявка с этим GUID уже создавалась, но в НРД не дошла (sendfail.go) — отправляем её же.
saved := s.unsentDeal(ctx, req.Header.GUID)
resend := saved != nil
if !resend {
deal, err := m2mcore.NewDeal(req.Header.GUID, investorID, signedDoc)
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: NewDeal: %w", err)
}
if saved, err = s.repo.Create(ctx, deal); 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)
}
// Сохраняем до отправки: если НРД пакет не примет, заявка останется «Не отправлена» с запросом.
if err := s.repo.Update(ctx, saved); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Update: %w", err)
}
} else {
req = saved.Request
}
// QUIK МНП: блокировка бумаг клиента до отправки в НРД (если цикл включён). При повторной
// отправке бумаги уже заблокированы первой попыткой.
if quikSendHook != nil && !(resend && quikHasRegistered(string(req.Header.GUID))) {
if err := quikSendHook(ctx, req); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: %w", err)
}
}
// Проверка 1С: бумаги уже заблокированы в МНП, в НРД — только после решения 1С.
if onecSendHook != nil {
wait, err := onecSendHook(ctx, req)
if err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: %w", err)
}
if wait {
if !resend {
_ = s.repo.AppendEvent(ctx, saved.ID, m2mcore.Event{Type: "onec_wait", CreatedAt: time.Now().UTC(), Actor: "system"})
}
return CreateClaimResponse{ID: saved.ID, Status: onecWaitStatus, CreatedAt: saved.CreatedAt, Success: true}, nil
}
}
resp, err := sender.Send(ctx, req)
if err != nil {
s.noteSendFailure(ctx, saved, req, err)
return CreateClaimResponse{}, fmt.Errorf("lkgateway: sender.Send: %w", err)
}
sendFailClear(string(req.Header.GUID))
saved.Response = resp
if err := saved.Submit(ctx); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Submit: %w", err)
}
if err := s.repo.Update(ctx, saved); err != nil {
return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Update: %w", err)
}
s.recorder.IncDeal(saved.State)
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),
CreatedAt: saved.CreatedAt,
Success: true,
}, nil
}
// GetClaim возвращает полную карточку заявки.
func (s *Service) GetClaim(ctx context.Context, id string) (ClaimView, error) {
deal, err := s.repo.GetByID(ctx, id)
if err != nil {
return ClaimView{}, err
}
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 {
filter.Limit = 50
}
deals, err := s.repo.List(ctx, filter)
if err != nil {
return ClaimsPage{}, err
}
sort.Slice(deals, func(i, j int) bool { return deals[i].CreatedAt.After(deals[j].CreatedAt) })
items := make([]ClaimView, 0, len(deals))
for _, d := range deals {
items = append(items, dealToView(d))
}
return ClaimsPage{Items: items, Total: len(items), Limit: filter.Limit, Offset: filter.Offset}, nil
}
// ApplyDecision принимает Decision (из mock-NSDSender или реального адаптера),
// обновляет соответствующую сделку и шлёт callback в ЛК.
func (s *Service) ApplyDecision(ctx context.Context, decision *m2m.M2MTransferDecision) error {
if decision == nil {
return errors.New("lkgateway: ApplyDecision: decision=nil")
}
deal, err := s.repo.GetByGUID(ctx, decision.Header.GUID)
if err != nil {
return fmt.Errorf("lkgateway: GetByGUID: %w", err)
}
if err := deal.ReceiveDecision(ctx, decision); err != nil {
return fmt.Errorf("lkgateway: ReceiveDecision: %w", err)
}
if err := s.repo.Update(ctx, deal); err != nil {
return fmt.Errorf("lkgateway: repo.Update: %w", err)
}
s.recorder.IncDeal(deal.State)
if s.callbackURL != "" {
s.sendCallback(ctx, deal)
}
return nil
}
// ApplyServiceResponse применяет M2MTransferResponse (ответ сервиса МОСТ) к
// сделке: сохраняет ответ, при ERROR переводит сделку в Rejected и шлёт
// callback в ЛК. Сделку ищем по GUID ответа.
func (s *Service) ApplyServiceResponse(ctx context.Context, resp *m2m.M2MTransferResponse, raw []byte) error {
if resp == nil {
return errors.New("lkgateway: ApplyServiceResponse: resp=nil")
}
deal, err := s.findDealForResponse(ctx, resp)
if err != nil {
return fmt.Errorf("lkgateway: поиск сделки для ответа: %w", err)
}
prev := deal.State
if err := deal.ReceiveServiceResponse(ctx, resp, raw); err != nil {
return fmt.Errorf("lkgateway: ReceiveServiceResponse: %w", err)
}
if err := s.repo.Update(ctx, deal); err != nil {
return fmt.Errorf("lkgateway: repo.Update: %w", err)
}
// Состояние сменилось (ERROR → Rejected) — учитываем в метриках и шлём
// callback. На INFO состояние не меняется — callback не нужен.
if deal.State != prev {
s.recorder.IncDeal(deal.State)
if s.callbackURL != "" {
s.sendCallback(ctx, deal)
}
}
return nil
}
// zeroGUID — нулевой UUID, который НРД присылает в сервисных ошибках
// (напр. M2M14), когда не идентифицирует исходный запрос.
const zeroGUID = "00000000-0000-0000-0000-000000000000"
// findDealForResponse сопоставляет ответ МОСТ со сделкой. Сначала по GUID;
// если GUID нулевой/пустой или сделка по нему не найдена (реальное поведение
// НРД при M2M14 — ответ без нашего GUID и без ReferenceID), применяем
// эвристику: берём самую раннюю ожидающую решение заявку без ответа. Для
// тестового сценария «одна заявка в полёте» это однозначно; при множестве
// заявок in-flight такие сервисные ошибки в принципе неразличимы на стороне
// НРД, и FIFO — наилучшее доступное приближение.
func (s *Service) findDealForResponse(ctx context.Context, resp *m2m.M2MTransferResponse) (*m2mcore.Deal, error) {
guid := string(resp.GUID)
if guid != "" && guid != zeroGUID {
deal, err := s.repo.GetByGUID(ctx, resp.GUID)
if err == nil {
return deal, nil
}
if !errors.Is(err, m2mcore.ErrNotFound) {
return nil, err
}
// GUID конкретный, но среди ИСХОДЯЩИХ сделок его нет — это сервисный ответ по
// чужому/входящему GUID (например M2M31 на наше решение по ВХОДЯЩЕМУ переводу).
// FIFO здесь ошибочно пометил бы не ту исходящую заявку — поэтому не угадываем.
return nil, m2mcore.ErrNotFound
}
// Fallback — ТОЛЬКО для ответа без идентифицируемого GUID (нулевой/пустой: поведение НРД при M2M14).
st := m2mcore.StateAwaitingDecision
deals, err := s.repo.List(ctx, m2mcore.Filter{State: &st, Limit: 200})
if err != nil {
return nil, err
}
var cand *m2mcore.Deal
for _, d := range deals {
if d.Response != nil {
continue // этой заявке ответ уже сопоставлен
}
if cand == nil || d.CreatedAt.Before(cand.CreatedAt) {
cand = d
}
}
if cand == nil {
return nil, m2mcore.ErrNotFound
}
log.Printf("lkgateway: ответ МОСТ с GUID=%s сопоставлен по эвристике (FIFO) заявке id=%s status=%s",
guid, cand.ID, resp.StatusCode)
return cand, nil
}
// sendCallback отправляет PATCH в ЛК с обновлением статуса.
func (s *Service) sendCallback(ctx context.Context, deal *m2mcore.Deal) {
cb := callbackForDeal(deal)
if err := postJSON(ctx, s.httpClient, s.callbackURL+"/api/v1/back_office/claims/"+deal.ID, "PATCH", cb); err != nil {
log.Printf("lkgateway: callback в ЛК упал: %v", err)
}
}
// Recorder возвращает экспонируемый Recorder для admin-страницы.
func (s *Service) Recorder() *m2mcore.MemoryRecorder { return s.recorder }
// Repo возвращает Repository (для админских проверок).
func (s *Service) Repo() m2mcore.Repository { return s.repo }
// Внутренние преобразования и хелперы.
func dtoToClaim(in CreateClaimRequest) (m2mcore.ClaimInput, error) {
out := m2mcore.ClaimInput{
InvestorClientID: in.Investor.ID,
TransferringDepositoryINN: m2m.OrganizationINN(in.TransferringDepositoryINN),
ReceivingDepositoryINN: m2m.OrganizationINN(in.ReceivingDepositoryINN),
}
// Переопределение документа инвестора (тест с роботом: серия = сценарий).
if d := in.InvestorDocumentOverride; d != nil {
out.InvestorDocument = &m2mcore.ClientDocument{
DocumentType: m2m.IdentityDocumentCode(d.DocumentType),
Series: d.Series,
Number: d.Number,
}
}
// CostInfo
if in.CostInfo.Yes != nil {
out.CostInfo = m2m.CostInfo{Yes: &m2m.CostInfoYes{Code: m2m.DeponentCode(in.CostInfo.Yes.Code)}}
} else {
out.CostInfo = m2m.CostInfo{No: &m2m.CostInfoNo{}}
}
// IIA
if in.IIAAgreement != nil {
out.IIAAgreement = &m2m.IIAAgreementDetails{
AgreementType: m2m.IIAContractType(in.IIAAgreement.AgreementType),
AgreementNumber: in.IIAAgreement.AgreementNumber,
AgreementDate: in.IIAAgreement.AgreementDate,
BrokerINN: m2m.OrganizationINN(in.IIAAgreement.BrokerINN),
}
}
// Securities
for _, sec := range in.Securities {
ds, err := dtoSecurityDetails(sec.SecurityDetails)
if err != nil {
return m2mcore.ClaimInput{}, err
}
q, err := dtoQuantity(sec.Quantity)
if err != nil {
return m2mcore.ClaimInput{}, err
}
out.Securities = append(out.Securities, m2mcore.ClaimSecurityInput{
SecurityCode: m2m.SecurityCode(sec.SecurityCode),
Details: ds,
Quantity: q,
})
}
return out, nil
}
func dtoSecurityDetails(in SecurityDetails) (m2m.SecurityDetails, error) {
if in.ISIN != "" {
isin := m2m.ISIN(in.ISIN)
return m2m.SecurityDetails{ISIN: &isin}, nil
}
if in.SecurityInfo != nil {
si := &m2m.SecurityDescription{
SecurityClassification: m2m.SecurityClassification(in.SecurityInfo.Classification),
SecurityCategory: m2m.SecurityCategory(in.SecurityInfo.Category),
SecurityType: in.SecurityInfo.SecurityType,
SecuritySeries: in.SecurityInfo.SecuritySeries,
}
if in.SecurityInfo.IdentificationDetails.RegNumber != "" {
rn := in.SecurityInfo.IdentificationDetails.RegNumber
si.IdentificationDetails = m2m.IdentificationDetails{RegNumber: &rn}
}
if in.SecurityInfo.IdentificationDetails.FundShares != nil {
si.IdentificationDetails = m2m.IdentificationDetails{
FundShares: &m2m.FundShares{
RegNumber: in.SecurityInfo.IdentificationDetails.FundShares.RegNumber,
Class: in.SecurityInfo.IdentificationDetails.FundShares.Class,
},
}
}
return m2m.SecurityDetails{SecurityInfo: si}, nil
}
return m2m.SecurityDetails{}, errors.New("lkgateway: SecurityDetails — задайте isin или security_info")
}
func dtoQuantity(in Quantity) (m2m.Quantity, error) {
if in.Whole > 0 {
w := in.Whole
return m2m.Quantity{Whole: &w}, nil
}
if in.Fractional != "" {
f := m2m.Decimal16(in.Fractional)
return m2m.Quantity{Fractional: &f}, nil
}
return m2m.Quantity{}, errors.New("lkgateway: Quantity — задайте whole или fractional")
}
func dealToView(d *m2mcore.Deal) ClaimView {
out := ClaimView{
ID: d.ID,
Status: string(d.State),
CreatedAt: d.CreatedAt,
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),
Number: string(ii.IdentityDocument.DocumentNumber),
},
}
if ii.IdentityDocument.DocumentSeries != nil {
out.Investor.Document.Series = string(*ii.IdentityDocument.DocumentSeries)
}
if d.Request.Header.CostInfo.Yes != nil {
out.CostInfo = CostInfo{Yes: &CostInfoYes{Code: string(d.Request.Header.CostInfo.Yes.Code)}}
} else if d.Request.Header.CostInfo.No != nil {
empty := struct{}{}
out.CostInfo = CostInfo{No: &empty}
}
if d.Request.Header.IIAAgreementDetails != nil {
out.IIAAgreement = &IIAAgreement{
AgreementType: string(d.Request.Header.IIAAgreementDetails.AgreementType),
AgreementNumber: d.Request.Header.IIAAgreementDetails.AgreementNumber,
AgreementDate: d.Request.Header.IIAAgreementDetails.AgreementDate,
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)
if len(d.RawResponse) > 0 {
// Ответ НРД в windows-1251 — декодируем в UTF-8 для показа.
out.M2MResponse.RawXML = string(nsdxml.DecodeWindows1251(d.RawResponse))
}
}
if d.Decision != nil {
out.M2MDecision = decisionToView(d.Decision)
}
if d.State != m2mcore.StateDraft {
cb := callbackForDeal(d)
out.LastCallback = &cb
}
// Самопроверка тестовой заявки роботу: серия документа = код сценария.
if devMode(globalRC) { // сценарии робота — только в режиме разработки: такие серии бывают у реальных паспортов
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),
StatusCode: string(r.StatusCode),
}
for _, e := range r.Responses {
ent := NSDResponseEntry{Code: e.Code, Text: e.Text}
if e.ReferenceID != nil {
ent.ReferenceID = string(*e.ReferenceID)
}
v.Responses = append(v.Responses, ent)
}
return v
}
func decisionToView(d *m2m.M2MTransferDecision) *NSDDecisionView {
v := &NSDDecisionView{GUID: string(d.Header.GUID)}
for _, sec := range d.Data.Securities {
entry := NSDDecisionSecurity{ReferenceID: string(sec.ReferenceID)}
if sec.TransferDecision.Confirmation != nil {
entry.Outcome = "confirmed"
} else if sec.TransferDecision.Rejection != nil {
entry.Outcome = "rejected"
entry.RejectCodes = sec.TransferDecision.Rejection.Codes
}
v.Securities = append(v.Securities, entry)
}
return v
}
func callbackForDeal(d *m2mcore.Deal) StatusCallback {
cb := StatusCallback{
ClaimID: d.ID,
NewStatus: string(d.State),
UpdatedAt: d.UpdatedAt,
}
if d.Decision != nil {
cb.NSDResponse = nsdResponseFromDecision(d.Decision)
if d.State == m2mcore.StateRejected {
for _, sec := range d.Decision.Data.Securities {
if sec.TransferDecision.Rejection != nil && len(sec.TransferDecision.Rejection.Codes) > 0 {
cb.ReasonCode = sec.TransferDecision.Rejection.Codes[0]
cb.ReasonText = "Отказ принимающей стороны"
break
}
}
}
} else if d.Response != nil {
cb.NSDResponse = responseToView(d.Response)
}
return cb
}
func nsdResponseFromDecision(d *m2m.M2MTransferDecision) *NSDResponseView {
v := &NSDResponseView{GUID: string(d.Header.GUID), StatusCode: string(m2m.StatusInfo)}
for _, sec := range d.Data.Securities {
ref := string(sec.ReferenceID)
ent := NSDResponseEntry{ReferenceID: ref}
if sec.TransferDecision.Confirmation != nil {
ent.Code = "01"
ent.Text = "Подтверждение принимающей стороны."
} else if sec.TransferDecision.Rejection != nil {
ent.Code = "07"
ent.Text = "Отказ принимающей стороны."
}
v.Responses = append(v.Responses, ent)
}
return v
}
// nsdSecCodes — код бумаги в НРД (SecurityCode) по ISIN из DEPO (кодировщик «НДЦ»), с кэшем на час.
// У акций код НРД отличается от ISIN (Сбербанк — SBER/03), и с ISIN вместо кода НРД сервис отвечает
// M2M43 «Ценная бумага не учитывается в НРД» (29.09.2026).
var nsdSecCodes = struct {
sync.Mutex
m map[string]nsdSecCodeEntry
}{m: map[string]nsdSecCodeEntry{}}
type nsdSecCodeEntry struct {
code string
at time.Time
}
func nsdSecCodeByISIN(isin string) string {
isin = strings.ToUpper(strings.TrimSpace(isin))
if isin == "" || globalRC == nil {
return ""
}
nsdSecCodes.Lock()
if e, ok := nsdSecCodes.m[isin]; ok && time.Since(e.at) < time.Hour {
nsdSecCodes.Unlock()
return e.code
}
nsdSecCodes.Unlock()
fs := globalRC.Snapshot().Fancy
if strings.TrimSpace(fs.Server) == "" {
return ""
}
db, err := fancyOpen(fs, fs.DepoDB())
if err != nil {
return ""
}
defer db.Close()
code := depoNSDCode(db, isin)
nsdSecCodes.Lock()
nsdSecCodes.m[isin] = nsdSecCodeEntry{code: code, at: time.Now()}
nsdSecCodes.Unlock()
return code
}
// withNSDSecCodes — бумаги, у которых код НРД не задан или равен ISIN, получают код НРД из DEPO.
func withNSDSecCodes(in CreateClaimRequest) CreateClaimRequest {
if len(in.Securities) == 0 {
return in
}
secs := append([]ClaimSec(nil), in.Securities...)
for i := range secs {
isin := strings.ToUpper(strings.TrimSpace(secs[i].SecurityDetails.ISIN))
if isin == "" || (secs[i].SecurityCode != "" && !strings.EqualFold(secs[i].SecurityCode, isin)) {
continue
}
if code := nsdSecCodeByISIN(isin); code != "" {
secs[i].SecurityCode = code
}
}
in.Securities = secs
return in
}