bf7c7b53bd
Настройка в шаге «Время и сеть» и Настройка → Сервер: дни недели, часы (по поясу сервера), включение правила. Запрос, полученный вне рабочего времени (и после включения правила), bj сразу отклоняет по всем бумагам кодом M2M06 «Приостановление операций по счёту депо зачисления» — кода «вне рабочего времени» в перечне НРД (инструкция по обмену M2M, раздел 7.4) нет, решение депозитария. Строка «Рабочее время» в проверке готовности; сквозной тест отказа через ИШ. Файл входящих — переменная (тесты не трогают сервер). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
368 lines
15 KiB
Go
368 lines
15 KiB
Go
package lkgateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
|
)
|
|
|
|
// Входящие переводы: M2MTransferRequest, присланные НРД/роботом, когда другая
|
|
// сторона выступает ПЕРЕДАЮЩЕЙ (сценарий 3333). На такой запрос мы должны
|
|
// ответить M2MTransferDecision — по каждой бумаге подтвердить приём или
|
|
// отказать. Здесь — хранилище входящих и их состояние ответа.
|
|
|
|
var incomingStateFile = "/var/lib/bj/.bj/incoming-transfers.json" // var — тесты подменяют путь
|
|
|
|
// IncomingSecurity — бумага во входящем переводе (что нам предлагают принять).
|
|
type IncomingSecurity struct {
|
|
ReferenceID string `json:"reference_id"` // Security/ReferenceId (нужен для ответа)
|
|
SecurityCode string `json:"security_code"` // код НРД ценной бумаги
|
|
ISIN string `json:"isin"`
|
|
Quantity string `json:"quantity"` // целое ИЛИ дробное (паи) — как строка
|
|
// Реквизиты счёта из запроса (место зачисления/списания).
|
|
SettleINN string `json:"settle_inn,omitempty"` // ИНН расчётного депозитария
|
|
DeponentCode string `json:"deponent_code,omitempty"` // код депонента
|
|
AccountID string `json:"account_id,omitempty"` // счёт депо
|
|
SectionID string `json:"section_id,omitempty"` // раздел счёта
|
|
// Решение по бумаге (после ответа): "confirmed" | "rejected" | "".
|
|
Decision string `json:"decision,omitempty"`
|
|
RejectCode string `json:"reject_code,omitempty"`
|
|
}
|
|
|
|
// IncomingTransfer — входящий перевод от робота/контрагента, ожидающий нашего
|
|
// решения (принять/отказать по бумагам).
|
|
type IncomingTransfer struct {
|
|
ID string `json:"id"` // наш внутренний id
|
|
GUID string `json:"guid"` // GUID запроса (в ответе используем ЕГО)
|
|
RequestCreated time.Time `json:"request_created,omitempty"` // когда отправитель сформировал запрос
|
|
ReceivedAt time.Time `json:"received_at"` // когда мы забрали из шлюза
|
|
SenderCode string `json:"sender_code"` // кто прислал (робот MC0012500000)
|
|
ReceiverCode string `json:"receiver_code"` // мы
|
|
SenderINN string `json:"sender_inn"` // TransferringDepository/INN (передающий)
|
|
// Анкета инвестора и документ из запроса.
|
|
InvestorLast string `json:"investor_last,omitempty"`
|
|
InvestorFirst string `json:"investor_first,omitempty"`
|
|
InvestorMiddle string `json:"investor_middle,omitempty"`
|
|
DocType string `json:"doc_type,omitempty"`
|
|
DocSeries string `json:"doc_series,omitempty"`
|
|
DocNumber string `json:"doc_number,omitempty"`
|
|
ReceivingINN string `json:"receiving_inn,omitempty"` // принимающий депозитарий (мы)
|
|
Securities []IncomingSecurity `json:"securities"`
|
|
IIS bool `json:"iis,omitempty"` // перевод на ИИС (в запросе есть IIAAgreementDetails)
|
|
RawXML string `json:"raw_xml"`
|
|
DecisionXML string `json:"decision_xml,omitempty"` // наш ответ M2MTransferDecision (для выгрузки регулятору)
|
|
Status string `json:"status"` // pending | answered
|
|
AnsweredAt time.Time `json:"answered_at,omitempty"`
|
|
AnswerNote string `json:"answer_note,omitempty"`
|
|
Auto bool `json:"auto,omitempty"` // отвечено автоматически (дубль)
|
|
Fingerprint string `json:"fingerprint,omitempty"` // отпечаток для детекта дублей
|
|
// Подача депозитарного поручения 16/1 (ORDXM) в НРД.
|
|
OrderSubmitted bool `json:"order_submitted,omitempty"`
|
|
OrderContrDID string `json:"order_contr_did,omitempty"` // contr_d_id ПЕРВОГО поручения (совместимость карточек/выгрузок)
|
|
// OrderLines — все поданные депо-поручения 16/1: одно на бумагу (contr_d_id ↔ ISIN ↔
|
|
// REFERENCE строки пакета). Нужны, чтобы исход каждой бумаги (MS010) писать в DEPO
|
|
// по своей строке — пакет может исполниться частично.
|
|
OrderLines []IncomingOrderLine `json:"order_lines,omitempty"`
|
|
OrderPkgs string `json:"order_pkgs,omitempty"` // id пакетов ИШ
|
|
OrderAt time.Time `json:"order_at,omitempty"`
|
|
OrderXML string `json:"order_xml,omitempty"` // что мы реально отправили (поручение 16/1)
|
|
// Сверка с DEPO (эмуляция ответа, ничего не отправляется) — для бейджа в списке.
|
|
DepoRecommend string `json:"depo_recommend,omitempty"` // confirm | reject | mixed
|
|
DepoSummary string `json:"depo_summary,omitempty"` // «принять» / «отказ M2M04» / …
|
|
DepoAt time.Time `json:"depo_at,omitempty"`
|
|
}
|
|
|
|
// IncomingStore — потокобезопасное хранилище входящих переводов с персистом
|
|
// в JSON (переживает перезапуск bj-server).
|
|
type IncomingStore struct {
|
|
mu sync.Mutex
|
|
items map[string]*IncomingTransfer
|
|
}
|
|
|
|
// NewIncomingStore создаёт хранилище и загружает сохранённые входящие.
|
|
func NewIncomingStore() *IncomingStore {
|
|
s := &IncomingStore{items: make(map[string]*IncomingTransfer)}
|
|
s.load()
|
|
return s
|
|
}
|
|
|
|
func (s *IncomingStore) load() {
|
|
b, err := os.ReadFile(incomingStateFile)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var list []*IncomingTransfer
|
|
if json.Unmarshal(b, &list) != nil {
|
|
return
|
|
}
|
|
for _, it := range list {
|
|
s.items[it.ID] = it
|
|
}
|
|
}
|
|
|
|
func (s *IncomingStore) save() {
|
|
list := make([]*IncomingTransfer, 0, len(s.items))
|
|
for _, it := range s.items {
|
|
list = append(list, it)
|
|
}
|
|
b, err := json.MarshalIndent(list, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
tmp := incomingStateFile + ".tmp"
|
|
if os.WriteFile(tmp, b, 0o640) == nil {
|
|
_ = os.Rename(tmp, incomingStateFile)
|
|
}
|
|
}
|
|
|
|
// AddFromRequest сохраняет входящий перевод из распарсенного M2MTransferRequest.
|
|
// guidExists=true если перевод с таким GUID уже сохранён (дедуп).
|
|
func (s *IncomingStore) AddFromRequest(req *m2m.M2MTransferRequest, rawXML string) (*IncomingTransfer, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
guid := string(req.Header.GUID)
|
|
for _, it := range s.items {
|
|
if it.GUID == guid {
|
|
return it, true // уже есть
|
|
}
|
|
}
|
|
id, _ := newID()
|
|
inv := req.Data.InvestorInformation
|
|
it := &IncomingTransfer{
|
|
IIS: req.Header.IIAAgreementDetails != nil,
|
|
ID: id,
|
|
GUID: guid,
|
|
RequestCreated: req.Header.CreationTimestamp.Time,
|
|
ReceivedAt: time.Now().UTC(),
|
|
SenderCode: string(req.Header.SenderCode),
|
|
ReceiverCode: string(req.Header.ReceiverCode),
|
|
SenderINN: string(req.Data.TransferringDepository.INN),
|
|
ReceivingINN: string(req.Data.ReceivingDepository.INN),
|
|
InvestorLast: inv.LastName,
|
|
InvestorFirst: inv.FirstName,
|
|
InvestorMiddle: inv.MiddleName,
|
|
DocType: string(inv.IdentityDocument.DocumentType),
|
|
DocNumber: string(inv.IdentityDocument.DocumentNumber),
|
|
RawXML: rawXML,
|
|
Status: "pending",
|
|
}
|
|
if inv.IdentityDocument.DocumentSeries != nil {
|
|
it.DocSeries = string(*inv.IdentityDocument.DocumentSeries)
|
|
}
|
|
for _, sec := range req.Data.TransferredSecurities.Securities {
|
|
is := IncomingSecurity{
|
|
ReferenceID: string(sec.ReferenceID),
|
|
SecurityCode: string(sec.SecurityCode),
|
|
}
|
|
if sec.SecurityDetails.ISIN != nil {
|
|
is.ISIN = string(*sec.SecurityDetails.ISIN)
|
|
}
|
|
if sec.Quantity.Whole != nil {
|
|
is.Quantity = strconv.FormatUint(*sec.Quantity.Whole, 10)
|
|
} else if sec.Quantity.Fractional != nil {
|
|
is.Quantity = string(*sec.Quantity.Fractional)
|
|
}
|
|
if len(sec.SettlementAccount) > 0 {
|
|
sa := sec.SettlementAccount[0]
|
|
is.SettleINN = string(sa.SettlementRequisites.INN)
|
|
is.DeponentCode = sa.SettlementLocation.DeponentCode
|
|
is.AccountID = string(sa.SettlementLocation.AccountID)
|
|
is.SectionID = sa.SettlementLocation.SectionID
|
|
}
|
|
it.Securities = append(it.Securities, is)
|
|
}
|
|
it.Fingerprint = incomingFingerprint(it)
|
|
s.items[id] = it
|
|
s.save()
|
|
return it, false
|
|
}
|
|
|
|
// incomingFingerprint — отпечаток «такого же запроса»: отправитель + документ
|
|
// инвестора + набор бумаг (ISIN/код : количество). GUID НЕ входит (у повторного
|
|
// запроса он другой). Используется для авто-ответа на дубли.
|
|
func incomingFingerprint(it *IncomingTransfer) string {
|
|
parts := []string{it.SenderCode, it.DocType, it.DocSeries, it.DocNumber,
|
|
it.InvestorLast, it.InvestorFirst, it.InvestorMiddle}
|
|
secs := make([]string, 0, len(it.Securities))
|
|
for _, s := range it.Securities {
|
|
key := s.ISIN
|
|
if key == "" {
|
|
key = s.SecurityCode
|
|
}
|
|
secs = append(secs, key+":"+s.Quantity)
|
|
}
|
|
sort.Strings(secs)
|
|
parts = append(parts, secs...)
|
|
return strings.Join(parts, "|")
|
|
}
|
|
|
|
// FindAnsweredDuplicate ищет ранее ОТВЕЧЕННЫЙ входящий с тем же отпечатком
|
|
// (кроме самого it). Возвращает его решения по бумагам для повтора.
|
|
func (s *IncomingStore) FindAnsweredDuplicate(fp, exceptID string) (*IncomingTransfer, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, it := range s.items {
|
|
if it.ID == exceptID || it.Status != "answered" || it.Fingerprint == "" {
|
|
continue
|
|
}
|
|
if it.Fingerprint == fp {
|
|
cp := *it
|
|
return &cp, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// List возвращает входящие, новые сверху.
|
|
func (s *IncomingStore) List() []*IncomingTransfer {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]*IncomingTransfer, 0, len(s.items))
|
|
for _, it := range s.items {
|
|
cp := *it
|
|
out = append(out, &cp)
|
|
}
|
|
// новые сверху
|
|
for i := 0; i < len(out); i++ {
|
|
for j := i + 1; j < len(out); j++ {
|
|
if out[j].ReceivedAt.After(out[i].ReceivedAt) {
|
|
out[i], out[j] = out[j], out[i]
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Get возвращает копию входящего по id.
|
|
func (s *IncomingStore) Get(id string) (*IncomingTransfer, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
it, ok := s.items[id]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
cp := *it
|
|
cp.Securities = append([]IncomingSecurity(nil), it.Securities...)
|
|
return &cp, true
|
|
}
|
|
|
|
// PendingCount — сколько входящих ждут нашего решения (для бейджа в меню).
|
|
func (s *IncomingStore) PendingCount() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
n := 0
|
|
for _, it := range s.items {
|
|
if it.Status == "pending" {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// MarkAnswered проставляет решения по бумагам и статус answered.
|
|
func (s *IncomingStore) MarkAnswered(id string, decisions map[string]IncomingSecurity, note string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
it, ok := s.items[id]
|
|
if !ok {
|
|
return
|
|
}
|
|
for i := range it.Securities {
|
|
if d, ok := decisions[it.Securities[i].ReferenceID]; ok {
|
|
it.Securities[i].Decision = d.Decision
|
|
it.Securities[i].RejectCode = d.RejectCode
|
|
}
|
|
}
|
|
it.Status = "answered"
|
|
it.AnsweredAt = time.Now().UTC()
|
|
it.AnswerNote = note
|
|
s.save()
|
|
}
|
|
|
|
// MarkAuto помечает входящий как отвеченный автоматически (дубль) и обновляет
|
|
// примечание.
|
|
func (s *IncomingStore) MarkAuto(id, note string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
it, ok := s.items[id]
|
|
if !ok {
|
|
return
|
|
}
|
|
it.Auto = true
|
|
if note != "" {
|
|
it.AnswerNote = note
|
|
}
|
|
s.save()
|
|
}
|
|
|
|
// IncomingOrderLine — одно депо-поручение 16/1 по одной бумаге пакета.
|
|
type IncomingOrderLine struct {
|
|
ContrDID string `json:"contr_did"`
|
|
ISIN string `json:"isin,omitempty"`
|
|
Ref string `json:"ref,omitempty"` // REFERENCE бумаги в пакете (Security/ReferenceId)
|
|
}
|
|
|
|
// SetOrderSubmitted помечает, что поданы поручения депо 16/1 (ORDXM) в НРД, и
|
|
// сохраняет отправленный XML (что именно мы отправили). lines — по одному на бумагу;
|
|
// OrderContrDID остаётся первым из них для совместимости.
|
|
func (s *IncomingStore) SetOrderSubmitted(id, contrDID, pkgs, orderXML string, lines []IncomingOrderLine) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if it, ok := s.items[id]; ok {
|
|
it.OrderSubmitted = true
|
|
it.OrderContrDID = contrDID
|
|
it.OrderLines = append([]IncomingOrderLine(nil), lines...)
|
|
it.OrderPkgs = pkgs
|
|
it.OrderXML = orderXML
|
|
it.OrderAt = time.Now().UTC()
|
|
s.save()
|
|
}
|
|
}
|
|
|
|
// SetDecisionXML сохраняет XML нашего ответа-решения (для выгрузки регулятору).
|
|
func (s *IncomingStore) SetDecisionXML(id, xml string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if it, ok := s.items[id]; ok {
|
|
it.DecisionXML = xml
|
|
s.save()
|
|
}
|
|
}
|
|
|
|
// SetDepo сохраняет итог сверки с DEPO (рекомендация + краткий текст) — для
|
|
// бейджа в списке входящих. Ответ при этом НЕ отправляется.
|
|
func (s *IncomingStore) SetDepo(id, recommend, summary string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if it, ok := s.items[id]; ok {
|
|
it.DepoRecommend = recommend
|
|
it.DepoSummary = summary
|
|
it.DepoAt = time.Now().UTC()
|
|
s.save()
|
|
}
|
|
}
|
|
|
|
// newID — короткий идентификатор для входящего перевода (первые 12 симв. UUIDv4).
|
|
func newID() (string, error) {
|
|
u, err := m2mcore.NewUUIDv4()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(u) >= 12 {
|
|
return u[:12], nil
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// incomingIsIIS — перевод на ИИС: по признаку из запроса, а у сохранённых раньше — по исходному XML.
|
|
func incomingIsIIS(it *IncomingTransfer) bool {
|
|
return it.IIS || strings.Contains(it.RawXML, "IIAAgreementDetails")
|
|
}
|