diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 307d53a..73883a4 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -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), }) } diff --git a/internal/lkgateway/claimnotsent_render_test.go b/internal/lkgateway/claimnotsent_render_test.go new file mode 100644 index 0000000..950a956 --- /dev/null +++ b/internal/lkgateway/claimnotsent_render_test.go @@ -0,0 +1,100 @@ +package lkgateway + +import ( + "bytes" + "strings" + "testing" + "time" +) + +// buildClaimDataFromView повторяет вычисления renderClaim для теста. +func buildClaimDataFromView(v ClaimView, quik []*QuikLink, fail *SendFailure) claimData { + qfISIN, qfReply := quikRegisterFailure(quik) + return claimData{ + page: page{Title: "Заявка"}, + Claim: v, Quik: quik, Fail: fail, + SentToNSD: claimSentToNSD(v), QuikFailISIN: qfISIN, QuikFailReply: qfReply, + QuikNoSec: quikReplyIsNoSecurities(qfReply), + } +} + +func TestClaimHelpers(t *testing.T) { + sent := ClaimView{Stages: []StageView{{State: "draft"}, {State: "validated"}, {State: "submitted_to_nsd"}, {State: "awaiting_decision"}}} + if !claimSentToNSD(sent) { + t.Fatal("submitted_to_nsd должна считаться отправкой в НРД") + } + notSent := ClaimView{Stages: []StageView{{State: "draft"}, {State: "validated"}, {State: "rejected"}}} + if claimSentToNSD(notSent) { + t.Fatal("validated→rejected без submitted_to_nsd — в НРД не отправлялось") + } + for _, r := range []string{"Данный инструмент запрещен для операции шорт", "Недостаточно ценных бумаг", "короткая позиция"} { + if !quikReplyIsNoSecurities(r) { + t.Fatalf("ответ %q должен распознаваться как нехватка бумаг", r) + } + } + if quikReplyIsNoSecurities("Неизвестный депозитарий получателя") { + t.Fatal("ошибка про депозитарий — не нехватка бумаг") + } + links := []*QuikLink{{GUID: "g", ISIN: "RU0009029540", State: "error", + Err: "МНП отверг регистрацию: Данный инструмент запрещен для операции шорт", + Steps: []QuikStep{{Action: "register", Reply: "Данный инструмент запрещен для операции шорт", OK: false, At: time.Now()}}}} + isin, reply := quikRegisterFailure(links) + if isin != "RU0009029540" || !strings.Contains(reply, "шорт") { + t.Fatalf("quikRegisterFailure вернул %q / %q", isin, reply) + } +} + +// Отклонена до отправки + QUIK сообщил о нехватке бумаг (как e3607d2a). +func TestClaimRenderNotSentNoSec(t *testing.T) { + a, err := newAdmin() + if err != nil { + t.Fatal(err) + } + v := ClaimView{ID: "e3607d2a-2608-441b-b49a-e99ef3e4edd1", Status: "rejected", + RequestRawXML: "", + Stages: []StageView{{State: "draft"}, {State: "validated"}, {State: "rejected"}}} + quik := []*QuikLink{{GUID: "67f09cec", ISIN: "RU0009029540", State: "error", + Err: "МНП отверг регистрацию: Данный инструмент запрещен для операции шорт", + Steps: []QuikStep{{Action: "register", Reply: "Данный инструмент запрещен для операции шорт", OK: false, At: time.Now()}}}} + var b bytes.Buffer + if err := a.claim.ExecuteTemplate(&b, "layout", buildClaimDataFromView(v, quik, nil)); err != nil { + t.Fatal(err) + } + s := b.String() + for _, want := range []string{ + "В НРД ничего не отправлено", // баннер отмены до отправки + "Сформированный пакет (M2MTransferRequest)", // заголовок карточки + "в НРД не отправлен", // бейдж + "нет (недостаточно) бумаг", // карточка QUIK: нехватка + "Данный инструмент запрещен для операции шорт", // дословный ответ QUIK + } { + if !strings.Contains(s, want) { + t.Fatalf("нет %q", want) + } + } + if strings.Contains(s, "Отправленный пакет (M2MTransferRequest)") { + t.Fatal("для неотправленной заявки не должно быть заголовка «Отправленный пакет»") + } +} + +// Нормально отправленная заявка — старый заголовок сохраняется, баннеров нет. +func TestClaimRenderSentOK(t *testing.T) { + a, err := newAdmin() + if err != nil { + t.Fatal(err) + } + v := ClaimView{ID: "11111111-2222-3333-4444-555555555555", Status: "awaiting_decision", + RequestRawXML: "", + Stages: []StageView{{State: "draft"}, {State: "validated"}, {State: "submitted_to_nsd"}, {State: "awaiting_decision"}}} + var b bytes.Buffer + if err := a.claim.ExecuteTemplate(&b, "layout", buildClaimDataFromView(v, nil, nil)); err != nil { + t.Fatal(err) + } + s := b.String() + if !strings.Contains(s, "Отправленный пакет (M2MTransferRequest)") { + t.Fatal("отправленная заявка должна показывать «Отправленный пакет»") + } + if strings.Contains(s, "В НРД ничего не отправлено") || strings.Contains(s, "в НРД не отправлен") { + t.Fatal("для отправленной заявки баннеры «не отправлено» показывать нельзя") + } +} diff --git a/internal/lkgateway/claimtimeline.go b/internal/lkgateway/claimtimeline.go index 43ea8d1..0ca1fff 100644 --- a/internal/lkgateway/claimtimeline.go +++ b/internal/lkgateway/claimtimeline.go @@ -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 +} diff --git a/internal/lkgateway/depohealth.go b/internal/lkgateway/depohealth.go index 51d482d..9ad565d 100644 --- a/internal/lkgateway/depohealth.go +++ b/internal/lkgateway/depohealth.go @@ -160,11 +160,27 @@ func depoDismissFailures(ctx context.Context, db *sql.DB, days int) (orders, eve return orders, events, nil } -var depoFailAlertedID int64 +var ( + depoFailAlertedID int64 + depoFailSeeded bool // водяной знак засеян текущим максимумом на старте процесса +) // depoAlertEventFailures — оповещение о новых ошибках обработки событий BJ_* (поручения с // ошибкой оповещает depoPollOrders). +// +// Водяной знак depoFailAlertedID — в памяти процесса. Чтобы при каждом РЕСТАРТЕ bj не +// поднимать повторно алерты про уже известные (и, как правило, уже разобранные) ошибки, +// на первом проходе засеиваем знак текущим максимумом ID событий BJ_*: алертим только про +// НОВЫЕ ошибки, появившиеся уже после запуска. (События BJ_* пишет сам bj, пока он лежит — +// новых не появляется, поэтому ничего не пропускаем.) func depoAlertEventFailures(ctx context.Context, db *sql.DB, rc *RuntimeConfig) { + if !depoFailSeeded { + var mx sql.NullInt64 + if e := db.QueryRowContext(ctx, fsql.Hlth03).Scan(&mx); e == nil && mx.Int64 > depoFailAlertedID { + depoFailAlertedID = mx.Int64 + } + depoFailSeeded = true + } var id sql.NullInt64 var typ, guid, comment string var n int diff --git a/internal/lkgateway/fsql/depohealth.go b/internal/lkgateway/fsql/depohealth.go index cb29b56..3d4c902 100644 --- a/internal/lkgateway/fsql/depohealth.go +++ b/internal/lkgateway/fsql/depohealth.go @@ -11,3 +11,7 @@ var Hlth01 = `SELECT TOP (@n) * FROM ( var Hlth02 = `SELECT COUNT(*) OVER (), ID, EVENT_TYPE, CAST(GUID AS varchar(36)), ISNULL(COMMENT,'') FROM dbo.FTV_M2M_EVENTS WHERE STATE='ERROR' AND EVENT_TYPE LIKE 'BJ[_]%' AND ID > @last AND ISNULL(TimeEnd, TimeNew) > DATEADD(day, -1, GETDATE()) ORDER BY ID DESC OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY` + +// Hlth03 — текущий максимум ID событий BJ_* (для засева водяного знака алертов на +// старте процесса: не поднимать повторно уже известные ошибки после рестарта bj). +var Hlth03 = `SELECT ISNULL(MAX(ID),0) FROM dbo.FTV_M2M_EVENTS WHERE EVENT_TYPE LIKE 'BJ[_]%'` diff --git a/internal/lkgateway/incoming_order.go b/internal/lkgateway/incoming_order.go index 9123d11..f822263 100644 --- a/internal/lkgateway/incoming_order.go +++ b/internal/lkgateway/incoming_order.go @@ -86,6 +86,15 @@ func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *Incoming if deps.igwClient == nil || deps.igwChan == "" { return nil, "", fmt.Errorf("ИШ не настроен — нельзя отправить поручение") } + // защита от параллельной/повторной подачи (авто-поллер + ручная кнопка/двойной клик) + if !beginSubmit("in:" + it.ID) { + return nil, "", fmt.Errorf("поручение 16/1 по этому переводу уже отправляется (параллельный запрос) — повторная подача отменена") + } + defer endSubmit("in:" + it.ID) + if cur, ok := deps.store.Get(it.ID); ok && cur.OrderSubmitted && strings.TrimSpace(cur.OrderPkgs) != "" { + // уже подано ранее — в НРД повторно НЕ шлём (идемпотентность) + return strings.Split(cur.OrderPkgs, ", "), "", nil + } // только .xml (без .SGN) — подпись НРД нам не нужна, ИШ подпишет своей ГОСТ. var xmls []string for _, p := range findMF010Files(it.GUID) { diff --git a/internal/lkgateway/nsddocs.go b/internal/lkgateway/nsddocs.go index 2da2fc8..e195121 100644 --- a/internal/lkgateway/nsddocs.go +++ b/internal/lkgateway/nsddocs.go @@ -152,9 +152,19 @@ func classifyNSDDoc(pkg, body string) nsdDoc { case strings.Contains(body, "") || strings.Contains(body, ""): abort, cause := xmlTag(body, "abort_code"), xmlTag(body, "abort_cause") if abort != "" || cause != "" { - d.Code, d.Rejected = "REG_REJECT", true - d.Title = "Отказ в регистрации поручения" - d.About = "НРД не принял поручение к исполнению: " + strings.TrimSpace(abort+" "+cause) + "." + if isDuplicateOrderAbort(cause) { + // НРД отклонил ПОВТОРНУЮ доставку того же поручения по исходящему + // номеру — защита от двойного исполнения (идемпотентность). Исходный + // экземпляр принят отдельной квитанцией; на статус перевода НЕ влияет, + // поэтому это НЕ отказ (Rejected=false), а информ* уведомление. + d.Code = "REG_DUP" + d.Title = "Повторная доставка поручения отклонена (дубликат)" + d.About = "НРД отклонил повторную доставку того же поручения по исходящему номеру (защита от двойного исполнения). Исходный экземпляр зарегистрирован и обрабатывается — на перевод не влияет." + } else { + d.Code, d.Rejected = "REG_REJECT", true + d.Title = "Отказ в регистрации поручения" + d.About = "НРД не принял поручение к исполнению: " + strings.TrimSpace(abort+" "+cause) + "." + } } else { d.Code = "REG" d.Title = "Квитанция о регистрации поручения" @@ -361,3 +371,11 @@ func htmlToPDF(ctx context.Context, page []byte) ([]byte, error) { } return os.ReadFile(out) } + +// isDuplicateOrderAbort — причина отказа НРД означает повторную доставку того же +// поручения по исходящему номеру (НРД отсекает дубль — защита от двойного +// исполнения), а не содержательный отказ. Такой ответ не влияет на статус перевода. +func isDuplicateOrderAbort(cause string) bool { + c := strings.ToLower(cause) + return strings.Contains(c, "уже существ") +} diff --git a/internal/lkgateway/outgoing_order.go b/internal/lkgateway/outgoing_order.go index b5c1312..6f1a69c 100644 --- a/internal/lkgateway/outgoing_order.go +++ b/internal/lkgateway/outgoing_order.go @@ -299,6 +299,15 @@ func submitOutgoingFor(ctx context.Context, deps outgoingDeps, guid string) (pkg if deps.igwClient == nil || deps.igwChan == "" { return nil, nil, "", fmt.Errorf("ИШ не настроен") } + // защита от параллельной/повторной подачи (авто-поллер + ручная кнопка/двойной клик) + if !beginSubmit("out:" + guid) { + return nil, nil, "", fmt.Errorf("поручение 16 по этой сделке уже отправляется (параллельный запрос) — повторная подача отменена") + } + defer endSubmit("out:" + guid) + if sub, ok := deps.store.Get(guid); ok && strings.TrimSpace(sub.Pkgs) != "" { + // уже подано ранее — в НРД повторно НЕ шлём (идемпотентность), отдаём прежний результат + return strings.Split(sub.Pkgs, ", "), sub.ContrDIDs, sub.OrderXML, nil + } deal, ok := scanForm16Deal(deps.store, guid) if !ok || len(deal.Secs) == 0 { return nil, nil, "", fmt.Errorf("черновик формы 16 по этой сделке в INBOX не найден") diff --git a/internal/lkgateway/quikcycle.go b/internal/lkgateway/quikcycle.go index aac07ea..c8c80da 100644 --- a/internal/lkgateway/quikcycle.go +++ b/internal/lkgateway/quikcycle.go @@ -548,6 +548,20 @@ func QuikOutRegister(ctx context.Context, rc *RuntimeConfig, guid, clientID, con l := globalQuikLinks.update(guid, isin, func(l *QuikLink) { l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "out", clientID, contract, isin, qty }) + // идемпотентность: уже зарегистрировано/подписано — повторно НЕ блокируем + // (как у QuikOutSign/Deny/Deposit). Иначе двойной клик/stale-страница создадут + // второе поручение «Вывод ЦБ (M2M)» и двойную блокировку. + if l != nil && l.InstrNo != "" && (l.State == "registered" || l.State == "signed") { + return l, nil + } + // защита от ПАРАЛЛЕЛЬНОЙ регистрации того же (guid,isin) до записи состояния + if !beginSubmit("quikreg:" + guid + ":" + isin) { + return l, fmt.Errorf("регистрация «Вывод ЦБ (M2M)» по этой бумаге уже выполняется (параллельный запрос)") + } + defer endSubmit("quikreg:" + guid + ":" + isin) + if cur := globalQuikLinks.Get(guid, isin); cur != nil && cur.InstrNo != "" && (cur.State == "registered" || cur.State == "signed") { + return cur, nil // успели зарегистрировать параллельно + } if s.Emulate { return quikEmulated(ctx, rc, guid, isin, "register", "registered", "вывод зарегистрирован, бумаги заблокированы") } diff --git a/internal/lkgateway/submitguard.go b/internal/lkgateway/submitguard.go new file mode 100644 index 0000000..586c6d8 --- /dev/null +++ b/internal/lkgateway/submitguard.go @@ -0,0 +1,40 @@ +package lkgateway + +import "sync" + +// submitguard.go — защита от повторной/параллельной отправки одного и того же +// поручения в НРД. Триггеры подачи независимы (авто-поллер m2mauto + ручная +// кнопка в карточке + возможный двойной клик), а точка отправки одна. НРД и так +// отсекает дубль по исходящему номеру (идемпотентность на стороне НРД — см. +// isDuplicateOrderAbort), но полагаться только на это нельзя: дубль поднимает +// лишние документы и путает оператора. Поэтому защищаемся и на стороне bj. +// +// beginSubmit даёт атомарный «захват» по ключу: пока один вызов шлёт поручение, +// параллельный получит false и повторно отправлять не станет. После отправки +// (успех или ошибка) ключ освобождается endSubmit — поэтому законный повтор +// после ошибки работает. Идемпотентность «после УСПЕШНОЙ подачи» обеспечивается +// отдельно — проверкой сохранённого факта подачи в сторе (в submitOutgoingFor / +// submitIncomingOrderFor): если поручение уже подано, повторно в НРД не шлём. +var inflightSubmit = struct { + mu sync.Mutex + set map[string]bool +}{set: map[string]bool{}} + +// beginSubmit пытается захватить ключ. true — захвачено (можно слать); false — +// по этому ключу уже идёт отправка в другом вызове. +func beginSubmit(key string) bool { + inflightSubmit.mu.Lock() + defer inflightSubmit.mu.Unlock() + if inflightSubmit.set[key] { + return false + } + inflightSubmit.set[key] = true + return true +} + +// endSubmit освобождает ключ (вызывать через defer сразу после beginSubmit). +func endSubmit(key string) { + inflightSubmit.mu.Lock() + delete(inflightSubmit.set, key) + inflightSubmit.mu.Unlock() +} diff --git a/internal/lkgateway/types.go b/internal/lkgateway/types.go index f897772..f88803b 100644 --- a/internal/lkgateway/types.go +++ b/internal/lkgateway/types.go @@ -153,7 +153,12 @@ type ClaimView struct { TestResult *TestResultView `json:"test_result,omitempty"` // Timeline — путь заявки во времени: положена в БД Fancy → вердикт «крана» // → отправлена в шлюз НРД → ответ НРД. С длительностями между этапами. + // На экране карточки это КРАТКИЙ операторский путь (claimTimelineOperator); + // в выгрузках и JSON — полный (buildClaimTimeline). Timeline []TimelineEvent `json:"timeline,omitempty"` + // TimelineTech — полный технический путь (все события: FTV/Fancy, QUIK, + // поручение 16). Показывается в карточке под сворачиваемым «Технический журнал». + TimelineTech []TimelineEvent `json:"timeline_tech,omitempty"` } // TimelineEvent — один шаг в истории движения заявки. diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html index d334b62..e833883 100644 --- a/internal/lkgateway/web/templates/admin_claim.html +++ b/internal/lkgateway/web/templates/admin_claim.html @@ -25,6 +25,11 @@ .tl-quik{--c:#b45309} .tl-ok{--c:#16a34a} .tl-warn{--c:#d97706} .tl-wait{--c:#94a3b8} /* нет места справа (узкие/средние экраны) — таймлайн в потоке под контентом */ @media (max-width:1640px){ .claim-tl{ position:static; width:auto; max-height:none; margin-top:16px; box-shadow:none } } + .tl-tech{ margin-top:12px; border-top:1px dashed var(--border,#e3e3e8); padding-top:8px } + .tl-tech>summary{ cursor:pointer; font-size:11px; color:var(--muted,#888); list-style:none; user-select:none } + .tl-tech>summary::-webkit-details-marker{ display:none } + .tl-tech>summary::before{ content:'▸ '; } + .tl-tech[open]>summary::before{ content:'▾ '; }
@@ -40,6 +45,10 @@ Перевод не доставлен в НРД — {{sendFailText .}}.
Попытка {{.At.Format "02.01.2006 15:04:05"}}{{if .PackageID}}, пакет ИШ {{.PackageID}}{{end}}{{if gt .Attempts 1}}, попыток: {{.Attempts}}{{end}}. Отправка в DEPO не отмечена, блокировка в QUIK МНП сохраняется. {{if .GaveUp}}Сутки прошли — автоповтор остановлен: отправьте вручную или удалите заявку.{{else}}Ничего нажимать не нужно: как только НРД снова ответит, bj сам отправит тот же запрос с тем же GUID (повтор — в течение суток). Кнопка «Отправить повторно» — если нужно попробовать прямо сейчас.{{end}}
{{end}} + {{if and .Claim.RequestRawXML (not .SentToNSD) (not .Fail)}}
+ ⛔ В НРД ничего не отправлено — отмена до отправки.
+ Пакет M2MTransferRequest сформирован, но в НРД не направлялся: заявка остановлена на проверке перед отправкой{{if .QuikFailReply}} — в QUIK МНП не удалось заблокировать бумаги{{if .QuikFailISIN}} ({{.QuikFailISIN}}){{end}}{{if .QuikNoSec}}: на счёте клиента нет/недостаточно бумаг для списания{{end}}{{end}}. Регистрация «Вывод ЦБ (M2M)» и блокировка идут в QUIK МНП до отправки в НРД, поэтому поручение 16 и запрос перевода в НРД не создавались. Устраните причину{{if .QuikNoSec}} (зачислите бумаги на счёт клиента){{end}} и отправьте заявку повторно.{{if .QuikFailReply}}
Ответ модуля QUIK: {{.QuikFailReply}}{{end}}
+
{{end}} {{$l := liveOut .Claim}}
{{$l.Text}}
@@ -122,6 +131,17 @@

