Files
Bridge-and-Join-s/internal/lkgateway/claimtimeline.go
T
fontvielle c476cfd7a3 ЛК-карточка и M2M: операторский путь заявки, баннеры «в НРД не отправлено», дубль-поручение, idempotency, фикс DEPO-алертов
- Карточка заявки: операторский «Путь заявки» (claimTimelineOperator) короткими
  понятными вехами + сворачиваемый технический журнал (buildClaimTimeline);
  QUIK показан пошагово (register/sign/deny/deposit) с верными временами, отказ
  регистрации больше не выглядит успехом (quikActionFailTitles).
- Баннеры: «в НРД не отправлено», когда пакет сформирован, но не ушёл (нет
  submitted_to_nsd и не Fail); заголовок карточки и бейдж учитывают это; красный
  баннер QUIK при отказе, строгий «нет бумаг» только при реальном шорт/недостатке.
- НРД-ответ «Поручение с исходящим номером … уже существует» теперь REG_DUP
  (предупреждение-дубль, не красный «Отказ в регистрации»); не влияет на od.Rejected.
- Idempotency: submitguard.go (inflightSubmit) + защита точек подачи от
  двойного срабатывания (manual + auto-loop, двойной клик): submitOutgoingFor,
  submitIncomingOrderFor, QuikOutRegister (ранний возврат при registered/signed).
- DEPO-алерты: водяной знак depoFailAlertedID засеивается текущим MAX(ID) на
  старте процесса (Hlth03) — после рестарта bj больше не поднимает повторно
  алерты про уже известные ошибки обработки событий BJ_*.
- Тесты: claimnotsent_render_test.go (хелперы + рендер карточки).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XG3zNMMf2LMoDpXo22mwMX
2026-10-15 17:47:28 +03:00

