ЛК-карточка и 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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user