Путь заявки из всех источников: сделка, QUIK, события DEPO (с обработкой/ошибкой), поручение 16 и документы НРД (регистрация, GS036, GS116 сквитовано, MS010); «Обновлена» — последнее событие; статус учитывает сквитовку (GS116), время MS010 в строке статуса

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-29 12:44:52 +03:00
parent e123eda691
commit 340fca7bd7
7 changed files with 405 additions and 46 deletions
+276
View File
@@ -0,0 +1,276 @@
package lkgateway
// Путь заявки (исходящий перевод) во времени — из всех источников, а не только из состояний сделки:
// сделка M2M (создание, отправка, решение контрагента), блокировка/подпись в QUIK МНП, события обмена
// с DEPO (FTV_M2M_EVENTS: время, обработано/ошибка), поручение 16 и все документы НРД по нему
// (регистрация, «нет встречного поручения» GS036, исполнение MS010), недоставленные отправки.
// Шаги сортируются по времени, между соседними — длительность. Последним — чего ждём сейчас.
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"time"
)
var mskZone = time.FixedZone("МСК", 3*3600)
type tlItem struct {
at time.Time
title, kind, detail string
}
// depoEventRow — строка FTV_M2M_EVENTS по переводу.
type depoEventRow struct {
At time.Time
Type string
Ref string
State string
Comment string
}
// depoEventsFor — события обмена с DEPO по переводу (EVENT_DATE пишется временем МСК без зоны).
func depoEventsFor(ctx context.Context, rc *RuntimeConfig, guid string) []depoEventRow {
if rc == nil || guid == "" {
return nil
}
db, err := depoEventsDB(rc)
if err != nil {
return nil
}
defer db.Close()
cctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
rows, err := db.QueryContext(cctx, `SELECT EVENT_TYPE, EVENT_DATE, ISNULL(REFERENCE,''), ISNULL(STATE,''), ISNULL(COMMENT,'')
FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g ORDER BY EVENT_DATE`, sql.Named("g", strings.ToUpper(guid)))
if err != nil {
return nil
}
defer rows.Close()
var out []depoEventRow
for rows.Next() {
var e depoEventRow
var at time.Time
if rows.Scan(&e.Type, &at, &e.Ref, &e.State, &e.Comment) != nil {
continue
}
// datetime без зоны: драйвер отдаёт стенные часы в UTC — это время МСК
e.At = time.Date(at.Year(), at.Month(), at.Day(), at.Hour(), at.Minute(), at.Second(), at.Nanosecond(), mskZone)
out = append(out, e)
}
return out
}
var depoEventTitles = map[string]string{
"BJ_SENT": "DEPO: перевод отправлен в НРД",
"BJ_GATEWAY_REJECT": "DEPO: заявка отклонена шлюзом bj",
"BJ_SERVICE_ERROR": "DEPO: сервис M2M отверг перевод",
"BJ_CP_CONFIRM": "DEPO: контрагент согласился",
"BJ_CP_REJECT": "DEPO: контрагент отказал",
"BJ_ORDER_SENT": "DEPO: поручение 16 подано в НРД",
"BJ_ORDER_LATE": "DEPO: поручение не исполнено за 5 минут",
"BJ_ORDER_ACCEPTED": "DEPO: НРД исполнил поручение (MS010)",
"BJ_ORDER_REJECTED": "DEPO: НРД отверг поручение (MS010)",
"BJ_EXECUTED": "DEPO: перевод исполнен",
"BJ_CANCELLED": "DEPO: поручение отменено (срок 30 дней)",
"BJ_QUIK_BLOCKED": "DEPO: бумаги заблокированы в QUIK",
"BJ_QUIK_SIGNED": "DEPO: поручение подписано в QUIK",
"BJ_QUIK_RELEASED": "DEPO: блокировка в QUIK снята",
"BJ_QUIK_DEPOSITED": "DEPO: бумаги зачислены в QUIK",
"BJ_RECEIVED": "DEPO: входящий перевод получен",
"BJ_DECISION_CONFIRM": "DEPO: наше согласие отправлено",
"BJ_DECISION_REJECT": "DEPO: наш отказ отправлен",
}
func depoEventDetail(e depoEventRow) (string, string) {
kind := "db"
var parts []string
if e.Ref != "" {
parts = append(parts, "строка "+e.Ref)
}
switch strings.ToUpper(e.State) {
case "ERROR":
kind = "reject"
c := e.Comment
if i := strings.Index(c, "//"); i > 0 { // служебный хвост обработчика DEPO
c = c[:i]
}
parts = append(parts, "ошибка обработки в DEPO: "+strings.TrimSpace(c))
case "DONE", "OK", "SUCCESS":
parts = append(parts, "обработано DEPO")
case "", "TODO", "NEW":
parts = append(parts, "ждёт обработки DEPO")
default:
parts = append(parts, "DEPO: "+e.State)
}
return strings.Join(parts, " · "), kind
}
var quikActionTitles = map[string]string{
"register": "QUIK МНП: вывод зарегистрирован, бумаги заблокированы",
"sign": "QUIK МНП: поручение подписано, бумаги списаны",
"deny": "QUIK МНП: поручение снято, блокировка снята",
"deposit": "QUIK МНП: бумаги зачислены",
"recipient": "QUIK МНП: получатель указан",
}
func claimTimelineFull(ctx context.Context, v ClaimView) []TimelineEvent {
guid := string(v.M2MGUID)
var items []tlItem
add := func(at time.Time, title, kind, detail string) {
items = append(items, tlItem{at: at, title: title, kind: kind, detail: detail})
}
// создание и откуда пришла
origin := "Перевод M2M создан в bj."
if guid != "" {
if lkID, ok := LKClaimIDByGUID(guid); ok {
origin = fmt.Sprintf("Заявка из личного кабинета ЕСИА Финанс (заявка ЛК #%d).", lkID)
}
}
add(v.CreatedAt, "Заявка создана", "db", origin)
// состояния сделки M2M
decided := false
for _, st := range v.Stages {
switch st.State {
case "submitted_to_nsd":
add(st.EnteredAt, "Запрос перевода отправлен в НРД", "gateway", "M2MTransferRequest подписан шлюзом, НРД принял пакет.")
case "manual_approval":
add(st.EnteredAt, "Передана на решение оператора", "warn", st.Reason)
case "confirmed", "rejected":
decided = true
title, kind := "Контрагент согласился на перевод", "response"
if st.State == "rejected" {
title, kind = "Перевод отклонён", "reject"
}
detail := st.Reason
if v.M2MDecision != nil {
conf, rej := 0, 0
for _, s := range v.M2MDecision.Securities {
if s.Outcome == "confirmed" {
conf++
} else {
rej++
}
}
detail = fmt.Sprintf("Решение контрагента (M2MTransferDecision): принято бумаг %d, отклонено %d.", conf, rej)
} else if v.M2MResponse != nil {
var codes []string
for _, e := range v.M2MResponse.Responses {
if e.Code != "" {
codes = append(codes, e.Code)
}
}
detail = "Ответ сервиса M2M: " + v.M2MResponse.StatusCode + " " + strings.Join(codes, " ")
}
add(st.EnteredAt, title, kind, strings.TrimSpace(detail))
case "timed_out":
add(st.EnteredAt, "Истёк срок ответа контрагента", "reject", st.Reason)
case "done":
add(st.EnteredAt, "Перевод завершён", "ok", "")
}
}
if !decided && v.M2MResponse != nil { // ответ сервиса M2M без смены состояния
add(v.UpdatedAt, "Ответ сервиса M2M (НРД)", "response", "Статус: "+v.M2MResponse.StatusCode)
}
if guid == "" {
return timelineFinish(items, v)
}
// недоставленная отправка
if f := sendFailGet(guid); f != nil {
add(f.At, "НРД не принял пакет", "reject", fmt.Sprintf("%s. Попыток: %d; повтор — автоматически, когда НРД ответит.", sendFailText(f), f.Attempts))
}
// QUIK МНП
for _, l := range globalQuikLinks.ForGUID(guid) {
for _, st := range l.Steps {
title := quikActionTitles[st.Action]
if title == "" {
title = "QUIK МНП: " + st.Action
}
kind := "quik"
if !st.OK {
kind = "reject"
}
add(st.At, title, kind, fmt.Sprintf("%s × %v. %s", l.ISIN, l.Qty, st.Reply))
}
}
// DEPO: номер поручения и события обмена
if dl := globalDepoLinks.Get(guid); dl != nil && dl.DocNo != "" {
add(dl.DocAt, "DEPO создала поручение №"+dl.DocNo, "db", "Поручение в учёте депозитария.")
}
for _, e := range depoEventsFor(ctx, globalRC, guid) {
title := depoEventTitles[e.Type]
if title == "" {
title = "DEPO: " + e.Type
}
detail, kind := depoEventDetail(e)
add(e.At, title, kind, detail)
}
// поручение 16 и документы НРД по нему
if globalOutgoingStore != nil {
if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok {
if !od.DraftAt.IsZero() {
add(od.DraftAt, "НРД прислал черновик поручения 16", "response", fmt.Sprintf("Черновик MF010 по %d бумагам.", len(od.Secs)))
}
if od.Submitted {
add(od.OrderAt, "Поручение 16 подано в НРД", "gateway", "Пакеты ИШ "+od.Pkgs+", номера поручений "+strings.Join(od.ContrDIDs, ", ")+".")
for _, d := range nsdOrderDocs(append(append([]string{}, od.ContrDIDs...), orderWatchRefs(guid)...)...) {
kind := "response"
switch {
case d.Rejected || d.Code == "REG_REJECT" || d.Code == "ERROR":
kind = "reject"
case d.Code == "GS036":
kind = "warn"
case d.Done:
kind = "ok"
}
add(d.Time, d.Title, kind, d.About)
}
}
}
}
if w := globalOrderWatch.Get(guid); w != nil && !w.LateAt.IsZero() {
add(w.LateAt, "Нет отчёта MS010 за 5 минут", "warn", "Регламентный срок исполнения прошёл; ожидаем исполнения до "+w.OrderAt.Add(orderExecWithin).In(mskZone).Format("02.01.2006")+".")
}
return timelineFinish(items, v)
}
// timelineFinish — сортировка по времени, длительности между шагами и «чего ждём» последним.
func timelineFinish(items []tlItem, v ClaimView) []TimelineEvent {
sort.SliceStable(items, func(i, j int) bool { return items[i].at.Before(items[j].at) })
var out []TimelineEvent
var prev time.Time
for _, it := range items {
if it.at.IsZero() {
continue
}
ev := TimelineEvent{Title: it.title, Kind: it.kind, Detail: it.detail, Time: it.at.In(mskZone)}
if !prev.IsZero() && it.at.After(prev) {
ev.Since = formatSince(it.at.Sub(prev))
}
out = append(out, ev)
prev = it.at
}
if l := liveOutgoingState(v); l.Active {
out = append(out, TimelineEvent{Title: "Сейчас: " + l.Text, Kind: "wait"})
}
return out
}
// timelineLast — время последнего шага (для «Обновлена»).
func timelineLast(tl []TimelineEvent) time.Time {
var last time.Time
for _, e := range tl {
if e.Time.After(last) {
last = e.Time
}
}
return last
}