501 lines
20 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 МНП: получатель указан",
}
// quikActionFailTitles — заголовки для НЕуспешных шагов QUIK МНП (OK=false),
// чтобы в пути заявки не было ложного «зарегистрирован, заблокировано» при отказе.
var quikActionFailTitles = 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 {
kind := "quik"
title := quikActionTitles[st.Action]
if title == "" {
title = "QUIK МНП: " + st.Action
}
if !st.OK { // провал: не показываем успешный заголовок («зарегистрирован, заблокировано»)
kind = "reject"
if ft := quikActionFailTitles[st.Action]; ft != "" {
title = ft
} else {
title = "QUIK МНП: " + st.Action + " — отклонено"
}
}
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" || d.Code == "REG_DUP": // REG_DUP — дубль доставки, не провал
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
}
// claimTimelineOperator — КРАТКИЙ путь заявки для оператора: только ключевые вехи
// простым языком, по одной на этап, без технической «кухни» (события FTV/Fancy,
// дубли QUIK, служебные коды FSM вроде manual_reject:QUIK). Оператору нужно быстро
// понять, что произошло и какие этапы пройдены. Полный технический путь —
// claimTimelineFull (в карточке под сворачиваемым «Технический журнал»).
func claimTimelineOperator(ctx context.Context, v ClaimView) []TimelineEvent {
guid := string(v.M2MGUID)
var items []tlItem
add := func(at time.Time, title, kind, detail string) {
if title == "" {
return
}
items = append(items, tlItem{at: at, title: title, kind: kind, detail: detail})
}
stageAt := func(state string) (time.Time, bool) {
for _, s := range v.Stages {
if s.State == state {
return s.EnteredAt, true
}
}
return time.Time{}, false
}
// шаги ЛК: первый («заявка получена») и последний статус для клиента
var lkFirst, lkStatus *LKFlowStep
if guid != "" {
if f := globalLKFlows.Get(guid); f != nil {
for i := range f.Steps {
st := &f.Steps[i]
if lkFirst == nil && strings.HasPrefix(st.Title, "Заявка из") {
lkFirst = st
}
if strings.Contains(st.Title, "Статус в ЛК") {
lkStatus = st
}
}
}
}
// 1) заявка получена
if lkFirst != nil {
add(lkFirst.At, "Заявка получена", "db", lkFirst.Detail)
} else {
src := "Создана в bj."
if guid != "" {
if lkID, ok := LKClaimIDByGUID(guid); ok {
src = fmt.Sprintf("Из личного кабинета ЕСИА Финанс (заявка #%d).", lkID)
}
}
add(v.CreatedAt, "Заявка получена", "db", src)
}
// 2) проверка перед исполнением
if t, ok := stageAt("validated"); ok {
add(t, "Проверка пройдена — можно исполнять", "ok", "")
}
// 3) QUIK МНП — по шагам (блокировка, затем списание) с их реальным временем,
// а не схлопнутая строка по финальному состоянию (иначе «списано» встало бы на
// время регистрации и раньше отправки в НРД). По одному шагу каждого действия.
quikFailed := false
for _, l := range globalQuikLinks.ForGUID(guid) {
if l == nil {
continue
}
qty := fmt.Sprintf("%s × %v.", l.ISIN, l.Qty)
var reg, sign, deny, dep *QuikStep
for i := range l.Steps {
switch l.Steps[i].Action {
case "register":
reg = &l.Steps[i]
case "sign":
sign = &l.Steps[i]
case "deny":
deny = &l.Steps[i]
case "deposit":
dep = &l.Steps[i]
}
}
if reg != nil {
if reg.OK {
add(reg.At, "Бумаги заблокированы в QUIK", "quik", qty)
} else {
quikFailed = true
reason := strings.TrimSpace(reg.replyOr(l.Err))
d := qty
if quikReplyIsNoSecurities(reason) {
d = "На счёте клиента нет бумаг для списания. " + qty
}
if reason != "" {
d += " Ответ QUIK: " + reason
}
add(reg.At, "Бумаги не заблокированы в QUIK", "reject", d)
}
}
if sign != nil && sign.OK {
add(sign.At, "Бумаги списаны в QUIK", "quik", qty)
}
if deny != nil {
add(deny.At, "Блокировка в QUIK снята", "db", qty)
}
if dep != nil && dep.OK {
add(dep.At, "Бумаги зачислены в QUIK", "quik", qty)
}
}
// 4) отправлено в НРД
if t, ok := stageAt("submitted_to_nsd"); ok {
add(t, "Отправлено в НРД", "gateway", "Запрос перевода подписан ИШ, НРД принял пакет.")
} else if f := sendFailGet(guid); f != nil && !quikFailed {
add(f.At, "НРД не принял пакет", "reject", sendFailText(f))
}
// 5) решение контрагента / сервисный ответ НРД
for _, s := range v.Stages {
switch s.State {
case "confirmed":
add(s.EnteredAt, "Контрагент согласился на перевод", "response", "")
case "timed_out":
add(s.EnteredAt, "Истёк срок ответа контрагента", "reject", s.Reason)
}
}
if v.M2MResponse != nil && v.M2MResponse.StatusCode == "ERROR" {
add(v.UpdatedAt, "НРД отклонил заявку", "reject", "Сервис МОСТ вернул ERROR — подробности в карточке ответа ниже.")
}
// 6) списание: поручение 16 → исполнение (MS010). Время итога берём из самого
// отчёта НРД (MS010), а не из времени подачи. Дубли доставки (REG_DUP) и
// промежуточные уведомления (GS036) в операторский путь не выводим.
if guid != "" && globalOutgoingStore != nil {
if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok {
if od.Submitted {
add(od.OrderAt, "Поручение на списание подано в НРД", "gateway", "")
}
// время итога — из самого отчёта MS010; исход (исполнено/отказ) — из od
// (orderRespVerdict по тексту MS010), а не из времени подачи.
var ms010At time.Time
for _, dc := range nsdOrderDocs(append(append([]string{}, od.ContrDIDs...), orderWatchRefs(guid)...)...) {
if dc.Code == "MS010" && dc.Done {
ms010At = dc.Time
}
}
switch {
case od.Done && od.Rejected:
add(ms010At, "НРД отказал в списании", "reject", "Отчёт MS010 — операция не исполнена.")
case od.Done:
add(ms010At, "Списание проведено — бумаги переданы контрагенту", "ok", "Отчёт НРД MS010.")
}
}
}
// 7) чем закончилось на проверке (если отклонена до QUIK/НРД и это нигде не отражено)
if v.Status == "rejected" && !quikFailed && v.M2MResponse == nil {
if _, sent := stageAt("submitted_to_nsd"); !sent {
if t, ok := stageAt("rejected"); ok {
add(t, "Отклонена при проверке", "reject", operatorReason(rejectStageReason(v)))
}
}
}
// 8) итог для клиента в ЛК
if lkStatus != nil {
add(lkStatus.At, lkStatus.Title, lkStatus.Kind, lkStatus.Detail)
}
if t, ok := stageAt("done"); ok {
add(t, "Перевод исполнен", "ok", "")
}
return timelineFinish(items, v)
}
// replyOr — текст ответа шага, либо запасной.
func (s *QuikStep) replyOr(fallback string) string {
if s != nil && strings.TrimSpace(s.Reply) != "" {
return s.Reply
}
return fallback
}
// rejectStageReason — причина из стадии rejected (служебная строка FSM).
func rejectStageReason(v ClaimView) string {
for _, s := range v.Stages {
if s.State == "rejected" {
return s.Reason
}
}
return ""
}
// operatorReason — служебную причину FSM в человеческий вид для оператора.
func operatorReason(r string) string {
r = strings.TrimSpace(r)
switch {
case r == "":
return ""
case strings.HasPrefix(r, "manual_reject:"):
return "отклонено оператором/проверкой (" + strings.TrimPrefix(r, "manual_reject:") + ")"
case strings.HasPrefix(r, "service_error:"):
return "ошибка сервиса НРД: " + m2mCodeText(strings.TrimPrefix(r, "service_error:"))
}
return r
}