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 }