ЛК-карточка и 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
+68 -3
View File
@@ -512,6 +512,63 @@ type claimData struct {
SubstAs string
Flash string
Fail *SendFailure // последняя неудачная отправка в НРД (sendfail.go)
// SentToNSD — пакет M2MTransferRequest действительно ушёл в НРД (сделка прошла
// стадию submitted_to_nsd). false → заявка остановлена ДО отправки (например,
// отказ на проверке в QUIK МНП): в НРД по ней ничего не отправлялось.
SentToNSD bool
// QuikFailISIN/QuikFailReply — если регистрация «Вывод ЦБ (M2M)» в QUIK МНП не
// прошла (бумаги не удалось заблокировать), это ISIN и дословный ответ модуля QUIK.
// Пусто — такой неудачи нет. QuikNoSec — ответ модуля однозначно про нехватку/
// отсутствие бумаг на счёте клиента (шорт/короткая позиция/недостаточно ЦБ), а не
// про иную причину отказа; только тогда показываем «нет бумаг».
QuikFailISIN string
QuikFailReply string
QuikNoSec bool
}
// quikReplyIsNoSecurities — ответ модуля QUIK об отказе в регистрации означает,
// что на счёте клиента нет/недостаточно бумаг для блокировки (списания). QUIK не
// возвращает остаток, но на попытку заблокировать больше, чем есть, отвечает про
// «шорт»/«короткую позицию»/«недостаточно ЦБ».
func quikReplyIsNoSecurities(reply string) bool {
r := strings.ToLower(reply)
for _, s := range []string{"шорт", "короткая позиц", "недостаточно", "нет бумаг", "нет цб", "не хватает"} {
if strings.Contains(r, s) {
return true
}
}
return false
}
// claimSentToNSD — прошла ли сделка стадию отправки пакета в НРД.
func claimSentToNSD(v ClaimView) bool {
for _, st := range v.Stages {
if st.State == "submitted_to_nsd" {
return true
}
}
return false
}
// quikRegisterFailure — если по заявке была неуспешная регистрация «Вывод ЦБ (M2M)»
// в QUIK МНП (бумаги не заблокированы), возвращает ISIN и дословный ответ модуля.
// Это достоверное подтверждение «на счёте клиента нет/недостаточно бумаг»: проверку
// достаточности делает сам QUIK при sp_NewInstrSec (в БД МНП остатков нет).
func quikRegisterFailure(links []*QuikLink) (isin, reply string) {
for _, l := range links {
if l == nil || (l.State != "error" && l.State != "manual") {
continue
}
for i := len(l.Steps) - 1; i >= 0; i-- {
if st := l.Steps[i]; st.Action == "register" && !st.OK && strings.TrimSpace(st.Reply) != "" {
return l.ISIN, strings.TrimSpace(st.Reply)
}
}
if strings.TrimSpace(l.Err) != "" {
return l.ISIN, strings.TrimSpace(l.Err)
}
}
return "", ""
}
// statusData — данные страницы статуса.
@@ -741,9 +798,14 @@ func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service
http.Error(w, err.Error(), http.StatusNotFound)
return
}
view.Timeline = buildClaimTimeline(r.Context(), view)
if last := timelineLast(view.Timeline); last.After(view.UpdatedAt) {
view.UpdatedAt = last // «Обновлена» — последнее событие по заявке, а не последняя смена состояния сделки
// На экране: краткий операторский путь (ключевые вехи), полный технический —
// под сворачиваемым «Технический журнал». «Обновлена» — по последнему событию
// полного пути (а не последней смене состояния сделки).
full := buildClaimTimeline(r.Context(), view)
view.TimelineTech = full
view.Timeline = claimTimelineOperator(r.Context(), view)
if last := timelineLast(full); last.After(view.UpdatedAt) {
view.UpdatedAt = last
}
var depo *OutgoingDeal
if view.M2MGUID != "" && globalOutgoingStore != nil {
@@ -773,10 +835,13 @@ func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service
substAs = rt.As // в НРД ушли данные тест-инвестора вместо клиента (режим «подмена»)
}
}
qfISIN, qfReply := quikRegisterFailure(quik)
render(w, a.claim, claimData{
Quik: quik, Watch: watch, DepoL: depoL, Fail: fail,
page: nowPage("Заявка", "claims"),
Claim: view, Depo: depo, SubstAs: substAs, Flash: r.URL.Query().Get("flash"),
SentToNSD: claimSentToNSD(view), QuikFailISIN: qfISIN, QuikFailReply: qfReply,
QuikNoSec: quikReplyIsNoSecurities(qfReply),
})
}