From cfd6dfa1e801f2680b904daad8f74273fd0b5751 Mon Sep 17 00:00:00 2001 From: zuevav Date: Wed, 16 Sep 2026 15:47:07 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=D1=85=D0=BE=D0=B4=D1=8F=D1=89=D0=B5=D0=B3=D0=BE?= =?UTF-8?q?:=20=D0=BE=D1=82=D0=BA=D0=BB=D0=BE=D0=BD=D1=91=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=20?= =?UTF-8?q?=E2=80=94=2016/1=20=D0=BD=D0=B5=20=D1=82=D1=80=D0=B5=D0=B1?= =?UTF-8?q?=D1=83=D0=B5=D1=82=D1=81=D1=8F,=20=D0=BE=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0;=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D0=BA=20=D0=BE=D1=82=D0=BC=D0=B5=D1=87=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B5?= =?UTF-8?q?=D1=81=D0=BB=D0=B8=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- internal/lkgateway/incoming_detail_test.go | 53 ++++++++++++++++++ internal/lkgateway/incoming_handlers.go | 54 ++++++++++++++----- .../web/templates/admin_incoming_detail.html | 34 +++++++++--- 3 files changed, 120 insertions(+), 21 deletions(-) create mode 100644 internal/lkgateway/incoming_detail_test.go diff --git a/internal/lkgateway/incoming_detail_test.go b/internal/lkgateway/incoming_detail_test.go new file mode 100644 index 0000000..169012f --- /dev/null +++ b/internal/lkgateway/incoming_detail_test.go @@ -0,0 +1,53 @@ +package lkgateway + +import ( + "net/http/httptest" + "strings" + "testing" + "time" +) + +func renderIncoming(t *testing.T, d incomingDetailData) string { + t.Helper() + a, err := newAdmin() + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + d.page = nowPage("Входящий перевод", "incoming") + render(rec, a.incomingDetail, d) + if rec.Code != 200 { + t.Fatalf("код %d: %s", rec.Code, rec.Body.String()) + } + return rec.Body.String() +} + +// Отклонённый перевод: 16/1 не требуется, операция завершена, без кнопок подачи и «черновик получен». +func TestIncomingDetailRejected(t *testing.T) { + it := &IncomingTransfer{ID: "x", GUID: "9F5E120D-EFD1-46EE-A9BA-35CB2E794B81", Status: "answered", Auto: true, AnsweredAt: time.Now(), + Securities: []IncomingSecurity{{ReferenceID: "R1", SecurityCode: "SBER/03", Decision: "rejected", RejectCode: "M2M04"}}} + rejected, codes := incomingRejectedAll(it) + if !rejected || codes != "M2M04" { + t.Fatalf("incomingRejectedAll: %v %q", rejected, codes) + } + body := renderIncoming(t, incomingDetailData{It: it, Rejected: rejected, RejectCodes: codes, AutoMode: true}) + for _, want := range []string{"не требуется — перевод отклонён", "Операция завершена отказом", "автоматически", "Зачисление в QUIK не требуется"} { + if !strings.Contains(body, want) { + t.Errorf("нет %q", want) + } + } + for _, bad := range []string{"Подать поручение 16/1 в НРД", "Черновик от НРД", "нужно подать"} { + if strings.Contains(body, bad) { + t.Errorf("лишнее в завершённом переводе: %q", bad) + } + } +} + +// Перевод ждёт решения: шаги 16/1 не отмечены выполненными. +func TestIncomingDetailPending(t *testing.T) { + it := &IncomingTransfer{ID: "y", GUID: "G", Status: "pending", Securities: []IncomingSecurity{{ReferenceID: "R1", SecurityCode: "SBER/03"}}} + body := renderIncoming(t, incomingDetailData{It: it}) + if !strings.Contains(body, "после нашего согласия") || strings.Contains(body, "Черновик от НРД") || strings.Contains(body, "Подать поручение 16/1 в НРД") { + t.Error("ожидающий перевод не должен показывать полученный черновик и кнопку подачи") + } +} diff --git a/internal/lkgateway/incoming_handlers.go b/internal/lkgateway/incoming_handlers.go index e1b3ddf..fe89f5f 100644 --- a/internal/lkgateway/incoming_handlers.go +++ b/internal/lkgateway/incoming_handlers.go @@ -176,20 +176,11 @@ func registerIncoming(mux *http.ServeMux, a *admin, deps incomingDeps) { } } } - data := struct { - page - It *IncomingTransfer - Flash string - OrderResponses []orderResponse - OrderDone bool - OrderDoneAt string - DraftReceived string - Depo depoResult // сверка с DEPO — эмуляция ответа, ничего не отправляется - Quik []*QuikLink - QuikOn bool - Watch *OrderWatch - DepoL *DepoLink - }{nowPage("Входящий перевод", "incoming"), it, r.URL.Query().Get("flash"), orderResps, orderDone, orderDoneAt, draftReceivedTime(it.GUID), depoResult{}, globalQuikLinks.ForGUID(it.GUID), deps.rc.Snapshot().Quik.WritesEnabled, globalOrderWatch.Get(it.GUID), globalDepoLinks.Get(it.GUID)} + rejected, codes := incomingRejectedAll(it) + data := incomingDetailData{page: nowPage("Входящий перевод", "incoming"), It: it, Flash: r.URL.Query().Get("flash"), + OrderResponses: orderResps, OrderDone: orderDone, OrderDoneAt: orderDoneAt, DraftReceived: draftReceivedTime(it.GUID), + Quik: globalQuikLinks.ForGUID(it.GUID), QuikOn: quikActive(deps.rc.Snapshot().Quik), Watch: globalOrderWatch.Get(it.GUID), DepoL: globalDepoLinks.Get(it.GUID), + Rejected: rejected, RejectCodes: codes, AutoMode: deps.rc.Snapshot().M2MAuto.Enabled} // Сверка выполняется при открытии карточки; итог сохраняем для бейджа в списке. data.Depo = runDepoCheck(it) if data.Depo.Ran { @@ -234,3 +225,38 @@ func handleIncomingAnswer(w http.ResponseWriter, r *http.Request, deps incomingD } http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther) } + +// incomingDetailData — карточка входящего перевода. +type incomingDetailData struct { + page + It *IncomingTransfer + Flash string + OrderResponses []orderResponse + OrderDone bool + OrderDoneAt string + DraftReceived string + Depo depoResult // сверка с DEPO — эмуляция ответа, ничего не отправляется + Quik []*QuikLink + QuikOn bool + Watch *OrderWatch + DepoL *DepoLink + Rejected bool // решение отправлено и ни одна бумага не принята — перевод завершён отказом + RejectCodes string // коды отказа через запятую + AutoMode bool // автоматический режим M2M (поручение 16/1 подаст bj) +} + +// incomingRejectedAll — перевод отвечен и ни одна бумага не принята (поручение 16/1 не нужно). +func incomingRejectedAll(it *IncomingTransfer) (bool, string) { + if it.Status != "answered" || incomingHasConfirmed(it) { + return false, "" + } + var codes []string + seen := map[string]bool{} + for _, s := range it.Securities { + if s.RejectCode != "" && !seen[s.RejectCode] { + seen[s.RejectCode] = true + codes = append(codes, s.RejectCode) + } + } + return true, strings.Join(codes, ", ") +} diff --git a/internal/lkgateway/web/templates/admin_incoming_detail.html b/internal/lkgateway/web/templates/admin_incoming_detail.html index feec25c..41f79fe 100644 --- a/internal/lkgateway/web/templates/admin_incoming_detail.html +++ b/internal/lkgateway/web/templates/admin_incoming_detail.html @@ -227,24 +227,39 @@ .ord-dl:hover{background:color-mix(in srgb,#16a34a 12%,var(--card))} + {{if .Rejected}} +
+

