92afdd2868
- finishResult: итог confirm / reject / partial, без отказа всем пакетом; - autoDecisions и ручной ответ: без правила «целиком», квал-предупреждение — отказ только по своей бумаге; - признак ИИС из заголовка запроса (IIAAgreementDetails), у сохранённых раньше — по исходному XML; - форма оператора: без автоматического «отказ всем», подсказка про ИИС; тексты и тесты. 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 — по каждой бумаге подтвердить приём или
|
|
// отказать. Здесь — хранилище входящих и их состояние ответа.
|
|
|
|
const incomingStateFile = "/var/lib/bj/.bj/incoming-transfers.json"
|
|
|
|
// 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")
|
|
}
|