QUIK МНП — блокировка и списание у клиента

все поручения → +{{if .QuikFailReply}}
+ {{if .QuikNoSec}} + ❌ Бумаги на счёте клиента в QUIK не заблокированы — списывать нечего.
+ На торговом счёте клиента в QUIK нет (недостаточно) бумаг{{if .QuikFailISIN}} {{.QuikFailISIN}}{{end}} для списания. QUIK проверяет остаток на своей стороне при регистрации поручения и отклонил «Вывод ЦБ (M2M)» (заблокировать больше, чем есть на счёте, нельзя). + {{else}} + ❌ Регистрация «Вывод ЦБ (M2M)» в QUIK МНП не прошла — бумаги не заблокированы.
+ QUIK отклонил поручение{{if .QuikFailISIN}} по {{.QuikFailISIN}}{{end}}. + {{end}} + Поэтому в НРД по заявке ничего не отправлялось: регистрация и блокировка в QUIK МНП идут до отправки пакета в НРД.
+ Ответ модуля QUIK: {{.QuikFailReply}} +
{{end}} {{if .DepoL}}
🗂 DEPO (bj2depo): {{if .DepoL.DocNo}}поручение №{{.DepoL.DocNo}} создано {{msk .DepoL.DocAt}}{{if .DepoL.DocState}} · состояние в DEPO: {{.DepoL.DocState}}{{end}}{{else}}поручение ещё не создано{{end}}{{range .DepoL.Errors}}
ошибка: {{.}}
{{end}}
{{end}}

