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