diff --git a/docs/fancy-events-partial-m2m.md b/docs/fancy-events-partial-m2m.md index e239346..43b66e6 100644 --- a/docs/fancy-events-partial-m2m.md +++ b/docs/fancy-events-partial-m2m.md @@ -55,3 +55,28 @@ bj готов писать события **по строке пакета** (RE «отказ» — когда отказ по всем, иначе «исполнен частично» с ожиданием остальных; - события в FTV_M2M_EVENTS в согласованном формате (п. 2); - в карточке перевода — статус по каждой бумаге, оповещение оператору о частичном исполнении. + +--- + +## Ответы Fancy (В. Кокин, письмо 21.09.2026) и что реализовано в bj + +| # | Вопрос | Ответ | В bj | +|---|--------|-------|------| +| 1 | Событие с REFERENCE по одной бумаге — провести только её | **Да** | события исходов пишутся по строкам с REFERENCE | +| 2 | Формат при частичном исходе | **(а) отдельные события по строкам** — они приходят в разное время | `BJ_EXECUTED`/`BJ_ORDER_REJECTED` по каждой строке | +| 3 | Писать по строке сразу при её MS010 или ждать все | **Сразу** | `orderWatchCheck` пишет исход строки в момент её MS010 | +| 4 | Состояние документа DEPO при частичном исполнении | **есть статус «частично»**, документ не делится | карточка bj: «исполнено частично: N из M» (`StatusRu`/`Kind`=warn) | +| 5 | Отказ после принятия (REORD) — событие отмены проводки? | **Такого не может быть**; экстремальный казус — руками | отдельного события отмены нет | +| 6 | 30 дней по одной бумаге → `BJ_CANCELLED` с REFERENCE | **Да** | истечение по строке → `BJ_CANCELLED` с REFERENCE, QUIK-блок снимается по ISIN | +| 7 | Связка contr_d_id ↔ REFERENCE джобе | **Не нужна** — по REFERENCE найдёт всё | contr_d_id кладём в COMMENT для разбора | +| 8 | Совместимость: строчные только при частичном? | **Сразу везде**, но **одновременно** на bj и bj2depo | все `BJ_ORDER_*`/`BJ_EXECUTED`/`BJ_CANCELLED` — строчные; события на весь GUID больше не пишем | + +**Реализация в bj** (`orderwatch.go`, `depoevents.go`, `incoming*.go`, `outgoing_order.go`): +- одно депо-поручение (MF010) = одна бумага; входящий 16/1 теперь хранит **все** `contr_d_id` (`OrderLines`: contr_d_id ↔ ISIN ↔ REFERENCE) — раньше только первый; +- исходящий 16: REFERENCE берётся из бумаг M2MTR по ISIN (`outgoingLineRefs`, кэш по GUID); +- `OrderWatch.Lines` — состояние по строкам; объект закрыт, когда MS010 по всем строкам; +- `BJ_ORDER_SENT` — по строке (COMMENT = contr_d_id); `BJ_ORDER_LATE` — по каждой незакрытой строке при просрочке 5 мин. + +**Развёртывание — только одновременно с bj2depo** (п. 8): до подтверждения Вадима, что джоба читает строчные события, bj на стенд/бой не выкатывается. + +Предложение Вадима: прогнать тесты с реальным контрагентом на функционале вендора (есть компания с потребностью тестировать). diff --git a/internal/lkgateway/depoevents.go b/internal/lkgateway/depoevents.go index bd70b68..9d3cffd 100644 --- a/internal/lkgateway/depoevents.go +++ b/internal/lkgateway/depoevents.go @@ -380,23 +380,44 @@ func depoOnServiceError(rc *RuntimeConfig, guid, codes, reason string) { depoEvent(rc, guid, "", "BJ_SERVICE_ERROR", strings.ReplaceAll(strings.TrimSpace(codes), " ", ";"), reason) } +// СТРОЧНЫЕ события по депо-поручениям (договорённость с Fancy 18.09.2026, ответы +// В. Кокина): одно депо-поручение = одна бумага пакета, поэтому подача и исход +// пишутся в DEPO ПО СТРОКАМ с REFERENCE и сразу по MS010 этой строки, не дожидаясь +// остальных — пакет может исполниться частично. Связка contr_d_id↔REFERENCE джобе +// bj2depo не нужна (по REFERENCE она находит всё); contr_d_id кладём в COMMENT для +// разбора. Событие на весь GUID (пустой REFERENCE) больше не пишем: переход на +// строчные — одновременно на bj и bj2depo. + +// depoOnOrderLineSent — подано депо-поручение по строке (REFERENCE), contr_d_id — в комментарии. +func depoOnOrderLineSent(rc *RuntimeConfig, guid, ref, contrDID string) { + depoEvent(rc, guid, ref, "BJ_ORDER_SENT", "", contrDID) +} + +// depoOnOrderLineResult — исход по строке: accepted | rejected | late | expired. +// accepted → BJ_ORDER_ACCEPTED + BJ_EXECUTED; rejected → BJ_ORDER_REJECTED; +// late → BJ_ORDER_LATE (5 мин без MS010); expired → BJ_CANCELLED (30 дней по бумаге). +func depoOnOrderLineResult(rc *RuntimeConfig, guid, ref, kind, text string) { + switch kind { + case "accepted": + depoEvent(rc, guid, ref, "BJ_ORDER_ACCEPTED", "", "") + depoEvent(rc, guid, ref, "BJ_EXECUTED", "", "") + case "rejected": + depoEvent(rc, guid, ref, "BJ_ORDER_REJECTED", "", text) + case "late": + depoEvent(rc, guid, ref, "BJ_ORDER_LATE", "", "") + case "expired": + depoEvent(rc, guid, ref, "BJ_CANCELLED", "", "") + } +} + +// depoOnOrderSent / depoOnOrderResult — прежний формат «на весь GUID» (пустой +// REFERENCE). Оставлены для совместимости вызовов; новые пути пишут по строкам. func depoOnOrderSent(rc *RuntimeConfig, guid string, contrDIDs []string, form string) { depoEvent(rc, guid, "", "BJ_ORDER_SENT", "", strings.Join(contrDIDs, ";")) } -// результат по депо-поручению: accepted | rejected | late | expired func depoOnOrderResult(rc *RuntimeConfig, guid, kind, text string) { - switch kind { - case "accepted": - depoEvent(rc, guid, "", "BJ_ORDER_ACCEPTED", "", "") - depoEvent(rc, guid, "", "BJ_EXECUTED", "", "") - case "rejected": - depoEvent(rc, guid, "", "BJ_ORDER_REJECTED", "", text) - case "late": - depoEvent(rc, guid, "", "BJ_ORDER_LATE", "", "") - case "expired": - depoEvent(rc, guid, "", "BJ_CANCELLED", "", "") - } + depoOnOrderLineResult(rc, guid, "", kind, text) } func depoOnQuik(rc *RuntimeConfig, guid, isin, evt, instrNo string) { @@ -627,7 +648,7 @@ func depoJobOnce(rc *RuntimeConfig) { return } start := time.Now() - _, err = db.ExecContext(ctx, "exec "+proc) + _, err = execProc(ctx, db, proc) res := fmt.Sprintf("%s: OK за %s", start.Format("02.01 15:04:05"), time.Since(start).Round(time.Millisecond)) if err != nil { res = fmt.Sprintf("%s: ОШИБКА %s", start.Format("02.01 15:04:05"), quikErrorDetail(err)) @@ -743,7 +764,7 @@ func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) { return "у логина " + f.User + " нет права EXECUTE на " + depoJobProcName(f) + " — выполните на сервере: " + strings.ReplaceAll(depoJobGrantScript(f), "\n", " "), false } start := time.Now() - if _, err := db.ExecContext(ctx, "exec "+depoJobProcName(f)); err != nil { + if _, err := execProc(ctx, db, depoJobProcName(f)); err != nil { return "вызов " + depoJobProcName(f) + ": " + quikErrorDetail(err), false } return fmt.Sprintf("право есть, процедура %s выполнена за %s; bj будет вызывать её каждые %d с", depoJobProcName(f), time.Since(start).Round(time.Millisecond), depoJobEvery(f)), true @@ -867,15 +888,18 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl } } if it.OrderSubmitted { - ev(it.OrderAt, it.GUID, "", "BJ_ORDER_SENT", "", it.OrderContrDID) - resps := findOrderResponses(it.OrderContrDID) - if done, rejected, text := orderRespVerdict(resps); done { - at := orderRespAt(resps) - if rejected { - ev(at, it.GUID, "", "BJ_ORDER_REJECTED", "", text) - } else { - ev(at, it.GUID, "", "BJ_ORDER_ACCEPTED", "", "") - ev(at, it.GUID, "", "BJ_EXECUTED", "", "") + // по строкам: у каждой бумаги своё поручение 16/1 и свой MS010 + for _, ln := range incomingOrderLines(it) { + ev(it.OrderAt, it.GUID, ln.Ref, "BJ_ORDER_SENT", "", ln.ContrDID) + resps := findOrderResponses(ln.ContrDID) + if done, rejected, text := orderRespVerdict(resps); done { + at := orderRespAt(resps) + if rejected { + ev(at, it.GUID, ln.Ref, "BJ_ORDER_REJECTED", "", text) + } else { + ev(at, it.GUID, ln.Ref, "BJ_ORDER_ACCEPTED", "", "") + ev(at, it.GUID, ln.Ref, "BJ_EXECUTED", "", "") + } } } } @@ -934,15 +958,26 @@ func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onl } if globalOutgoingStore != nil { if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok && od.Submitted { - ev(od.OrderAt, guid, "", "BJ_ORDER_SENT", "", strings.Join(od.ContrDIDs, ";")) - resps, _ := outgoingResponses(od.ContrDIDs) - if done, rejected, text := orderRespVerdict(resps); done { - at := orderRespAt(resps) - if rejected { - ev(at, guid, "", "BJ_ORDER_REJECTED", "", text) - } else { - ev(at, guid, "", "BJ_ORDER_ACCEPTED", "", "") - ev(at, guid, "", "BJ_EXECUTED", "", "") + // по строкам: один черновик MF010 = одна бумага; REFERENCE — по ISIN из M2MTR + isinRef := map[string]string{} + for ref, isin := range refISIN { + isinRef[strings.ToUpper(isin)] = ref + } + for _, sec := range od.Secs { + if sec.ContrDID == "" { + continue + } + ref := isinRef[strings.ToUpper(sec.ISIN)] + ev(od.OrderAt, guid, ref, "BJ_ORDER_SENT", "", sec.ContrDID) + resps := findOrderResponses(sec.ContrDID) + if done, rejected, text := orderRespVerdict(resps); done { + at := orderRespAt(resps) + if rejected { + ev(at, guid, ref, "BJ_ORDER_REJECTED", "", text) + } else { + ev(at, guid, ref, "BJ_ORDER_ACCEPTED", "", "") + ev(at, guid, ref, "BJ_EXECUTED", "", "") + } } } } @@ -998,10 +1033,16 @@ func depoSimulateIncoming(ctx context.Context, rc *RuntimeConfig, it *IncomingTr depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "") } } + // Исходы — по строкам (REFERENCE), как в бою: по каждой принятой бумаге своё поручение. if anyConfirmed { - depoEventAt(rc, now.Add(2*time.Minute), guid, "", "BJ_ORDER_SENT", "", "") - depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_ORDER_ACCEPTED", "", "") - depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_EXECUTED", "", "") + for _, s := range it.Securities { + if s.Decision == "rejected" { + continue + } + depoEventAt(rc, now.Add(2*time.Minute), guid, s.ReferenceID, "BJ_ORDER_SENT", "", "") + depoEventAt(rc, now.Add(3*time.Minute), guid, s.ReferenceID, "BJ_ORDER_ACCEPTED", "", "") + depoEventAt(rc, now.Add(3*time.Minute), guid, s.ReferenceID, "BJ_EXECUTED", "", "") + } } return guid, nil } diff --git a/internal/lkgateway/incoming.go b/internal/lkgateway/incoming.go index 9ccc541..d3556e1 100644 --- a/internal/lkgateway/incoming.go +++ b/internal/lkgateway/incoming.go @@ -63,11 +63,15 @@ type IncomingTransfer struct { Auto bool `json:"auto,omitempty"` // отвечено автоматически (дубль) Fingerprint string `json:"fingerprint,omitempty"` // отпечаток для детекта дублей // Подача депозитарного поручения 16/1 (ORDXM) в НРД. - OrderSubmitted bool `json:"order_submitted,omitempty"` - OrderContrDID string `json:"order_contr_did,omitempty"` // contr_d_id поручения (связь с ответами НРД) - OrderPkgs string `json:"order_pkgs,omitempty"` // id пакетов ИШ - OrderAt time.Time `json:"order_at,omitempty"` - OrderXML string `json:"order_xml,omitempty"` // что мы реально отправили (поручение 16/1) + OrderSubmitted bool `json:"order_submitted,omitempty"` + OrderContrDID string `json:"order_contr_did,omitempty"` // contr_d_id ПЕРВОГО поручения (совместимость карточек/выгрузок) + // OrderLines — все поданные депо-поручения 16/1: одно на бумагу (contr_d_id ↔ ISIN ↔ + // REFERENCE строки пакета). Нужны, чтобы исход каждой бумаги (MS010) писать в DEPO + // по своей строке — пакет может исполниться частично. + OrderLines []IncomingOrderLine `json:"order_lines,omitempty"` + OrderPkgs string `json:"order_pkgs,omitempty"` // id пакетов ИШ + OrderAt time.Time `json:"order_at,omitempty"` + OrderXML string `json:"order_xml,omitempty"` // что мы реально отправили (поручение 16/1) // Сверка с DEPO (эмуляция ответа, ничего не отправляется) — для бейджа в списке. DepoRecommend string `json:"depo_recommend,omitempty"` // confirm | reject | mixed DepoSummary string `json:"depo_summary,omitempty"` // «принять» / «отказ M2M04» / … @@ -296,14 +300,23 @@ func (s *IncomingStore) MarkAuto(id, note string) { s.save() } -// SetOrderSubmitted помечает, что подано поручение депо 16/1 (ORDXM) в НРД, и -// сохраняет отправленный XML (что именно мы отправили). -func (s *IncomingStore) SetOrderSubmitted(id, contrDID, pkgs, orderXML string) { +// IncomingOrderLine — одно депо-поручение 16/1 по одной бумаге пакета. +type IncomingOrderLine struct { + ContrDID string `json:"contr_did"` + ISIN string `json:"isin,omitempty"` + Ref string `json:"ref,omitempty"` // REFERENCE бумаги в пакете (Security/ReferenceId) +} + +// SetOrderSubmitted помечает, что поданы поручения депо 16/1 (ORDXM) в НРД, и +// сохраняет отправленный XML (что именно мы отправили). lines — по одному на бумагу; +// OrderContrDID остаётся первым из них для совместимости. +func (s *IncomingStore) SetOrderSubmitted(id, contrDID, pkgs, orderXML string, lines []IncomingOrderLine) { s.mu.Lock() defer s.mu.Unlock() if it, ok := s.items[id]; ok { it.OrderSubmitted = true it.OrderContrDID = contrDID + it.OrderLines = append([]IncomingOrderLine(nil), lines...) it.OrderPkgs = pkgs it.OrderXML = orderXML it.OrderAt = time.Now().UTC() diff --git a/internal/lkgateway/incoming_order.go b/internal/lkgateway/incoming_order.go index dbdd74f..cec49fb 100644 --- a/internal/lkgateway/incoming_order.go +++ b/internal/lkgateway/incoming_order.go @@ -99,14 +99,25 @@ func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *Incoming id := it.ID var sentXML []string var contrDID string + // Одно MF010 = одна бумага пакета: запоминаем каждую строку (contr_d_id ↔ ISIN ↔ + // REFERENCE), чтобы исход по MS010 писать в DEPO по своей строке. + refByISIN := map[string]string{} + for _, s := range it.Securities { + if s.ISIN != "" { + refByISIN[strings.ToUpper(s.ISIN)] = s.ReferenceID + } + } + var lines []IncomingOrderLine for _, p := range xmls { raw, e := os.ReadFile(p) // windows-1251, шлём как есть if e != nil { firstErr = e.Error() continue } + body := string(decodeCP1251(raw)) + cd := orderContrDID(raw) if contrDID == "" { - contrDID = orderContrDID(raw) + contrDID = cd } zipBody, e := igw.PackDepoOrder(raw, filepath.Base(p)) if e != nil { @@ -119,12 +130,16 @@ func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *Incoming continue } sent = append(sent, pkgID) - sentXML = append(sentXML, string(decodeCP1251(raw))) // сохраняем что отправили (UTF-8) + sentXML = append(sentXML, body) // сохраняем что отправили (UTF-8) + isin := strings.ToUpper(of16(body, "security_isin")) + lines = append(lines, IncomingOrderLine{ContrDID: cd, ISIN: isin, Ref: refByISIN[isin]}) } if len(sent) > 0 { - deps.store.SetOrderSubmitted(id, contrDID, strings.Join(sent, ", "), strings.Join(sentXML, "\n\n")) + deps.store.SetOrderSubmitted(id, contrDID, strings.Join(sent, ", "), strings.Join(sentXML, "\n\n"), lines) if cur, ok := deps.store.Get(id); ok { - go depoOnOrderSent(deps.rc, cur.GUID, []string{contrDID}, "16/1 (зачисление)") + for _, l := range lines { + go depoOnOrderLineSent(deps.rc, cur.GUID, l.Ref, l.ContrDID) + } } } if len(sent) == 0 { diff --git a/internal/lkgateway/orderwatch.go b/internal/lkgateway/orderwatch.go index ece6799..57c84eb 100644 --- a/internal/lkgateway/orderwatch.go +++ b/internal/lkgateway/orderwatch.go @@ -14,8 +14,11 @@ import ( "log" "os" "path/filepath" + "strings" "sync" "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" ) const ( @@ -28,10 +31,56 @@ 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 дней) + // 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"` - LateAt time.Time `json:"late_at,omitempty"` // когда зафиксировали просрочку 5 мин - ExpiredAt time.Time `json:"expired_at,omitempty"` // когда истекли 30 дней + ExpiredAt time.Time `json:"expired_at,omitempty"` +} + +// 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 } // Состояние по-русски для карточек. @@ -39,6 +88,20 @@ 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") + ")" @@ -58,6 +121,12 @@ func (w *OrderWatch) StatusRu() string { } 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 "" @@ -133,9 +202,57 @@ func (s *orderWatchStore) update(guid string, fn func(w *OrderWatch)) *OrderWatc 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() { +// 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) @@ -143,60 +260,120 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str 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 + 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{} + } + w.Lines[ln.ContrDID] = &OrderLineWatch{Ref: ln.Ref, ISIN: ln.ISIN, MS010At: at, Rejected: rejected} + if rejected { + w.Rejected = true + } + }) + if rejected { + go depoOnOrderLineResult(rc, guid, ln.Ref, "rejected", text) + } else { + go depoOnOrderLineResult(rc, guid, ln.Ref, "accepted", "") + } + } + 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 + } + } + globalOrderWatch.update(guid, func(w *OrderWatch) { w.MS010At = latest }) + return + } + + // 3) просрочка 5 минут — один сигнал на объект, событие по каждой незакрытой строке if w.LateAt.IsZero() && now.Sub(orderAt) > orderMS010Within { globalOrderWatch.update(guid, func(w *OrderWatch) { w.LateAt = now }) - go depoOnOrderResult(rc, guid, "late", "") + for _, ln := range lines { + if !w.Lines[ln.ContrDID].lineDone() { + go depoOnOrderLineResult(rc, guid, ln.Ref, "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 дней) — перевод отменён") + + // 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"))) + } } } -func orderWatchTick(ctx context.Context, rc *RuntimeConfig) { +// 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 } - resps, _ := outgoingResponses(od.ContrDIDs) - orderWatchCheck(ctx, rc, od.GUID, "out", od.OrderAt, resps, "Поручение 16 (списание)") + orderWatchCheck(ctx, rc, od.GUID, "out", od.OrderAt, outgoingLineRefs(ctx, svc, od), "Поручение 16 (списание)") } } if globalIncomingStore != nil { @@ -204,11 +381,30 @@ func orderWatchTick(ctx context.Context, rc *RuntimeConfig) { if !it.OrderSubmitted { continue } - orderWatchCheck(ctx, rc, it.GUID, "in", it.OrderAt, findOrderResponses(it.OrderContrDID), "Поручение 16/1 (зачисление)") + orderWatchCheck(ctx, rc, it.GUID, "in", it.OrderAt, incomingOrderLines(it), "Поручение 16/1 (зачисление)") } } } +// 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) @@ -225,7 +421,7 @@ func (s *Server) orderWatchLoop(ctx context.Context) { log.Printf("order-watch: panic: %v", r) } }() - orderWatchTick(ctx, s.rc) + orderWatchTick(ctx, s.rc, s.svc) }() healthBeat("order-watch") } diff --git a/internal/lkgateway/orderwatch_lines_test.go b/internal/lkgateway/orderwatch_lines_test.go new file mode 100644 index 0000000..82b526c --- /dev/null +++ b/internal/lkgateway/orderwatch_lines_test.go @@ -0,0 +1,77 @@ +package lkgateway + +import ( + "testing" + "time" +) + +// Строчные события (договорённость с Fancy 18.09.2026): пакет исполняется по бумагам, +// состояние объекта складывается из состояний строк. +func TestOrderWatchLinesPartial(t *testing.T) { + now := time.Now() + w := &OrderWatch{GUID: "G1", Lines: map[string]*OrderLineWatch{ + "cd-sber": {Ref: "R1", ISIN: "RU0009029540", MS010At: now}, // исполнено + "cd-tech": {Ref: "R2", ISIN: "RU000A106YF0", MS010At: now, Rejected: true}, // отказ + "cd-gaz": {Ref: "R3", ISIN: "RU0007661625"}, // ждём + }} + lines := []orderLine{{ContrDID: "cd-sber", Ref: "R1"}, {ContrDID: "cd-tech", Ref: "R2"}, {ContrDID: "cd-gaz", Ref: "R3"}} + + if allLinesMS010(w, lines) { + t.Error("не все строки получили MS010 — объект не должен считаться закрытым") + } + total, executed, rejected, expired := w.linesSummary() + if total != 3 || executed != 1 || rejected != 1 || expired != 0 { + t.Errorf("сводка строк: total=%d executed=%d rejected=%d expired=%d", total, executed, rejected, expired) + } + if got := w.StatusRu(); got != "исполнено частично: 1 из 3 бумаг, отвергнуто 1, ожидаем MS010 по 1" { + t.Errorf("частичный статус: %q", got) + } + if w.Kind() != "warn" { + t.Errorf("частичное исполнение должно быть warn, got %q", w.Kind()) + } + + // все строки закрыты → объект закрыт + w.Lines["cd-gaz"].MS010At = now + if !allLinesMS010(w, lines) { + t.Error("все строки с MS010 — объект должен быть закрыт") + } + // строка, истёкшая по сроку, тоже финальна + if !(&OrderLineWatch{ExpiredAt: now}).lineDone() || (&OrderLineWatch{}).lineDone() || (*OrderLineWatch)(nil).lineDone() { + t.Error("lineDone: истёкшая строка финальна, пустая/nil — нет") + } +} + +// Строки входящего 16/1: новые записи хранят все contr_d_id с REFERENCE; старые — только первый. +func TestIncomingOrderLines(t *testing.T) { + it := &IncomingTransfer{OrderLines: []IncomingOrderLine{ + {ContrDID: "c1", ISIN: "RU0009029540", Ref: "R1"}, + {ContrDID: "c2", ISIN: "RU000A106YF0", Ref: "R2"}, + }} + got := incomingOrderLines(it) + if len(got) != 2 || got[0].ContrDID != "c1" || got[0].Ref != "R1" || got[1].Ref != "R2" { + t.Errorf("строки нового формата: %+v", got) + } + legacy := &IncomingTransfer{OrderContrDID: "c-old"} + if got := incomingOrderLines(legacy); len(got) != 1 || got[0].ContrDID != "c-old" || got[0].Ref != "" { + t.Errorf("старая запись: одна строка без REFERENCE, got %+v", got) + } + if incomingOrderLines(&IncomingTransfer{}) != nil || incomingOrderLines(nil) != nil { + t.Error("без поручений строк нет") + } +} + +// Строки исходящего 16 без репозитория: contr_d_id/ISIN из черновиков, REFERENCE пустой. +func TestOutgoingLineRefsNoRepo(t *testing.T) { + od := &OutgoingDeal{GUID: "G2", Secs: []OutgoingSec{ + {ISIN: "ru0009029540", ContrDID: "cd1"}, + {ISIN: "RU000A106YF0", ContrDID: ""}, // без номера поручения — не подавалась + {ISIN: "RU0007661625", ContrDID: "cd3"}, + }} + got := outgoingLineRefs(t.Context(), nil, od) + if len(got) != 2 || got[0].ContrDID != "cd1" || got[0].ISIN != "RU0009029540" || got[1].ContrDID != "cd3" { + t.Errorf("строки исходящего: %+v", got) + } + if outgoingLineRefs(t.Context(), nil, nil) != nil { + t.Error("nil-сделка → нет строк") + } +} diff --git a/internal/lkgateway/outgoing_order.go b/internal/lkgateway/outgoing_order.go index e1ebda7..31e9ec7 100644 --- a/internal/lkgateway/outgoing_order.go +++ b/internal/lkgateway/outgoing_order.go @@ -43,6 +43,7 @@ type outgoingDeps struct { store *OutgoingStore igwClient *igw.Client igwChan string + svc *Service // сделки M2MTR: REFERENCE бумаг для строчных событий DEPO } // OutgoingSubmit — сохраняемое состояние подачи поручения 16 по одной сделке @@ -332,7 +333,10 @@ func submitOutgoingFor(ctx context.Context, deps outgoingDeps, guid string) (pkg } orderXML = strings.Join(xmls, "\n\n") deps.store.SetSubmitted(guid, contrDIDs, strings.Join(pkgs, ", "), orderXML) - go depoOnOrderSent(globalRC, guid, contrDIDs, "16 (списание)") + // Подача — по строкам (одно поручение 16 = одна бумага): REFERENCE по ISIN из M2MTR. + for _, ln := range outgoingLineRefs(ctx, deps.svc, deal) { + go depoOnOrderLineSent(globalRC, guid, ln.Ref, ln.ContrDID) + } return pkgs, contrDIDs, orderXML, firstErr // firstErr — если часть не ушла } diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 7f9b2c1..804ffe1 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -195,6 +195,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { store: globalOutgoingStore, igwClient: igwClient, igwChan: igwChannel, + svc: svc, // REFERENCE бумаг из M2MTR — для строчных событий DEPO } globalOutgoingDeps = &outDeps // автоматический режим M2M registerOutgoing(mux, adminTpl, outDeps) diff --git a/internal/lkgateway/sqlident.go b/internal/lkgateway/sqlident.go new file mode 100644 index 0000000..32ef123 --- /dev/null +++ b/internal/lkgateway/sqlident.go @@ -0,0 +1,29 @@ +package lkgateway + +// sqlident.go — имена объектов MSSQL из настроек (процедура обработчика DEPO, +// база, логин) параметризовать нельзя, они склеиваются в текст запроса. Чтобы +// значение из формы не превратилось в произвольный T-SQL, перед EXEC имя +// проверяется по строгому шаблону идентификатора. + +import ( + "context" + "database/sql" + "fmt" + "regexp" +) + +// sqlIdentRe — допустимое имя: буквы, цифры, подчёркивание, точка (схема.имя), +// квадратные скобки MSSQL. Ни пробелов, ни кавычек, ни точки с запятой. +var sqlIdentRe = regexp.MustCompile(`^[A-Za-z0-9_.\[\]]{1,128}$`) + +// sqlIdentOK — строка похожа на имя объекта SQL, а не на код. +func sqlIdentOK(s string) bool { return sqlIdentRe.MatchString(s) } + +// execProc — выполнить хранимую процедуру по имени из настроек, предварительно +// проверив имя. Сигнатура как у ExecContext, чтобы подставлять на место "exec "+name. +func execProc(ctx context.Context, db *sql.DB, proc string) (sql.Result, error) { + if !sqlIdentOK(proc) { + return nil, fmt.Errorf("недопустимое имя процедуры %q: разрешены буквы, цифры, _ . [ ]", proc) + } + return db.ExecContext(ctx, "exec "+proc) +}