При отправке в НРД bj регистрирует в Модуле неторговых поручений «Вывод ЦБ (M2M)» — QUIK блокирует бумаги на позиции клиента. Отказ контрагента или НРД → поручение снимается с причиной (клиент видит её в терминале). Приём НРД нашего поручения 16 → подпись депозитария, бумаги списываются.

@@ -249,8 +269,10 @@ {{if .Claim.RequestRawXML}}
-

Отправленный пакет (M2MTransferRequest){{if .SubstAs}} подмена личности{{end}}

- {{if .SubstAs}} +

{{if .SentToNSD}}Отправленный пакет (M2MTransferRequest){{else}}Сформированный пакет (M2MTransferRequest){{end}}{{if .SubstAs}} подмена личности{{end}}{{if not .SentToNSD}} в НРД не отправлен{{end}}

+ {{if not .SentToNSD}} +

Этот XML был сформирован, но в НРД не отправлялся — заявка остановлена до отправки{{if not .Fail}} (проверка перед отправкой не пройдена){{else}} (НРД не принял пакет — см. выше){{end}}. Тип пакета #M2MTR.

+ {{else if .SubstAs}}
⚠ В НРД отправлены ПОДМЕНЁННЫЕ данные, не эти. Режим эмуляции «Подмена личности»: в пакете, ушедшем в НРД, блок инвестора заменён на тест-инвестора {{.SubstAs}}. XML ниже — наша запись с реальными данными клиента (так заявка пришла из ЛК); в НРД реальные данные клиента НЕ отправлялись.
@@ -373,6 +395,26 @@ {{if $e.Detail}}
{{$e.Detail}}
{{end}}
{{end}} + {{if .Claim.TimelineTech}} +
+ Технический журнал · {{len .Claim.TimelineTech}} шаг. +
+ {{range $i, $e := .Claim.TimelineTech}} + {{if $i}} +
+ + {{if $e.Since}}через {{$e.Since}}{{end}} +
+ {{end}} +
+
{{if $e.Time.IsZero}}ожидание{{else}}{{$e.Time.Format "02.01 15:04:05"}} МСК{{end}}
+
{{$e.Title}}
+ {{if $e.Detail}}
{{$e.Detail}}
{{end}} +
+ {{end}} +
+
+ {{end}} {{end}} {{/* /claim-grid */}}
Создана{{.Claim.CreatedAt.Format "02.01.2006 15:04:05"}}