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>
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user