Путь заявки из всех источников: сделка, 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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user