Files
Bridge-and-Join-s/internal/lkgateway/incoming.go
T
zuevav 92afdd2868 Входящие M2M: частичный приём — каждая бумага решается сама по себе (НРД допускает: по каждой принятой свой черновик 16/1); переводы на ИИС не принимаем — отказ M2M07 по всем бумагам
- 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
2026-09-25 15:36:58 +03:00

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")
}