Карточка поручения: уведомление НРД GS036 «нет встречного поручения» (по референсу строки) — в статусе, в ответах НРД и в оповещении о просрочке

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-09-28 19:36:50 +03:00
parent 02e44c9173
commit e123eda691
4 changed files with 143 additions and 12 deletions
+93 -8
View File
@@ -35,6 +35,9 @@ type OrderWatch struct {
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"`
// Lines — состояние по каждой строке пакета (одно депо-поручение = одна бумага),
// ключ — contr_d_id. По договорённости с Fancy (18.09.2026) исходы пишутся в DEPO
// по строкам с REFERENCE и сразу по MS010 этой строки, не дожидаясь остальных:
@@ -44,11 +47,20 @@ type OrderWatch struct {
// 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"`
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 по этой строке
}
// 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).
@@ -110,9 +122,14 @@ func (w *OrderWatch) StatusRu() string {
if w.MS010At.Sub(w.OrderAt) > orderMS010Within {
late = fmt.Sprintf(", с опозданием %s", w.MS010At.Sub(w.OrderAt).Round(time.Minute))
}
if !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.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:
@@ -259,6 +276,10 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str
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 // финальное состояние уже зафиксировано по всем строкам
}
@@ -281,7 +302,11 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str
if w.Lines == nil {
w.Lines = map[string]*OrderLineWatch{}
}
w.Lines[ln.ContrDID] = &OrderLineWatch{Ref: ln.Ref, ISIN: ln.ISIN, MS010At: at, Rejected: rejected}
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
}
@@ -315,9 +340,13 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str
}
}
if fresh {
why := "отчёт MS010 не получен"
if !w.NoCounterAt.IsZero() {
why = "НРД сообщил, что контрагент ещё не подал встречное " + w.counterName() + " — наше поручение принято, исполнение ждёт контрагента"
}
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")))
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")))
}
}
@@ -426,3 +455,59 @@ func (s *Server) orderWatchLoop(ctx context.Context) {
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]
// уведомление «нет встречного поручения» (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"))
}
}
}
}