Files
Bridge-and-Join-s/internal/lkgateway/claimtimeline.go
T

283 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
if f := globalLKFlows.Get(guid); guid != "" && f != nil && len(f.Steps) > 0 {
for _, st := range f.Steps { // путь заявки ЛК: забрана из кабинета, проверка шлюза, подмена, статусы в ЛК
add(st.At, st.Title, st.Kind, st.Detail)
}
} else {
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
}