21d5f33394
Часть модуля 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
794 lines
33 KiB
Go
794 lines
33 KiB
Go
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
|
||
}
|