32d88fc42a
Перевод 97EAD00C (заявка ЛК #71741, 23.10.2026) исполнен в QUIK и НРД (MS010), но в ЛК статус не изменился, а сделка осталась «Подтверждена»: - связки guid → заявка ЛК искались с учётом регистра: мы шлём GUID строчными, НРД отвечает заглавными — OnLKOutcome молча не находил заявку. Ключи — строчными (lkGUIDKey), старые записи приводятся при загрузке; итог модуля ЛК (lk.transfer_requests) — lower(guid); - после MS010 по всем строкам поручения 16 сделка → «Исполнена» (CompleteSUB16), итог — в ЛК (outOnExecuted, каждый проход order-watch, повтор безвреден); в бою согласие контрагента — ещё не «Исполнена»: статус ждёт MS010, как в тесте «боевой ЛК → TEST3»; имитатор НРД — итог сразу. Тестовый 28 с боевым ЛК взял ту же заявку и после отказа МНП («запрещен для операции шорт» — бумагу уже заблокировал 9) брал её каждые 10 секунд: 108 сделок с новыми GUID и столько же писем. - Сделка заведена, а отправка не прошла (МНП не заблокировал) — заявка помечается взятой и связывается с переводом: дальше — оператор в карточке. - Боевой ЛК при тестовом профиле ИШ (lkProdOnTestNSD): заявки клиентов не берутся (кроме теста «боевой ЛК → тестовый НРД»), портфели не заполняются, предупреждение в настройках ЛК. - Письма: в теме и первой строке — сервер bj и контур НРД («[bj ТЕСТ …]» у тестового); письма о входящих — метка только у тестового. - История FSM — по МСК с датой, «текущее состояние» вместо «сейчас». - Карточка теста «боевой ЛК → тестовый НРД» — только в режиме разработки (включённый тест виден всегда, чтобы его выключить). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
628 lines
25 KiB
Go
628 lines
25 KiB
Go
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"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||
)
|
||
|
||
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"` // все строки получили MS010
|
||
Rejected bool `json:"rejected,omitempty"` // хотя бы одна строка отвергнута
|
||
LateAt time.Time `json:"late_at,omitempty"` // когда зафиксировали просрочку 5 мин
|
||
ExpiredAt time.Time `json:"expired_at,omitempty"` // все незакрытые строки истекли (30 дней)
|
||
// NoCounterAt — НРД прислал уведомление GS036 «об отсутствии встречного поручения»: контрагент
|
||
// ещё не подал своё поручение (к нашей 16 — его 16/1, к нашей 16/1 — его 16). Сбрасывается MS010.
|
||
NoCounterAt time.Time `json:"no_counter_at,omitempty"`
|
||
// MatchedAt — НРД сквитовал поручения со встречными (GS116, MACH) по всем строкам: дальше — исполнение.
|
||
MatchedAt time.Time `json:"matched_at,omitempty"`
|
||
// Lines — состояние по каждой строке пакета (одно депо-поручение = одна бумага),
|
||
// ключ — contr_d_id. По договорённости с Fancy (18.09.2026) исходы пишутся в DEPO
|
||
// по строкам с REFERENCE и сразу по MS010 этой строки, не дожидаясь остальных:
|
||
// пакет может исполниться частично (Сбер — да, Т-Технологии — отказ).
|
||
Lines map[string]*OrderLineWatch `json:"lines,omitempty"`
|
||
}
|
||
|
||
// OrderLineWatch — одна строка поручения: какая бумага (REFERENCE/ISIN) и её исход.
|
||
type OrderLineWatch struct {
|
||
Ref string `json:"ref,omitempty"` // REFERENCE строки в FTV_M2M_LINES
|
||
ISIN string `json:"isin,omitempty"`
|
||
MS010At time.Time `json:"ms010_at,omitempty"`
|
||
Rejected bool `json:"rejected,omitempty"`
|
||
ExpiredAt time.Time `json:"expired_at,omitempty"`
|
||
NoCounterAt time.Time `json:"no_counter_at,omitempty"` // GS036 по этой строке
|
||
MatchedAt time.Time `json:"matched_at,omitempty"` // GS116 MACH по этой строке
|
||
}
|
||
|
||
// counterName — чьё встречное поручение ждём: к нашей 16 — 16/1 принимающего, к нашей 16/1 — 16 передающего.
|
||
func (w *OrderWatch) counterName() string {
|
||
if w != nil && w.Direction == "in" {
|
||
return "поручение 16 передающего депозитария"
|
||
}
|
||
return "поручение 16/1 принимающего депозитария"
|
||
}
|
||
|
||
// orderLine — строка поручения для наблюдателя: contr_d_id ↔ бумага (REFERENCE, ISIN).
|
||
type orderLine struct {
|
||
ContrDID string
|
||
Ref string
|
||
ISIN string
|
||
}
|
||
|
||
// lineDone — строка в финальном состоянии (MS010 получен или срок истёк).
|
||
func (l *OrderLineWatch) lineDone() bool {
|
||
return l != nil && (!l.MS010At.IsZero() || !l.ExpiredAt.IsZero())
|
||
}
|
||
|
||
// linesSummary — сколько строк закрыто MS010 / отвергнуто / истекло из общего числа.
|
||
// Нужно для частичного исполнения: пакет может быть исполнен не целиком.
|
||
func (w *OrderWatch) linesSummary() (total, executed, rejected, expired int) {
|
||
if w == nil {
|
||
return
|
||
}
|
||
for _, l := range w.Lines {
|
||
total++
|
||
switch {
|
||
case !l.MS010At.IsZero() && l.Rejected:
|
||
rejected++
|
||
case !l.MS010At.IsZero():
|
||
executed++
|
||
case !l.ExpiredAt.IsZero():
|
||
expired++
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// Состояние по-русски для карточек.
|
||
func (w *OrderWatch) StatusRu() string {
|
||
if w == nil {
|
||
return ""
|
||
}
|
||
// Частичное исполнение: часть строк закрыта, часть ещё ждёт / отвергнута / истекла.
|
||
if total, executed, rejected, expired := w.linesSummary(); total > 1 && executed > 0 && executed < total {
|
||
s := fmt.Sprintf("исполнено частично: %d из %d бумаг", executed, total)
|
||
if rejected > 0 {
|
||
s += fmt.Sprintf(", отвергнуто %d", rejected)
|
||
}
|
||
if expired > 0 {
|
||
s += fmt.Sprintf(", истёк срок по %d", expired)
|
||
}
|
||
if executed+rejected+expired < total {
|
||
s += fmt.Sprintf(", ожидаем MS010 по %d", total-executed-rejected-expired)
|
||
}
|
||
return s
|
||
}
|
||
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 = ", с опозданием " + humanDur(w.MS010At.Sub(w.OrderAt))
|
||
}
|
||
switch {
|
||
case !w.MatchedAt.IsZero() && w.MS010At.Sub(w.MatchedAt) > orderMS010Within:
|
||
late += "; сквитовано со встречным в " + w.MatchedAt.Local().Format("15:04") + ", исполнение НРД — через " + humanDur(w.MS010At.Sub(w.MatchedAt))
|
||
case w.MatchedAt.IsZero() && !w.NoCounterAt.IsZero():
|
||
late += "; встречное " + w.counterName() + " подано не сразу (уведомление НРД " + w.NoCounterAt.Local().Format("15:04") + ")"
|
||
}
|
||
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.MatchedAt.IsZero():
|
||
return "поручение сквитовано со встречным (НРД " + w.MatchedAt.Local().Format("02.01 15:04") + "), ждём исполнения НРД (MS010)"
|
||
case !w.NoCounterAt.IsZero():
|
||
return "НРД: контрагент ещё не подал встречное " + w.counterName() + " (уведомление НРД " + w.NoCounterAt.Local().Format("02.01 15:04") + "); наше поручение принято, ждём исполнения"
|
||
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 {
|
||
if w != nil {
|
||
// частично исполнено — предупреждение, а не «отказ» и не «ок»
|
||
if total, executed, _, _ := w.linesSummary(); total > 1 && executed > 0 && executed < total {
|
||
return "warn"
|
||
}
|
||
}
|
||
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()
|
||
|
||
// outgoingLineRefsCache — REFERENCE по ISIN для исходящей сделки: берётся из
|
||
// M2MTR-запроса в репозитории; после отправки не меняется, поэтому кэшируем по GUID.
|
||
var outgoingLineRefsCache = struct {
|
||
mu sync.Mutex
|
||
m map[string]map[string]string // guid → ISIN → ref
|
||
}{m: map[string]map[string]string{}}
|
||
|
||
// outgoingLineRefs — строки исходящего поручения 16: contr_d_id ↔ ISIN ↔ REFERENCE.
|
||
// Один черновик MF010 = одна бумага; REFERENCE берём из бумаг M2MTR по ISIN.
|
||
func outgoingLineRefs(ctx context.Context, svc *Service, od *OutgoingDeal) []orderLine {
|
||
if od == nil {
|
||
return nil
|
||
}
|
||
outgoingLineRefsCache.mu.Lock()
|
||
byISIN, ok := outgoingLineRefsCache.m[od.GUID]
|
||
outgoingLineRefsCache.mu.Unlock()
|
||
if !ok && svc != nil {
|
||
byISIN = map[string]string{}
|
||
if deals, err := svc.Repo().List(ctx, m2mcore.Filter{Limit: 500}); err == nil {
|
||
for _, d := range deals {
|
||
if d == nil || d.Request == nil || !strings.EqualFold(string(d.GUID), od.GUID) {
|
||
continue
|
||
}
|
||
for _, sec := range d.Request.Data.TransferredSecurities.Securities {
|
||
byISIN[strings.ToUpper(quikSecISIN(sec))] = string(sec.ReferenceID)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
if len(byISIN) > 0 {
|
||
outgoingLineRefsCache.mu.Lock()
|
||
outgoingLineRefsCache.m[od.GUID] = byISIN
|
||
outgoingLineRefsCache.mu.Unlock()
|
||
}
|
||
}
|
||
var out []orderLine
|
||
for _, sec := range od.Secs {
|
||
if sec.ContrDID == "" {
|
||
continue
|
||
}
|
||
isin := strings.ToUpper(sec.ISIN)
|
||
out = append(out, orderLine{ContrDID: sec.ContrDID, Ref: byISIN[isin], ISIN: isin})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// orderWatchCheck — один объект (перевод): подано когда, ответы НРД ПО КАЖДОЙ СТРОКЕ →
|
||
// фиксируем MS010/просрочку/истечение и пишем строчные события в DEPO сразу по
|
||
// MS010 этой бумаги, не дожидаясь остальных (частичное исполнение пакета).
|
||
func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction string, orderAt time.Time, lines []orderLine, label string) {
|
||
if guid == "" || orderAt.IsZero() || len(lines) == 0 {
|
||
return
|
||
}
|
||
w := globalOrderWatch.Get(guid)
|
||
if w == nil {
|
||
w = globalOrderWatch.update(guid, func(w *OrderWatch) { w.Direction, w.OrderAt = direction, orderAt })
|
||
}
|
||
if time.Since(orderAt) < 48*time.Hour { // GS036 ищем и после исполнения — карточка объясняет задержку
|
||
orderNoteNoCounter(guid, w, lines)
|
||
w = globalOrderWatch.Get(guid)
|
||
}
|
||
if !w.MS010At.IsZero() || !w.ExpiredAt.IsZero() {
|
||
return // финальное состояние уже зафиксировано по всем строкам
|
||
}
|
||
now := time.Now()
|
||
fresh := orderAt.After(orderWatchStarted) // по старым поручениям при первом запуске не шумим
|
||
|
||
// 1) исходы по строкам: у каждой бумаги свой MS010
|
||
for _, ln := range lines {
|
||
st := w.Lines[ln.ContrDID]
|
||
if st.lineDone() {
|
||
continue
|
||
}
|
||
resps := findOrderResponses(ln.ContrDID)
|
||
done, rejected, text := orderRespVerdict(resps)
|
||
if !done {
|
||
continue
|
||
}
|
||
at := orderRespAt(resps)
|
||
globalOrderWatch.update(guid, func(w *OrderWatch) {
|
||
if w.Lines == nil {
|
||
w.Lines = map[string]*OrderLineWatch{}
|
||
}
|
||
nc := time.Time{}
|
||
if old := w.Lines[ln.ContrDID]; old != nil {
|
||
nc = old.NoCounterAt
|
||
}
|
||
w.Lines[ln.ContrDID] = &OrderLineWatch{Ref: ln.Ref, ISIN: ln.ISIN, MS010At: at, Rejected: rejected, NoCounterAt: nc}
|
||
if rejected {
|
||
w.Rejected = true
|
||
}
|
||
})
|
||
no := orderRespNo(resps)
|
||
if rejected {
|
||
go depoOnOrderLineResult(rc, guid, ln.Ref, "rejected", text, no)
|
||
} else {
|
||
go depoOnOrderLineResult(rc, guid, ln.Ref, "accepted", "", no)
|
||
}
|
||
}
|
||
w = globalOrderWatch.Get(guid)
|
||
|
||
// 2) все строки закрыты MS010 → финальное состояние объекта
|
||
if allLinesMS010(w, lines) {
|
||
latest := time.Time{}
|
||
for _, ln := range lines {
|
||
if st := w.Lines[ln.ContrDID]; st != nil && st.MS010At.After(latest) {
|
||
latest = st.MS010At
|
||
}
|
||
}
|
||
w = globalOrderWatch.update(guid, func(w *OrderWatch) { w.MS010At = latest })
|
||
if direction == "out" { // тест «боевой ЛК → TEST3»: итог в ЛК — по исполнению НРД (realtest.go)
|
||
partial := ""
|
||
if total, executed, rejected, _ := w.linesSummary(); total > 1 && executed > 0 && rejected > 0 {
|
||
partial = fmt.Sprintf("исполнено %d из %d бумаг", executed, total)
|
||
}
|
||
go realTestOnExecuted(context.Background(), rc, guid, w.Rejected, partial)
|
||
}
|
||
return
|
||
}
|
||
|
||
// 3) просрочка 5 минут — один сигнал на объект, событие по каждой незакрытой строке
|
||
if w.LateAt.IsZero() && now.Sub(orderAt) > orderMS010Within {
|
||
globalOrderWatch.update(guid, func(w *OrderWatch) { w.LateAt = now })
|
||
for _, ln := range lines {
|
||
if !w.Lines[ln.ContrDID].lineDone() {
|
||
go depoOnOrderLineResult(rc, guid, ln.Ref, "late", "", "")
|
||
}
|
||
}
|
||
if fresh {
|
||
why := "отчёт MS010 не получен"
|
||
if !w.MatchedAt.IsZero() {
|
||
why = "поручения сквитованы со встречными (" + w.MatchedAt.Local().Format("15:04") + "), ждём исполнения НРД"
|
||
} else if !w.NoCounterAt.IsZero() {
|
||
why = "НРД сообщил, что контрагент ещё не подал встречное " + w.counterName() + " — наше поручение принято, исполнение ждёт контрагента"
|
||
}
|
||
raiseSystemAlert(rc, "НРД: депо-поручение не исполнено в срок 5 минут",
|
||
fmt.Sprintf("%s (перевод %s): подано %s, %s. Ожидаем исполнения до %s, после этого поручение считается отменённым.",
|
||
label, shortGUID(guid), orderAt.Local().Format("02.01.2006 15:04"), why, orderAt.Add(orderExecWithin).Local().Format("02.01.2006")))
|
||
}
|
||
}
|
||
|
||
// 4) 30 дней — по каждой незакрытой строке BJ_CANCELLED с REFERENCE; QUIK-блокировка снимается по ISIN
|
||
if now.Sub(orderAt) > orderExecWithin {
|
||
expiredAny := false
|
||
for _, ln := range lines {
|
||
if w.Lines[ln.ContrDID].lineDone() {
|
||
continue
|
||
}
|
||
expiredAny = true
|
||
globalOrderWatch.update(guid, func(w *OrderWatch) {
|
||
if w.Lines == nil {
|
||
w.Lines = map[string]*OrderLineWatch{}
|
||
}
|
||
w.Lines[ln.ContrDID] = &OrderLineWatch{Ref: ln.Ref, ISIN: ln.ISIN, ExpiredAt: now}
|
||
})
|
||
go depoOnOrderLineResult(rc, guid, ln.Ref, "expired", "", "")
|
||
if direction == "out" {
|
||
for _, l := range globalQuikLinks.ForGUID(guid) {
|
||
if l.State == "registered" && (ln.ISIN == "" || strings.EqualFold(l.ISIN, ln.ISIN)) {
|
||
_, _ = QuikOutDeny(ctx, rc, guid, l.ISIN, "Истёк срок исполнения поручения в НРД (30 дней) — перевод отменён")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if expiredAny {
|
||
globalOrderWatch.update(guid, func(w *OrderWatch) { w.ExpiredAt = now })
|
||
raiseSystemAlert(rc, "НРД: истёк срок 30 дней по депо-поручению — считается отменённым",
|
||
fmt.Sprintf("%s (перевод %s): подано %s, отчёт MS010 так и не получен. Поручение считается отменённым; проверьте перевод в НРД и позицию клиента.",
|
||
label, shortGUID(guid), orderAt.Local().Format("02.01.2006 15:04")))
|
||
}
|
||
}
|
||
}
|
||
|
||
// allLinesMS010 — по всем строкам получен MS010.
|
||
func allLinesMS010(w *OrderWatch, lines []orderLine) bool {
|
||
if w == nil || len(lines) == 0 {
|
||
return false
|
||
}
|
||
for _, ln := range lines {
|
||
st := w.Lines[ln.ContrDID]
|
||
if st == nil || st.MS010At.IsZero() {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func orderWatchTick(ctx context.Context, rc *RuntimeConfig, svc *Service) {
|
||
if globalOutgoingStore != nil {
|
||
for _, od := range scanForm16Drafts(globalOutgoingStore) {
|
||
if !od.Submitted {
|
||
continue
|
||
}
|
||
orderWatchCheck(ctx, rc, od.GUID, "out", od.OrderAt, outgoingLineRefs(ctx, svc, od), "Поручение 16 (списание)")
|
||
if w := globalOrderWatch.Get(od.GUID); w != nil && !w.MS010At.IsZero() && time.Since(w.MS010At) < 7*24*time.Hour {
|
||
outOnExecuted(ctx, rc, svc, od.GUID, w)
|
||
}
|
||
}
|
||
}
|
||
if globalIncomingStore != nil {
|
||
for _, it := range globalIncomingStore.List() {
|
||
if !it.OrderSubmitted {
|
||
continue
|
||
}
|
||
orderWatchCheck(ctx, rc, it.GUID, "in", it.OrderAt, incomingOrderLines(it), "Поручение 16/1 (зачисление)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// outOnExecuted — НРД закрыл MS010 все строки нашего поручения 16: сделка → «Исполнена» (если списана
|
||
// хоть одна бумага), итог — в ЛК ЕСИА Финанс. Вызывается каждый проход, повтор ничего не меняет
|
||
// (23.10.2026: после MS010 сделка 97EAD00C оставалась «Подтверждена», в ЛК статус не менялся).
|
||
// Тест «боевой ЛК → TEST3» — свой итог (realTestOnExecuted).
|
||
func outOnExecuted(ctx context.Context, rc *RuntimeConfig, svc *Service, guid string, w *OrderWatch) {
|
||
total, executed, rejected, _ := w.linesSummary()
|
||
if total == 0 && !w.Rejected { // старые записи без строк — одна строка
|
||
total, executed = 1, 1
|
||
}
|
||
if svc != nil && executed > 0 {
|
||
d, err := svc.Repo().GetByGUID(ctx, m2m.UUID(guid))
|
||
if err != nil {
|
||
d, err = svc.Repo().GetByGUID(ctx, m2m.UUID(strings.ToLower(guid)))
|
||
}
|
||
if err == nil && (d.State == m2mcore.StateConfirmed || d.State == m2mcore.StateAwaitingSUB16) {
|
||
if err := d.CompleteSUB16(ctx); err != nil {
|
||
log.Printf("order-watch: %s: перевод не отмечен исполненным: %v", shortGUID(guid), err)
|
||
} else if err := svc.Repo().Update(ctx, d); err != nil {
|
||
log.Printf("order-watch: %s: сохранение «Исполнена»: %v", shortGUID(guid), err)
|
||
} else {
|
||
log.Printf("order-watch: %s: поручение 16 исполнено (MS010) — перевод «Исполнена»", shortGUID(guid))
|
||
}
|
||
}
|
||
}
|
||
if realTestGet(guid) != nil || !lkLinked(guid) {
|
||
return
|
||
}
|
||
status, reason := "done", "Перевод исполнен: бумаги списаны"
|
||
switch {
|
||
case executed == 0:
|
||
status, reason = "rejected", "НРД не исполнил поручение на списание"
|
||
case rejected > 0:
|
||
reason = fmt.Sprintf("Перевод исполнен частично: исполнено %d из %d бумаг", executed, total)
|
||
}
|
||
globalLKFlows.stepIfExists(guid, pick3(status == "done", "response", "reject"), "Итог НРД (MS010) → статус в ЛК", reason)
|
||
OnLKOutcome(ctx, rc, guid, status, reason)
|
||
log.Printf("order-watch: %s: итог в ЛК — %s (%s)", shortGUID(guid), status, reason)
|
||
}
|
||
|
||
// incomingOrderLines — строки поданных 16/1. Старые записи (до строчных событий) знали
|
||
// только первый contr_d_id — для них одна строка без REFERENCE.
|
||
func incomingOrderLines(it *IncomingTransfer) []orderLine {
|
||
if it == nil {
|
||
return nil
|
||
}
|
||
if len(it.OrderLines) > 0 {
|
||
out := make([]orderLine, 0, len(it.OrderLines))
|
||
for _, l := range it.OrderLines {
|
||
out = append(out, orderLine{ContrDID: l.ContrDID, Ref: l.Ref, ISIN: l.ISIN})
|
||
}
|
||
return out
|
||
}
|
||
if it.OrderContrDID != "" {
|
||
return []orderLine{{ContrDID: it.OrderContrDID}}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 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, s.svc)
|
||
}()
|
||
healthBeat("order-watch")
|
||
}
|
||
}
|
||
|
||
// findNoCounterNotice — уведомление НРД GS036 «об отсутствии встречного поручения» по REFERENCE строки
|
||
// (номера нашего поручения в нём нет, есть референс сделки M2M). Возвращает время получения.
|
||
func findNoCounterNotice(ref string) (time.Time, bool) {
|
||
if len(ref) < 8 {
|
||
return time.Time{}, false
|
||
}
|
||
for _, pk := range pkgIndex() {
|
||
if strings.Contains(pk.body, ref) && strings.Contains(strings.ToLower(pk.body), "отсутствии встречного поручения") {
|
||
return pk.mt, true
|
||
}
|
||
}
|
||
return time.Time{}, false
|
||
}
|
||
|
||
// orderWatchRefs — референсы строк поручения по переводу (для поиска уведомлений НРД без номера поручения).
|
||
func orderWatchRefs(guid string) []string {
|
||
w := globalOrderWatch.Get(guid)
|
||
if w == nil {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for _, l := range w.Lines {
|
||
if len(l.Ref) >= 8 {
|
||
out = append(out, l.Ref)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// orderNoteNoCounter — отметить уведомления GS036 «нет встречного поручения» по строкам поручения.
|
||
func orderNoteNoCounter(guid string, w *OrderWatch, lines []orderLine) {
|
||
for _, ln := range lines {
|
||
st := w.Lines[ln.ContrDID]
|
||
// отчёт GS116 «сквитовано» (MACH) — по номеру поручения
|
||
if st == nil || st.MatchedAt.IsZero() {
|
||
if at, ok := findMatchedNotice(ln.ContrDID); ok {
|
||
globalOrderWatch.update(guid, func(w *OrderWatch) {
|
||
if w.Lines == nil {
|
||
w.Lines = map[string]*OrderLineWatch{}
|
||
}
|
||
l := w.Lines[ln.ContrDID]
|
||
if l == nil {
|
||
l = &OrderLineWatch{Ref: ln.Ref, ISIN: ln.ISIN}
|
||
w.Lines[ln.ContrDID] = l
|
||
}
|
||
l.MatchedAt = at
|
||
all, last := true, time.Time{}
|
||
for _, x := range lines {
|
||
lx := w.Lines[x.ContrDID]
|
||
if lx == nil || lx.MatchedAt.IsZero() {
|
||
all = false
|
||
break
|
||
}
|
||
if lx.MatchedAt.After(last) {
|
||
last = lx.MatchedAt
|
||
}
|
||
}
|
||
if all {
|
||
w.MatchedAt = last
|
||
}
|
||
})
|
||
}
|
||
}
|
||
// уведомление «нет встречного поручения» (GS036) — по REFERENCE строки
|
||
if (st == nil || st.NoCounterAt.IsZero()) && ln.Ref != "" {
|
||
if at, ok := findNoCounterNotice(ln.Ref); ok {
|
||
globalOrderWatch.update(guid, func(w *OrderWatch) {
|
||
if w.Lines == nil {
|
||
w.Lines = map[string]*OrderLineWatch{}
|
||
}
|
||
l := w.Lines[ln.ContrDID]
|
||
if l == nil {
|
||
l = &OrderLineWatch{Ref: ln.Ref, ISIN: ln.ISIN}
|
||
w.Lines[ln.ContrDID] = l
|
||
}
|
||
l.NoCounterAt = at
|
||
if w.NoCounterAt.IsZero() || at.Before(w.NoCounterAt) {
|
||
w.NoCounterAt = at
|
||
}
|
||
})
|
||
log.Printf("order-watch: перевод %s, %s: НРД сообщил об отсутствии встречного поручения (%s)", shortGUID(guid), ln.Ref, at.Local().Format("15:04:05"))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// findMatchedNotice — отчёт НРД GS116 «сквитовано со встречным» (<Mtchd>) по номеру поручения.
|
||
func findMatchedNotice(contrDID string) (time.Time, bool) {
|
||
if contrDID == "" {
|
||
return time.Time{}, false
|
||
}
|
||
for _, pk := range pkgIndex() {
|
||
if strings.Contains(pk.body, "<Mtchd>") && strings.Contains(pk.body, "GS116") && strings.Contains(pk.body, ">"+contrDID+"<") {
|
||
return pk.mt, true
|
||
}
|
||
}
|
||
return time.Time{}, false
|
||
}
|
||
|
||
// orderRespNo — номер ответа НРД (BizMsgIdr) из отчёта MS010 по строке.
|
||
func orderRespNo(resps []orderResponse) string {
|
||
for _, r := range resps {
|
||
if r.Done && r.No != "" {
|
||
return r.No
|
||
}
|
||
}
|
||
return ""
|
||
}
|