Карточка поручения: уведомление НРД GS036 «нет встречного поручения» (по референсу строки) — в статусе, в ответах НРД и в оповещении о просрочке
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -101,7 +101,7 @@ func liveOutgoingState(c ClaimView) LiveStatus {
|
|||||||
if d := form16Snapshot()[guid]; d != nil {
|
if d := form16Snapshot()[guid]; d != nil {
|
||||||
cp := *d
|
cp := *d
|
||||||
if cp.Submitted && len(cp.Responses) == 0 && !cp.Done {
|
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)
|
_, cp.Rejected, _ = orderRespVerdict(cp.Responses)
|
||||||
}
|
}
|
||||||
depo = &cp
|
depo = &cp
|
||||||
|
|||||||
@@ -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: "<BizSvc>GS036</BizSvc><NtfctnTxt> отсутствии встречного поручения к поручению депо</NtfctnTxt><NtfctnHdr>Референс</NtfctnHdr><NtfctnTxt>M2M20260928HEKR0</NtfctnTxt>"},
|
||||||
|
{dir: "K2895751", mt: at, body: "<reg_no>14463891</reg_no><contragent_doc_id>21952</contragent_doc_id>"},
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,9 @@ type OrderWatch struct {
|
|||||||
Rejected bool `json:"rejected,omitempty"` // хотя бы одна строка отвергнута
|
Rejected bool `json:"rejected,omitempty"` // хотя бы одна строка отвергнута
|
||||||
LateAt time.Time `json:"late_at,omitempty"` // когда зафиксировали просрочку 5 мин
|
LateAt time.Time `json:"late_at,omitempty"` // когда зафиксировали просрочку 5 мин
|
||||||
ExpiredAt time.Time `json:"expired_at,omitempty"` // все незакрытые строки истекли (30 дней)
|
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 — состояние по каждой строке пакета (одно депо-поручение = одна бумага),
|
// Lines — состояние по каждой строке пакета (одно депо-поручение = одна бумага),
|
||||||
// ключ — contr_d_id. По договорённости с Fancy (18.09.2026) исходы пишутся в DEPO
|
// ключ — contr_d_id. По договорённости с Fancy (18.09.2026) исходы пишутся в DEPO
|
||||||
// по строкам с REFERENCE и сразу по MS010 этой строки, не дожидаясь остальных:
|
// по строкам с REFERENCE и сразу по MS010 этой строки, не дожидаясь остальных:
|
||||||
@@ -44,11 +47,20 @@ type OrderWatch struct {
|
|||||||
|
|
||||||
// OrderLineWatch — одна строка поручения: какая бумага (REFERENCE/ISIN) и её исход.
|
// OrderLineWatch — одна строка поручения: какая бумага (REFERENCE/ISIN) и её исход.
|
||||||
type OrderLineWatch struct {
|
type OrderLineWatch struct {
|
||||||
Ref string `json:"ref,omitempty"` // REFERENCE строки в FTV_M2M_LINES
|
Ref string `json:"ref,omitempty"` // REFERENCE строки в FTV_M2M_LINES
|
||||||
ISIN string `json:"isin,omitempty"`
|
ISIN string `json:"isin,omitempty"`
|
||||||
MS010At time.Time `json:"ms010_at,omitempty"`
|
MS010At time.Time `json:"ms010_at,omitempty"`
|
||||||
Rejected bool `json:"rejected,omitempty"`
|
Rejected bool `json:"rejected,omitempty"`
|
||||||
ExpiredAt time.Time `json:"expired_at,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).
|
// orderLine — строка поручения для наблюдателя: contr_d_id ↔ бумага (REFERENCE, ISIN).
|
||||||
@@ -110,9 +122,14 @@ func (w *OrderWatch) StatusRu() string {
|
|||||||
if w.MS010At.Sub(w.OrderAt) > orderMS010Within {
|
if w.MS010At.Sub(w.OrderAt) > orderMS010Within {
|
||||||
late = fmt.Sprintf(", с опозданием %s", w.MS010At.Sub(w.OrderAt).Round(time.Minute))
|
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
|
return "исполнено: MS010 получен " + w.MS010At.Local().Format("02.01.2006 15:04") + late
|
||||||
case !w.ExpiredAt.IsZero():
|
case !w.ExpiredAt.IsZero():
|
||||||
return "СРОК 30 ДНЕЙ ИСТЁК " + w.ExpiredAt.Local().Format("02.01.2006") + " — поручение считается отменённым"
|
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():
|
case !w.LateAt.IsZero():
|
||||||
return "НЕ ИСПОЛНЕНО В СРОК 5 МИН (подано " + w.OrderAt.Local().Format("02.01 15:04") + "); ожидаем исполнения до " + w.OrderAt.Add(orderExecWithin).Local().Format("02.01.2006")
|
return "НЕ ИСПОЛНЕНО В СРОК 5 МИН (подано " + w.OrderAt.Local().Format("02.01 15:04") + "); ожидаем исполнения до " + w.OrderAt.Add(orderExecWithin).Local().Format("02.01.2006")
|
||||||
default:
|
default:
|
||||||
@@ -259,6 +276,10 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str
|
|||||||
if w == nil {
|
if w == nil {
|
||||||
w = globalOrderWatch.update(guid, func(w *OrderWatch) { w.Direction, w.OrderAt = direction, orderAt })
|
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() {
|
if !w.MS010At.IsZero() || !w.ExpiredAt.IsZero() {
|
||||||
return // финальное состояние уже зафиксировано по всем строкам
|
return // финальное состояние уже зафиксировано по всем строкам
|
||||||
}
|
}
|
||||||
@@ -281,7 +302,11 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str
|
|||||||
if w.Lines == nil {
|
if w.Lines == nil {
|
||||||
w.Lines = map[string]*OrderLineWatch{}
|
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 {
|
if rejected {
|
||||||
w.Rejected = true
|
w.Rejected = true
|
||||||
}
|
}
|
||||||
@@ -315,9 +340,13 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if fresh {
|
if fresh {
|
||||||
|
why := "отчёт MS010 не получен"
|
||||||
|
if !w.NoCounterAt.IsZero() {
|
||||||
|
why = "НРД сообщил, что контрагент ещё не подал встречное " + w.counterName() + " — наше поручение принято, исполнение ждёт контрагента"
|
||||||
|
}
|
||||||
raiseSystemAlert(rc, "НРД: депо-поручение не исполнено в срок 5 минут",
|
raiseSystemAlert(rc, "НРД: депо-поручение не исполнено в срок 5 минут",
|
||||||
fmt.Sprintf("%s (перевод %s): подано %s, отчёт MS010 не получен. Ожидаем исполнения до %s, после этого поручение считается отменённым.",
|
fmt.Sprintf("%s (перевод %s): подано %s, %s. Ожидаем исполнения до %s, после этого поручение считается отменённым.",
|
||||||
label, shortGUID(guid), orderAt.Local().Format("02.01.2006 15:04"), orderAt.Add(orderExecWithin).Local().Format("02.01.2006")))
|
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")
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ func scanForm16DraftsOpt(store *OutgoingStore, withResponses bool) []*OutgoingDe
|
|||||||
d.OrderXML = sub.OrderXML
|
d.OrderXML = sub.OrderXML
|
||||||
d.ContrDIDs = sub.ContrDIDs
|
d.ContrDIDs = sub.ContrDIDs
|
||||||
if withResponses {
|
if withResponses {
|
||||||
d.Responses, d.Done = outgoingResponses(sub.ContrDIDs)
|
d.Responses, d.Done = outgoingResponses(sub.ContrDIDs, orderWatchRefs(d.GUID)...)
|
||||||
_, d.Rejected, _ = orderRespVerdict(d.Responses)
|
_, d.Rejected, _ = orderRespVerdict(d.Responses)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,11 +271,12 @@ func scanForm16Deal(store *OutgoingStore, guid string) (*OutgoingDeal, bool) {
|
|||||||
|
|
||||||
// outgoingResponses агрегирует ответы НРД (MS010/REORD/…) по всем поручениям сделки.
|
// outgoingResponses агрегирует ответы НРД (MS010/REORD/…) по всем поручениям сделки.
|
||||||
// done=true, когда MS010 получен хотя бы по одному поручению.
|
// done=true, когда MS010 получен хотя бы по одному поручению.
|
||||||
func outgoingResponses(contrDIDs []string) ([]orderResponse, bool) {
|
func outgoingResponses(contrDIDs []string, refs ...string) ([]orderResponse, bool) {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var out []orderResponse
|
var out []orderResponse
|
||||||
var done bool
|
var done bool
|
||||||
for _, cd := range contrDIDs {
|
keys := append(append([]string{}, contrDIDs...), refs...) // GS036 «нет встречного» — только по референсу
|
||||||
|
for _, cd := range keys {
|
||||||
for _, rp := range findOrderResponses(cd) {
|
for _, rp := range findOrderResponses(cd) {
|
||||||
if seen[rp.Pkg] {
|
if seen[rp.Pkg] {
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user