Депозитарное поручение 16/1

+ не требуется — перевод отклонён +
+
+
+ ✓ +
Операция завершена отказом
+
Отказ {{.RejectCodes}}{{with .RejectCodes}} ({{m2mCode .}}){{end}} отправлен {{msk .It.AnsweredAt}}{{if .It.Auto}} автоматически{{end}}. Бумаги не зачисляются, поручение 16/1 не подаётся — со стороны депозитария действий не требуется.
+
+
+ {{else}}

Депозитарное поручение 16/1

{{if .OrderDone}}проведено · MS010 {{else if .It.OrderSubmitted}}подано, ждём MS010 + {{else if not $answered}}после решения по переводу + {{else if .AutoMode}}подаст bj автоматически {{else}}не подано{{end}}
{{if .Watch}}
⏱ Срок по регламенту: {{.Watch.StatusRu}}
{{end}}
-
- ✓ -
Черновик получен
{{if .DraftReceived}}{{.DraftReceived}}{{else}}сформирован НРД (MF010){{end}}
- ⬇ Черновик от НРД
+
+ {{if .DraftReceived}}✓{{else}}1{{end}} +
Черновик получен
{{if .DraftReceived}}{{.DraftReceived}}{{else if $answered}}ждём черновик MF010 от НРД{{else}}после нашего согласия{{end}}
+ {{if .DraftReceived}}⬇ Черновик от НРД{{end}}
-
+
{{if .It.OrderSubmitted}}✓{{else}}2{{end}} -
Поручение подано в НРД
{{if .It.OrderSubmitted}}{{msk .It.OrderAt}}{{else}}нужно подать{{end}}
+
Поручение подано в НРД
{{if .It.OrderSubmitted}}{{msk .It.OrderAt}}{{else if .AutoMode}}bj подаст автоматически по черновику{{else if .DraftReceived}}нужно подать{{else}}после черновика{{end}}
{{if .It.OrderXML}}⬇ Отправленная форма 16/1{{end}}
@@ -255,7 +270,7 @@
- {{if not .It.OrderSubmitted}} + {{if and (not .It.OrderSubmitted) .DraftReceived}}
@@ -284,6 +299,7 @@
Как это работает

После нашего согласия на прием НРД формирует черновик поручения 16/1 (MF010). Депозитарий подаёт его в НРД (тип ЭД ORDXM) — НРД сматчит со встречным поручением 16 контрагента, проведёт зачисление и выпустит отчёт XMLRP = MS010. Кнопка «Подать» отправляет черновик через ИШ (подпись ГОСТ ставит ИШ); пока Fancy-DEPO не подключена, можно скачать черновик и обработать вручную.

+ {{end}}
@@ -367,6 +383,9 @@ все поручения →
{{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}} +{{if .Rejected}} +

Зачисление в QUIK не требуется — перевод отклонён ({{.RejectCodes}}), бумаги клиенту не поступают.

+{{else}}

Когда НРД принимает наше поручение 16/1 (отчёт MS010), bj регистрирует в Модуле неторговых поручений «Ввод ЦБ (M2M)» по ISIN — оно исполняется сразу, бумаги появляются на позиции клиента в QUIK.{{if not .QuikOn}} Записи в МНП сейчас выключены (Настройка → QUIK МНП).{{end}}

{{if not .Quik}}
Зачислений по этому переводу ещё нет. @@ -390,5 +409,6 @@
{{end}} +{{end}}
{{end}}