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

233 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}