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:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+232
View File
@@ -0,0 +1,232 @@
package lkgateway
// Контроль сроков депо-поручений 16 / 16/1 (регламент депозитария, 09.09.2026):
// - отчёт MS010 ожидается в течение 5 минут после подачи; нет — оповещение
// «не исполнено в срок»;
// - далее исполнение ожидается до 30 дней; по истечении — поручение считается
// отменённым (оповещение, по исходящему снимается блокировка в QUIK).
// Состояние — /var/lib/bj/.bj/order-watch.json (чтобы оповещать один раз).
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
)
const (
orderWatchFile = "/var/lib/bj/.bj/order-watch.json"
orderMS010Within = 5 * time.Minute
orderExecWithin = 30 * 24 * time.Hour
)
type OrderWatch struct {
GUID string `json:"guid"`
Direction string `json:"direction"` // out (16) | in (16/1)
OrderAt time.Time `json:"order_at"`
MS010At time.Time `json:"ms010_at,omitempty"`
Rejected bool `json:"rejected,omitempty"`
LateAt time.Time `json:"late_at,omitempty"` // когда зафиксировали просрочку 5 мин
ExpiredAt time.Time `json:"expired_at,omitempty"` // когда истекли 30 дней
}
// Состояние по-русски для карточек.
func (w *OrderWatch) StatusRu() string {
if w == nil {
return ""
}
switch {
case !w.MS010At.IsZero() && w.Rejected:
return "НРД отверг поручение (MS010 " + w.MS010At.Local().Format("02.01 15:04") + ")"
case !w.MS010At.IsZero():
late := ""
if w.MS010At.Sub(w.OrderAt) > orderMS010Within {
late = fmt.Sprintf(", с опозданием %s", w.MS010At.Sub(w.OrderAt).Round(time.Minute))
}
return "исполнено: MS010 получен " + w.MS010At.Local().Format("02.01.2006 15:04") + late
case !w.ExpiredAt.IsZero():
return "СРОК 30 ДНЕЙ ИСТЁК " + w.ExpiredAt.Local().Format("02.01.2006") + " — поручение считается отменённым"
case !w.LateAt.IsZero():
return "НЕ ИСПОЛНЕНО В СРОК 5 МИН (подано " + w.OrderAt.Local().Format("02.01 15:04") + "); ожидаем исполнения до " + w.OrderAt.Add(orderExecWithin).Local().Format("02.01.2006")
default:
return "ожидаем MS010 до " + w.OrderAt.Add(orderMS010Within).Local().Format("15:04")
}
}
func (w *OrderWatch) Kind() string {
switch {
case w == nil:
return ""
case !w.MS010At.IsZero() && w.Rejected:
return "bad"
case !w.MS010At.IsZero():
return "ok"
case !w.ExpiredAt.IsZero():
return "bad"
case !w.LateAt.IsZero():
return "warn"
}
return "wait"
}
type orderWatchStore struct {
mu sync.Mutex
items map[string]*OrderWatch
}
var globalOrderWatch = loadOrderWatch()
func loadOrderWatch() *orderWatchStore {
s := &orderWatchStore{items: map[string]*OrderWatch{}}
if b, err := os.ReadFile(orderWatchFile); err == nil {
var list []*OrderWatch
if json.Unmarshal(b, &list) == nil {
for _, w := range list {
s.items[w.GUID] = w
}
}
}
return s
}
func (s *orderWatchStore) saveLocked() {
list := make([]*OrderWatch, 0, len(s.items))
for _, w := range s.items {
list = append(list, w)
}
b, _ := json.MarshalIndent(list, "", " ")
_ = os.MkdirAll(filepath.Dir(orderWatchFile), 0o755)
tmp := orderWatchFile + ".tmp"
if os.WriteFile(tmp, b, 0o640) == nil {
_ = os.Rename(tmp, orderWatchFile)
}
}
// Get — состояние контроля по GUID (nil, если поручение не подавалось).
func (s *orderWatchStore) Get(guid string) *OrderWatch {
s.mu.Lock()
defer s.mu.Unlock()
if w, ok := s.items[guid]; ok {
cp := *w
return &cp
}
return nil
}
func (s *orderWatchStore) update(guid string, fn func(w *OrderWatch)) *OrderWatch {
s.mu.Lock()
defer s.mu.Unlock()
w, ok := s.items[guid]
if !ok {
w = &OrderWatch{GUID: guid}
s.items[guid] = w
}
fn(w)
s.saveLocked()
cp := *w
return &cp
}
var orderWatchStarted = time.Now()
// orderWatchCheck — один объект: подано когда, ответы НРД → фиксируем MS010/просрочку/истечение.
func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction string, orderAt time.Time, resps []orderResponse, label string) {
if guid == "" || orderAt.IsZero() {
return
}
w := globalOrderWatch.Get(guid)
if w == nil {
w = globalOrderWatch.update(guid, func(w *OrderWatch) { w.Direction, w.OrderAt = direction, orderAt })
}
if !w.MS010At.IsZero() || !w.ExpiredAt.IsZero() {
return // финальное состояние уже зафиксировано
}
if done, rejected, text := orderRespVerdict(resps); done {
at := time.Now()
for _, r := range resps {
if r.Done {
if t, err := time.ParseInLocation("02.01.2006 15:04:05", r.Time, time.Local); err == nil {
at = t
}
}
}
globalOrderWatch.update(guid, func(w *OrderWatch) { w.MS010At, w.Rejected = at, rejected })
if rejected {
go depoOnOrderResult(rc, guid, "rejected", text)
} else {
go depoOnOrderResult(rc, guid, "accepted", "")
}
return
}
now := time.Now()
fresh := orderAt.After(orderWatchStarted) // по старым поручениям при первом запуске не шумим
if w.LateAt.IsZero() && now.Sub(orderAt) > orderMS010Within {
globalOrderWatch.update(guid, func(w *OrderWatch) { w.LateAt = now })
go depoOnOrderResult(rc, guid, "late", "")
if fresh {
raiseSystemAlert(rc, "НРД: депо-поручение не исполнено в срок 5 минут",
fmt.Sprintf("%s (перевод %s): подано %s, отчёт MS010 не получен. Ожидаем исполнения до %s, после этого поручение считается отменённым.",
label, shortGUID(guid), orderAt.Local().Format("02.01.2006 15:04"), orderAt.Add(orderExecWithin).Local().Format("02.01.2006")))
}
}
if w.ExpiredAt.IsZero() && now.Sub(orderAt) > orderExecWithin {
globalOrderWatch.update(guid, func(w *OrderWatch) { w.ExpiredAt = now })
go depoOnOrderResult(rc, guid, "expired", "")
raiseSystemAlert(rc, "НРД: истёк срок 30 дней по депо-поручению — считается отменённым",
fmt.Sprintf("%s (перевод %s): подано %s, отчёт MS010 так и не получен. Поручение считается отменённым; проверьте перевод в НРД и позицию клиента.",
label, shortGUID(guid), orderAt.Local().Format("02.01.2006 15:04")))
if direction == "out" {
for _, l := range globalQuikLinks.ForGUID(guid) {
if l.State == "registered" {
_, _ = QuikOutDeny(ctx, rc, guid, l.ISIN, "Истёк срок исполнения поручения в НРД (30 дней) — перевод отменён")
}
}
}
}
}
func orderWatchTick(ctx context.Context, rc *RuntimeConfig) {
if globalOutgoingStore != nil {
for _, od := range scanForm16Drafts(globalOutgoingStore) {
if !od.Submitted {
continue
}
resps, _ := outgoingResponses(od.ContrDIDs)
orderWatchCheck(ctx, rc, od.GUID, "out", od.OrderAt, resps, "Поручение 16 (списание)")
}
}
if globalIncomingStore != nil {
for _, it := range globalIncomingStore.List() {
if !it.OrderSubmitted {
continue
}
orderWatchCheck(ctx, rc, it.GUID, "in", it.OrderAt, findOrderResponses(it.OrderContrDID), "Поручение 16/1 (зачисление)")
}
}
}
// orderWatchLoop — фоновый контроль сроков (раз в минуту).
func (s *Server) orderWatchLoop(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("order-watch: panic: %v", r)
}
}()
orderWatchTick(ctx, s.rc)
}()
healthBeat("order-watch")
}
}