From e123eda691375c35d8e819fcc0919210f684c124 Mon Sep 17 00:00:00 2001 From: zuevav Date: Mon, 28 Sep 2026 19:36:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D1=80=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?:=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=9D=D0=A0=D0=94=20GS036=20=C2=AB=D0=BD=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B2=D1=81=D1=82=D1=80=D0=B5=D1=87=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BF=D0=BE=D1=80=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=C2=BB=20(=D0=BF=D0=BE=20=D1=80=D0=B5=D1=84=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D1=81=D1=83=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8)=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=B5?= =?UTF-8?q?,=20=D0=B2=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=D1=85=20?= =?UTF-8?q?=D0=9D=D0=A0=D0=94=20=D0=B8=20=D0=B2=20=D0=BE=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=89=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BE=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=80=D0=BE=D1=87=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi --- internal/lkgateway/livestatus.go | 2 +- internal/lkgateway/nocounter_test.go | 45 ++++++++++++ internal/lkgateway/orderwatch.go | 101 ++++++++++++++++++++++++--- internal/lkgateway/outgoing_order.go | 7 +- 4 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 internal/lkgateway/nocounter_test.go diff --git a/internal/lkgateway/livestatus.go b/internal/lkgateway/livestatus.go index c962d0a..22b0a31 100644 --- a/internal/lkgateway/livestatus.go +++ b/internal/lkgateway/livestatus.go @@ -101,7 +101,7 @@ func liveOutgoingState(c ClaimView) LiveStatus { if d := form16Snapshot()[guid]; d != nil { cp := *d if cp.Submitted && len(cp.Responses) == 0 && !cp.Done { - cp.Responses, cp.Done = outgoingResponses(cp.ContrDIDs) // ответы ищем только для этой, живой сделки + cp.Responses, cp.Done = outgoingResponses(cp.ContrDIDs, orderWatchRefs(guid)...) // ответы ищем только для этой, живой сделки _, cp.Rejected, _ = orderRespVerdict(cp.Responses) } depo = &cp diff --git a/internal/lkgateway/nocounter_test.go b/internal/lkgateway/nocounter_test.go new file mode 100644 index 0000000..6609b4b --- /dev/null +++ b/internal/lkgateway/nocounter_test.go @@ -0,0 +1,45 @@ +package lkgateway + +import ( + "strings" + "testing" + "time" +) + +// GS036 «об отсутствии встречного поручения» связывается с поручением по референсу строки. +func TestNoCounterNotice(t *testing.T) { + at := time.Date(2026, 9, 28, 17, 50, 2, 0, time.Local) + pkgIndexCache.mu.Lock() + old, oldAt := pkgIndexCache.list, pkgIndexCache.at + pkgIndexCache.list = []pkgIndexEntry{ + {dir: "Z2895760", mt: at, body: "GS036 отсутствии встречного поручения к поручению депоРеференсM2M20260928HEKR0"}, + {dir: "K2895751", mt: at, body: "1446389121952"}, + } + pkgIndexCache.at = time.Now().Add(time.Hour) + pkgIndexCache.mu.Unlock() + defer func() { + pkgIndexCache.mu.Lock() + pkgIndexCache.list, pkgIndexCache.at = old, oldAt + pkgIndexCache.mu.Unlock() + }() + + if got, ok := findNoCounterNotice("M2M20260928HEKR0"); !ok || !got.Equal(at) { + t.Fatalf("уведомление не найдено: %v %v", got, ok) + } + if _, ok := findNoCounterNotice("M2M20260928M5XQC"); ok { + t.Fatal("чужой референс") + } + + w := &OrderWatch{Direction: "out", OrderAt: at.Add(-time.Minute), LateAt: at.Add(5 * time.Minute), NoCounterAt: at} + if s := w.StatusRu(); !strings.Contains(s, "контрагент ещё не подал встречное поручение 16/1") { + t.Fatalf("ждём встречное: %q", s) + } + w.Direction = "in" + if s := w.StatusRu(); !strings.Contains(s, "поручение 16 передающего") { + t.Fatalf("входящий: %q", s) + } + w.Direction, w.MS010At = "out", at.Add(53*time.Minute) + if s := w.StatusRu(); !strings.Contains(s, "исполнено") || !strings.Contains(s, "задержка на стороне контрагента") { + t.Fatalf("исполнено с задержкой: %q", s) + } +} diff --git a/internal/lkgateway/orderwatch.go b/internal/lkgateway/orderwatch.go index 57c84eb..090eea1 100644 --- a/internal/lkgateway/orderwatch.go +++ b/internal/lkgateway/orderwatch.go @@ -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")) + } + } + } +} diff --git a/internal/lkgateway/outgoing_order.go b/internal/lkgateway/outgoing_order.go index 0156655..7064ca2 100644 --- a/internal/lkgateway/outgoing_order.go +++ b/internal/lkgateway/outgoing_order.go @@ -249,7 +249,7 @@ func scanForm16DraftsOpt(store *OutgoingStore, withResponses bool) []*OutgoingDe d.OrderXML = sub.OrderXML d.ContrDIDs = sub.ContrDIDs if withResponses { - d.Responses, d.Done = outgoingResponses(sub.ContrDIDs) + d.Responses, d.Done = outgoingResponses(sub.ContrDIDs, orderWatchRefs(d.GUID)...) _, d.Rejected, _ = orderRespVerdict(d.Responses) } } @@ -271,11 +271,12 @@ func scanForm16Deal(store *OutgoingStore, guid string) (*OutgoingDeal, bool) { // outgoingResponses агрегирует ответы НРД (MS010/REORD/…) по всем поручениям сделки. // done=true, когда MS010 получен хотя бы по одному поручению. -func outgoingResponses(contrDIDs []string) ([]orderResponse, bool) { +func outgoingResponses(contrDIDs []string, refs ...string) ([]orderResponse, bool) { seen := map[string]bool{} var out []orderResponse var done bool - for _, cd := range contrDIDs { + keys := append(append([]string{}, contrDIDs...), refs...) // GS036 «нет встречного» — только по референсу + for _, cd := range keys { for _, rp := range findOrderResponses(cd) { if seen[rp.Pkg] { continue