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