Карточка входящего: отклонённый перевод — 16/1 не требуется, операция завершена; черновик отмечается только если реально получен

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-16 15:47:07 +03:00
parent 7288c86fba
commit cfd6dfa1e8
3 changed files with 120 additions and 21 deletions
@@ -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("ожидающий перевод не должен показывать полученный черновик и кнопку подачи")
}
}
+40 -14
View File
@@ -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, ", ")
}
@@ -227,24 +227,39 @@
.ord-dl:hover{background:color-mix(in srgb,#16a34a 12%,var(--card))}
</style>
{{if .Rejected}}
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<h2 style="margin:0">Депозитарное поручение 16/1</h2>
<span class="badge rejected">не требуется — перевод отклонён</span>
</div>
<div class="ord-steps">
<div class="ord-step done">
<span class="dot">✓</span>
<div><div class="t">Операция завершена отказом</div>
<div class="s">Отказ {{.RejectCodes}}{{with .RejectCodes}} ({{m2mCode .}}){{end}} отправлен {{msk .It.AnsweredAt}}{{if .It.Auto}} автоматически{{end}}. Бумаги не зачисляются, поручение 16/1 не подаётся — со стороны депозитария действий не требуется.</div></div>
</div>
</div>
{{else}}
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<h2 style="margin:0">Депозитарное поручение 16/1</h2>
{{if .OrderDone}}<span class="badge confirmed">проведено · MS010</span>
{{else if .It.OrderSubmitted}}<span class="badge awaiting_decision">подано, ждём MS010</span>
{{else if not $answered}}<span class="badge" style="background:var(--card-2);color:var(--muted)">после решения по переводу</span>
{{else if .AutoMode}}<span class="badge awaiting_decision">подаст bj автоматически</span>
{{else}}<span class="badge" style="background:var(--card-2);color:var(--muted)">не подано</span>{{end}}
</div>
{{if .Watch}}<div style="margin:8px 0 0;padding:8px 12px;border-radius:9px;font-size:13px;font-weight:600;{{if eq .Watch.Kind "ok"}}background:color-mix(in srgb,#16a34a 12%,var(--card));color:#0f7a37{{else if eq .Watch.Kind "warn"}}background:color-mix(in srgb,#d97706 14%,var(--card));color:#9a6b12{{else if eq .Watch.Kind "bad"}}background:color-mix(in srgb,#dc2626 12%,var(--card));color:#b3261e{{else}}background:var(--card-2);color:var(--muted){{end}}">⏱ Срок по регламенту: {{.Watch.StatusRu}}</div>{{end}}
<div class="ord-steps">
<div class="ord-step done">
<span class="dot">✓</span>
<div><div class="t">Черновик получен</div><div class="s">{{if .DraftReceived}}{{.DraftReceived}}{{else}}сформирован НРД (MF010){{end}}</div>
<a class="ord-dl" href="/admin/incoming/{{.It.ID}}/draft.zip">⬇ Черновик от НРД</a></div>
<div class="ord-step {{if .DraftReceived}}done{{else if $answered}}wait{{else}}todo{{end}}">
<span class="dot">{{if .DraftReceived}}✓{{else}}1{{end}}</span>
<div><div class="t">Черновик получен</div><div class="s">{{if .DraftReceived}}{{.DraftReceived}}{{else if $answered}}ждём черновик MF010 от НРД{{else}}после нашего согласия{{end}}</div>
{{if .DraftReceived}}<a class="ord-dl" href="/admin/incoming/{{.It.ID}}/draft.zip">⬇ Черновик от НРД</a>{{end}}</div>
</div>
<div class="ord-step {{if .It.OrderSubmitted}}done{{else}}wait{{end}}">
<div class="ord-step {{if .It.OrderSubmitted}}done{{else if .DraftReceived}}wait{{else}}todo{{end}}">
<span class="dot">{{if .It.OrderSubmitted}}✓{{else}}2{{end}}</span>
<div><div class="t">Поручение подано в НРД</div><div class="s">{{if .It.OrderSubmitted}}{{msk .It.OrderAt}}{{else}}нужно подать{{end}}</div>
<div><div class="t">Поручение подано в НРД</div><div class="s">{{if .It.OrderSubmitted}}{{msk .It.OrderAt}}{{else if .AutoMode}}bj подаст автоматически по черновику{{else if .DraftReceived}}нужно подать{{else}}после черновика{{end}}</div>
{{if .It.OrderXML}}<a class="ord-dl" href="/admin/incoming/{{.It.ID}}/order.xml">⬇ Отправленная форма 16/1</a>{{end}}</div>
</div>
<div class="ord-step {{if .OrderDone}}done{{else if .It.OrderSubmitted}}wait{{else}}todo{{end}}">
@@ -255,7 +270,7 @@
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
{{if not .It.OrderSubmitted}}
{{if and (not .It.OrderSubmitted) .DraftReceived}}
<form method="post" action="/admin/incoming/{{.It.ID}}/submit-order" onsubmit="return confirm('Подать поручение 16/1 (тип ЭД ORDXM) в НРД (TEST3)?');">
<button type="submit" class="btn btn-ok">▶ Подать поручение 16/1 в НРД</button>
</form>
@@ -284,6 +299,7 @@
<details style="margin-top:12px"><summary style="cursor:pointer;font-size:12px;color:var(--muted)">Как это работает</summary>
<p class="muted" style="font-size:12px;margin-top:6px">После нашего согласия на прием НРД формирует <b>черновик поручения 16/1</b> (MF010). Депозитарий подаёт его в НРД (тип ЭД <code>ORDXM</code>) — НРД сматчит со встречным поручением 16 контрагента, проведёт зачисление и выпустит отчёт <code>XMLRP</code> = <b>MS010</b>. Кнопка «Подать» отправляет черновик через ИШ (подпись ГОСТ ставит ИШ); пока Fancy-DEPO не подключена, можно скачать черновик и обработать вручную.</p>
</details>
{{end}}
</div>
@@ -367,6 +383,9 @@
<a href="/admin/quik" class="muted" style="font-size:12px">все поручения →</a>
</div>
{{if .DepoL}}<div style="margin:8px 0 0;padding:8px 12px;border-radius:9px;font-size:13px;{{if .DepoL.Errors}}background:color-mix(in srgb,#dc2626 12%,var(--card));color:#b3261e{{else}}background:var(--card-2){{end}}">🗂 DEPO (bj2depo): {{if .DepoL.DocNo}}поручение <b>№{{.DepoL.DocNo}}</b> создано {{msk .DepoL.DocAt}}{{if .DepoL.DocState}} · состояние в DEPO: <b>{{.DepoL.DocState}}</b>{{end}}{{else}}поручение ещё не создано{{end}}{{range .DepoL.Errors}}<div>ошибка: {{.}}</div>{{end}}</div>{{end}}
{{if .Rejected}}
<p class="muted" style="font-size:12.5px;margin:10px 0 0">Зачисление в QUIK не требуется — перевод отклонён ({{.RejectCodes}}), бумаги клиенту не поступают.</p>
{{else}}
<p class="muted" style="font-size:12.5px;margin:6px 0 0">Когда НРД принимает наше поручение 16/1 (отчёт MS010), bj регистрирует в Модуле неторговых поручений «Ввод ЦБ (M2M)» по ISIN — оно исполняется сразу, бумаги появляются на позиции клиента в QUIK.{{if not .QuikOn}} <b>Записи в МНП сейчас выключены</b> (Настройка → QUIK МНП).{{end}}</p>
{{if not .Quik}}<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;align-items:center"><span class="muted">Зачислений по этому переводу ещё нет.</span>
@@ -390,5 +409,6 @@
</div>
</div>
{{end}}
{{end}}
</div>
{{end}}