Files
zuevav da12a9ec1c feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 19:41:12 +03:00

348 lines
13 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"`
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 поручения (связь с ответами НРД)
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{
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()
}
// SetOrderSubmitted помечает, что подано поручение депо 16/1 (ORDXM) в НРД, и
// сохраняет отправленный XML (что именно мы отправили).
func (s *IncomingStore) SetOrderSubmitted(id, contrDID, pkgs, orderXML string) {
s.mu.Lock()
defer s.mu.Unlock()
if it, ok := s.items[id]; ok {
it.OrderSubmitted = true
it.OrderContrDID = contrDID
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
}