ЛК-карточка и 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
This commit is contained in:
fontvielle
2026-10-15 17:47:28 +03:00
parent 5beb726e5b
commit c476cfd7a3
12 changed files with 552 additions and 12 deletions
+221 -3
View File
@@ -116,6 +116,16 @@ var quikActionTitles = map[string]string{
"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
@@ -194,13 +204,18 @@ func claimTimelineFull(ctx context.Context, v ClaimView) []TimelineEvent {
// QUIK МНП
for _, l := range globalQuikLinks.ForGUID(guid) {
for _, st := range l.Steps {
kind := "quik"
title := quikActionTitles[st.Action]
if title == "" {
title = "QUIK МНП: " + st.Action
}
kind := "quik"
if !st.OK {
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))
}
@@ -232,7 +247,7 @@ func claimTimelineFull(ctx context.Context, v ClaimView) []TimelineEvent {
switch {
case d.Rejected || d.Code == "REG_REJECT" || d.Code == "ERROR":
kind = "reject"
case d.Code == "GS036":
case d.Code == "GS036" || d.Code == "REG_DUP": // REG_DUP — дубль доставки, не провал
kind = "warn"
case d.Done:
kind = "ok"
@@ -280,3 +295,206 @@ func timelineLast(tl []TimelineEvent) time.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
}