Входящие M2M: частичный приём — каждая бумага решается сама по себе (НРД допускает: по каждой принятой свой черновик 16/1); переводы на ИИС не принимаем — отказ M2M07 по всем бумагам

- finishResult: итог confirm / reject / partial, без отказа всем пакетом;
- autoDecisions и ручной ответ: без правила «целиком», квал-предупреждение — отказ только по своей бумаге;
- признак ИИС из заголовка запроса (IIAAgreementDetails), у сохранённых раньше — по исходному XML;
- форма оператора: без автоматического «отказ всем», подсказка про ИИС; тексты и тесты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-09-25 15:36:58 +03:00
parent 76b97b0755
commit 92afdd2868
12 changed files with 90 additions and 103 deletions
+1
View File
@@ -54,6 +54,7 @@ var templateFuncs = template.FuncMap{
"devOn": authTemplateFuncs["devOn"],
"devLicensed": authTemplateFuncs["devLicensed"],
"mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии
"isIIS": incomingIsIIS, // входящий перевод на ИИС
"depoSubs": func() any { return depoWizardSubs }, // экраны шага 3 мастера
"authOpen": authOpen,
"internalDocs": func() bool { return internalDocsBuilt },
+22 -22
View File
@@ -339,35 +339,31 @@ func finishSec(sr *depoSecResult) {
}
}
// finishResult — итог по переводу. Пакет принимается только целиком (решение 16.09.2026): если хотя
// бы по одной бумаге отказ, отказ рекомендуется по всем бумагам с кодом первой отказной.
// finishResult — итог по переводу. Частичный приём (решение 25.09.2026): каждая бумага решается сама
// по себе — прошедшие проверки принимаются, остальным отказ со своим кодом. НРД так допускает: по
// каждой принятой бумаге приходит свой черновик 16/1 и подаётся своё поручение. Переводы на ИИС не
// принимаются совсем — это отдельная проверка по каждой бумаге (отказ всем).
func finishResult(res *depoResult) {
first := -1
for i, s := range res.Secs {
if s.Recommend != "confirm" {
first = i
break
conf, rej, code, sec := 0, 0, "", ""
for _, s := range res.Secs {
if s.Recommend == "confirm" {
conf++
continue
}
rej++
if code == "" {
code, sec = s.RejectCode, s.Security
}
}
switch {
case len(res.Secs) == 0:
case first < 0:
case rej == 0:
res.Recommend, res.Summary = "confirm", "принять"
default:
code, sec := res.Secs[first].RejectCode, res.Secs[first].Security
passed := 0
for i := range res.Secs {
if res.Secs[i].Recommend == "confirm" {
passed++
res.Secs[i].Recommend, res.Secs[i].RejectCode = "reject", code
res.Secs[i].Checks = append(res.Secs[i].Checks, depoCheck{Name: "Пакет принимается только целиком", OK: false, Code: code,
Detail: "сама бумага проверку прошла; отказ всем пакетом из-за " + sec + " (" + code + ")"})
}
}
case conf == 0:
res.Recommend, res.Summary = "reject", "отказ "+code
if passed > 0 {
res.Summary = fmt.Sprintf("отказ пакетом %s (из-за %s)", code, sec)
}
default:
res.Recommend = "partial"
res.Summary = fmt.Sprintf("частично: принять %d, отказ %d (первый — %s по %s)", conf, rej, code, sec)
}
}
@@ -418,6 +414,10 @@ func runDepoCheck(it *IncomingTransfer) depoResult {
for _, s := range it.Securities {
isin := strings.ToUpper(strings.TrimSpace(s.ISIN))
sr := depoSecResult{ReferenceID: s.ReferenceID, Security: pick(isin, s.SecurityCode)}
if incomingIsIIS(it) { // решение 25.09.2026: входящие переводы на ИИС не принимаем совсем
sr.Checks = append(sr.Checks, depoCheck{Name: "Перевод не на ИИС", OK: false, Code: "M2M07",
Detail: "перевод на ИИС (в запросе договор ИИС) — входящие переводы на ИИС не принимаем, отказ по всем бумагам"})
}
ruOK := strings.HasPrefix(isin, "RU")
sr.Checks = append(sr.Checks, depoCheck{Name: "Российский выпуск (RU-ISIN)", OK: ruOK, Code: "M2M01",
Detail: pick3(ruOK, "допускается к переводам M2M", "не-RU ISIN — к M2M не допускается")})
+26 -23
View File
@@ -5,47 +5,50 @@ import (
"testing"
)
// Пакет принимается только целиком: отказ по одной бумаге — отказ по всем с тем же кодом.
func TestFinishResultWholePackage(t *testing.T) {
// Частичный приём (решение 25.09.2026): каждая бумага решается сама по себе.
func TestFinishResultPartial(t *testing.T) {
res := depoResult{Secs: []depoSecResult{
{Security: "RU0009029540", Recommend: "confirm"},
{Security: "RU0007661625", Recommend: "reject", RejectCode: "M2M05"},
{Security: "RU000A0JUR61", Recommend: "confirm"},
}}
finishResult(&res)
if res.Recommend != "reject" || !strings.Contains(res.Summary, "отказ пакетом M2M05") {
if res.Recommend != "partial" || !strings.Contains(res.Summary, "принять 2, отказ 1") {
t.Fatalf("итог: %s %q", res.Recommend, res.Summary)
}
for _, s := range res.Secs {
if s.Recommend != "reject" || s.RejectCode != "M2M05" {
t.Fatalf("бумага %s: %s %s", s.Security, s.Recommend, s.RejectCode)
}
if res.Secs[0].Recommend != "confirm" || res.Secs[2].Recommend != "confirm" || res.Secs[1].RejectCode != "M2M05" {
t.Fatalf("бумаги решаются отдельно: %+v", res.Secs)
}
if last := res.Secs[0].Checks[len(res.Secs[0].Checks)-1]; last.OK || !strings.Contains(last.Detail, "RU0007661625") {
t.Fatalf("пояснение отказа пакетом: %+v", last)
}
ok := depoResult{Secs: []depoSecResult{{Recommend: "confirm"}, {Recommend: "confirm"}}}
finishResult(&ok)
if ok.Recommend != "confirm" {
t.Fatalf("все бумаги прошли: %s", ok.Recommend)
}
no := depoResult{Secs: []depoSecResult{{Recommend: "reject", RejectCode: "M2M01"}, {Recommend: "reject", RejectCode: "M2M05"}}}
finishResult(&no)
if no.Recommend != "reject" || no.Summary != "отказ M2M01" {
t.Fatalf("все отклонены: %s %q", no.Recommend, no.Summary)
}
}
func TestPackageDecisions(t *testing.T) {
in := []SecurityDecision{{ReferenceID: "A", Confirm: true}, {ReferenceID: "B", RejectCode: "M2M08"}, {ReferenceID: "C", Confirm: true}}
out := packageDecisions(in)
for _, d := range out {
if d.Confirm || d.RejectCode != "M2M08" {
t.Fatalf("частичное согласие не должно уходить: %+v", out)
// Автоответ: частичное согласие уходит как есть; перевод на ИИС — отказ M2M07 по всем бумагам.
func TestAutoDecisionsPartialAndIIS(t *testing.T) {
it := &IncomingTransfer{Securities: []IncomingSecurity{{ReferenceID: "A"}, {ReferenceID: "B"}}}
r := depoResult{Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}, {ReferenceID: "B", Recommend: "reject", RejectCode: "M2M05"}}}
d, _ := autoDecisions(it, r)
if !d[0].Confirm || d[1].Confirm || d[1].RejectCode != "M2M05" {
t.Fatalf("частичное согласие: %+v", d)
}
it.IIS = true
d, _ = autoDecisions(it, r)
for _, x := range d {
if x.Confirm || x.RejectCode != "M2M07" {
t.Fatalf("ИИС не принимаем: %+v", d)
}
}
if !in[0].Confirm {
t.Fatal("исходный срез не должен меняться")
}
all := []SecurityDecision{{ReferenceID: "A", Confirm: true}, {ReferenceID: "B", Confirm: true}}
if got := packageDecisions(all); !got[0].Confirm || !got[1].Confirm {
t.Fatalf("согласие по всем: %+v", got)
legacy := &IncomingTransfer{RawXML: "<x><IIAAgreementDetails/></x>"}
if !incomingIsIIS(legacy) {
t.Fatal("ИИС у сохранённого раньше перевода — по исходному XML")
}
}
+7
View File
@@ -55,6 +55,7 @@ type IncomingTransfer struct {
DocNumber string `json:"doc_number,omitempty"`
ReceivingINN string `json:"receiving_inn,omitempty"` // принимающий депозитарий (мы)
Securities []IncomingSecurity `json:"securities"`
IIS bool `json:"iis,omitempty"` // перевод на ИИС (в запросе есть IIAAgreementDetails)
RawXML string `json:"raw_xml"`
DecisionXML string `json:"decision_xml,omitempty"` // наш ответ M2MTransferDecision (для выгрузки регулятору)
Status string `json:"status"` // pending | answered
@@ -135,6 +136,7 @@ func (s *IncomingStore) AddFromRequest(req *m2m.M2MTransferRequest, rawXML strin
id, _ := newID()
inv := req.Data.InvestorInformation
it := &IncomingTransfer{
IIS: req.Header.IIAAgreementDetails != nil,
ID: id,
GUID: guid,
RequestCreated: req.Header.CreationTimestamp.Time,
@@ -358,3 +360,8 @@ func newID() (string, error) {
}
return u, nil
}
// incomingIsIIS — перевод на ИИС: по признаку из запроса, а у сохранённых раньше — по исходному XML.
func incomingIsIIS(it *IncomingTransfer) bool {
return it.IIS || strings.Contains(it.RawXML, "IIAAgreementDetails")
}
+6 -23
View File
@@ -50,7 +50,7 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
}
nsd := cfg.NSD
decisions = packageDecisions(decisions)
decisions = iisDecisions(it, decisions)
// Карта решений по ReferenceID.
byRef := make(map[string]SecurityDecision, len(decisions))
@@ -154,32 +154,15 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
// transferringINN — ИНН нашей организации (передающий депозитарий в запросе, принимающий — в решении).
func transferringINN(s Settings) string { return orgINN(s) }
// packageDecisions — пакет принимается только целиком (решение 16.09.2026): если хотя бы по одной бумаге
// отказ, отказываем по всем; принятым бумагам ставится код первой отказной.
func packageDecisions(decisions []SecurityDecision) []SecurityDecision {
code := ""
for _, d := range decisions {
if !d.Confirm {
code = d.RejectCode
break
}
}
if code == "" {
for _, d := range decisions {
if !d.Confirm {
code = "M2M05"
break
}
}
}
if code == "" {
// iisDecisions — входящие переводы на ИИС не принимаем (решение 25.09.2026): отказ M2M07 по всем
// бумагам, даже если оператор отметил «принять». Остальные переводы — по каждой бумаге отдельно.
func iisDecisions(it *IncomingTransfer, decisions []SecurityDecision) []SecurityDecision {
if it == nil || !incomingIsIIS(it) {
return decisions
}
out := make([]SecurityDecision, len(decisions))
for i, d := range decisions {
if d.Confirm {
d.Confirm, d.RejectCode = false, code
}
d.Confirm, d.RejectCode = false, "M2M07"
out[i] = d
}
return out
+13 -8
View File
@@ -3,8 +3,9 @@ package lkgateway
// Автоматический режим M2M (решение 16.09.2026): ручные шаги оператора выполняются без участия
// человека, чтобы соблюдать сроки и убрать человеческий фактор.
//
// - Входящий перевод ждёт решения → свежая сверка с DEPO → решение по её рекомендации: пакет
// принимается только целиком; предупреждение со Strict (бумага для квалифицированных инвесторов
// - Входящий перевод ждёт решения → свежая сверка с DEPO → решение по её рекомендации: каждая
// бумага решается сама по себе (частичный приём), переводы на ИИС — отказ M2M07 по всем бумагам;
// предупреждение со Strict (бумага для квалифицированных инвесторов
// при неполной квалификации клиента) — отказ M2M08. Без сверки ничего не принимается: если DEPO
// или ИШ недоступны, bj повторяет каждые m2mAutoRetry и шлёт оповещение.
// - После нашего согласия НРД присылает черновик 16/1 (MF010) → поручение подаётся автоматически.
@@ -144,15 +145,19 @@ func incomingHasConfirmed(it *IncomingTransfer) bool {
}
// autoDecisions — решение по результату сверки: рекомендация по каждой бумаге, Strict-предупреждение
// даёт отказ M2M08, пакет только целиком. why — пояснение для карточки.
// даёт отказ M2M08 по этой бумаге, остальные решаются сами по себе; перевод на ИИС — отказ всем. why — пояснение для карточки.
func autoDecisions(it *IncomingTransfer, r depoResult) (decisions []SecurityDecision, why string) {
bySec := map[string]depoSecResult{}
strictSec := map[string]bool{} // бумаги для квал. инвесторов при неполной квалификации — отказ M2M08 именно по ним
strict := ""
for _, sr := range r.Secs {
bySec[sr.ReferenceID] = sr
for _, c := range sr.Checks {
if c.Strict && c.Warn && strict == "" {
strict = sr.Security
if c.Strict && c.Warn {
strictSec[sr.ReferenceID] = true
if strict == "" {
strict = sr.Security
}
}
}
}
@@ -162,7 +167,7 @@ func autoDecisions(it *IncomingTransfer, r depoResult) (decisions []SecurityDeci
switch {
case !ok:
d.RejectCode = "M2M05"
case strict != "":
case strictSec[sec.ReferenceID]:
d.RejectCode = "M2M08"
case sr.Recommend == "confirm":
d.Confirm = true
@@ -174,10 +179,10 @@ func autoDecisions(it *IncomingTransfer, r depoResult) (decisions []SecurityDeci
}
decisions = append(decisions, d)
}
decisions = packageDecisions(decisions)
decisions = iisDecisions(it, decisions)
why = r.Summary
if strict != "" {
why = "отказ M2M08: бумага " + strict + " для квалифицированных инвесторов, квалификация клиента неполная"
why += "; отказ M2M08 по " + strict + ": бумага для квалифицированных инвесторов, квалификация клиента неполная"
}
return decisions, why
}
+9 -13
View File
@@ -16,25 +16,21 @@ func TestAutoDecisions(t *testing.T) {
t.Fatalf("согласие: %+v", dec)
}
// Отказ по одной — отказ всем пакетом с её кодом.
r = depoResult{Summary: "отказ пакетом M2M05", Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}, {ReferenceID: "B", Recommend: "reject", RejectCode: "M2M05"}}}
// Отказ по одной — частичный приём: остальные принимаются (решение 25.09.2026).
r = depoResult{Summary: "частично", Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}, {ReferenceID: "B", Recommend: "reject", RejectCode: "M2M05"}}}
dec, _ = autoDecisions(it, r)
for _, d := range dec {
if d.Confirm || d.RejectCode != "M2M05" {
t.Fatalf("пакет целиком: %+v", dec)
}
if !dec[0].Confirm || dec[1].Confirm || dec[1].RejectCode != "M2M05" {
t.Fatalf("частичный приём: %+v", dec)
}
// Бумага для квал. инвесторов при неполной квалификации — отказ M2M08 всем.
// Бумага для квал. инвесторов при неполной квалификации — отказ M2M08 только по ней.
r = depoResult{Summary: "принять", Secs: []depoSecResult{
{ReferenceID: "A", Security: "RU000A10F5K6", Recommend: "confirm", Checks: []depoCheck{{Name: "Бумага только для квал. инвесторов", OK: true, Warn: true, Strict: true, Code: "M2M08"}}},
{ReferenceID: "B", Recommend: "confirm", Checks: []depoCheck{{Name: "Квал-инвестор (если бумага только для квал.)", OK: true, Warn: true, Code: "M2M08"}}},
}}
dec, why := autoDecisions(it, r)
for _, d := range dec {
if d.Confirm || d.RejectCode != "M2M08" {
t.Fatalf("строгое предупреждение: %+v", dec)
}
if dec[0].Confirm || dec[0].RejectCode != "M2M08" || !dec[1].Confirm {
t.Fatalf("строгое предупреждение — отказ только по этой бумаге: %+v", dec)
}
if !strings.Contains(why, "RU000A10F5K6") {
t.Fatalf("пояснение: %q", why)
@@ -49,9 +45,9 @@ func TestAutoDecisions(t *testing.T) {
t.Fatalf("информационное предупреждение не должно отказывать: %+v", dec)
}
// Бумага без результата сверки — не принимаем.
// Бумага без результата сверки — не принимаем (остальные решаются сами по себе).
r = depoResult{Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}}}
if dec, _ = autoDecisions(it, r); dec[0].Confirm || dec[1].Confirm {
if dec, _ = autoDecisions(it, r); !dec[0].Confirm || dec[1].Confirm {
t.Fatalf("без сверки по бумаге: %+v", dec)
}
}
@@ -14,6 +14,7 @@
.dc-rec{display:inline-block;padding:5px 14px;border-radius:999px;font-weight:700;font-size:14px}
.dc-rec.confirm{background:var(--ok-weak,#e6f4ea);color:var(--ok,#12813f)}
.dc-rec.reject{background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)}
.dc-rec.partial,.dc-rec.mixed{background:var(--warn-weak,#fff4e0);color:var(--warn,#9a6b12)}
.dc-rec.mixed{background:var(--warn-weak,#fff4e0);color:var(--warn,#9a6b12)}
</style>
@@ -65,7 +65,7 @@
<h2>Ход исполнения</h2>
<ol>
<li><b>Пакет отправлен в НРД.</b> Сервис MOEX МОСТ проверяет формат и присылает квитанцию. Если есть ошибка, поручение завершается отказом с кодом, а блокировка снимается.</li>
<li><b>Решение принимающего депозитария.</b> Депозитарий соглашается или отказывает (код отказа виден в карточке). Пакет принимается только целиком.</li>
<li><b>Решение принимающего депозитария.</b> Депозитарий соглашается или отказывает по каждой бумаге отдельно (код отказа виден в карточке): возможен частичный приём. Переводы на ИИС не принимаются.</li>
<li><b>Черновик поручения 16.</b> После согласия НРД присылает черновик поручения на списание.</li>
<li><b>Поручение 16 подано.</b> Нажмите «Подать поручение 16 по черновику». Если включён автоматический режим M2M, bj подаёт поручение сам.</li>
<li><b>Отчёт НРД об исполнении.</b> Когда отчёт (MS010) получен, бумаги списаны. Кнопка «Документы НРД» скачивает архив с квитанциями и отчётом, в том числе в PDF.</li>
@@ -42,7 +42,7 @@
.dc-rec{display:inline-block;padding:4px 12px;border-radius:999px;font-weight:700;font-size:13px}
.dc-rec.confirm{background:var(--ok-weak,#e6f4ea);color:var(--ok,#12813f)}
.dc-rec.reject{background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)}
.dc-rec.mixed{background:var(--warn-weak,#fff4e0);color:var(--warn,#9a6b12)}
.dc-rec.mixed,.dc-rec.partial{background:var(--warn-weak,#fff4e0);color:var(--warn,#9a6b12)}
</style>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<h2 style="margin:0">Сверка с DEPO — {{if devOn}}эмуляция ответа{{else}}рекомендация по ответу{{end}}</h2>
@@ -141,7 +141,7 @@
{{end}}
</tbody>
</table>
<p class="muted" style="font-size:12.5px;margin:10px 0 0">Пакет принимается только целиком: при отказе по одной бумаге отказ ставится всем бумагам с тем же кодом (так и будет отправлено).</p>
<p class="muted" style="font-size:12.5px;margin:10px 0 0">Решение по каждой бумаге отдельно: принятые бумаги НРД переведёт (по каждой придёт свой черновик 16/1), по остальным уйдёт отказ с выбранным кодом.{{if and $.It (isIIS $.It)}} <b>Перевод на ИИС — такие мы не принимаем: отказ M2M07 по всем бумагам будет отправлен в любом случае.</b>{{end}}</p>
<div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">
<button type="submit" class="btn btn-ok">Отправить решение{{if devOn}} роботу{{end}}</button>
<button type="button" class="btn" onclick="m2mSetAll('confirm')">Принять все</button>
@@ -161,16 +161,6 @@
document.querySelectorAll('input.dec').forEach(function(r){
r.addEventListener('change', function(){
toggle(r.getAttribute('data-ref'));
// Пакет целиком: отказ по одной бумаге — отказ по всем с тем же кодом.
if(r.value === 'reject' && r.checked){
var src = document.querySelector('select[name="reject_'+r.getAttribute('data-ref')+'"]');
document.querySelectorAll('input.dec[value="reject"]').forEach(function(o){
if(o.checked) return;
o.checked = true; toggle(o.getAttribute('data-ref'));
var sel = document.querySelector('select[name="reject_'+o.getAttribute('data-ref')+'"]');
if(sel && src) sel.value = src.value;
});
}
});
});
window.m2mSetAll = function(val){
@@ -107,6 +107,7 @@
.dc-rec{display:inline-block;padding:4px 12px;border-radius:999px;font-weight:700;font-size:13px}
.dc-rec.confirm{background:var(--ok-weak,#e6f4ea);color:var(--ok,#12813f)}
.dc-rec.reject{background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)}
.dc-rec.partial,.dc-rec.mixed{background:var(--warn-weak,#fff4e0);color:var(--warn,#9a6b12)}
.dc-rec.mixed{background:var(--warn-weak,#fff4e0);color:var(--warn,#9a6b12)}
</style>
<div style="margin-top:18px;padding:12px 14px;border:1px solid var(--border);border-radius:12px;background:var(--card)">
@@ -313,7 +313,7 @@
{{template "orgSection" .}}
<div class="card" id="m2m-auto">
<h2><span class="dot {{if .Settings.M2MAuto.Enabled}}ok{{else}}warn{{end}}"></span>Автоматический режим M2M</h2>
<p class="muted">Без участия оператора, чтобы соблюдать сроки: <strong>ответ на входящие переводы</strong> по свежей сверке с DEPO (пакет принимается только целиком; бумага для квалифицированных инвесторов при неполной или непрочитанной квалификации клиента — отказ M2M08), <strong>подача поручения 16/1</strong>, как только НРД пришлёт черновик после нашего согласия, и <strong>подача поручения 16</strong> по исходящим после согласия контрагента. Если DEPO или ИШ недоступны — bj повторяет каждые 3 минуты и шлёт оповещение; без сверки ничего не принимается. Обрабатывается только то, что пришло не раньше чем за сутки до включения. Кнопки в карточках остаются для ручной работы.</p>
<p class="muted">Без участия оператора, чтобы соблюдать сроки: <strong>ответ на входящие переводы</strong> по свежей сверке с DEPO (решение по каждой бумаге отдельно — возможен частичный приём; переводы на ИИС — отказ M2M07; бумага для квалифицированных инвесторов при неполной или непрочитанной квалификации клиента — отказ M2M08), <strong>подача поручения 16/1</strong>, как только НРД пришлёт черновик после нашего согласия, и <strong>подача поручения 16</strong> по исходящим после согласия контрагента. Если DEPO или ИШ недоступны — bj повторяет каждые 3 минуты и шлёт оповещение; без сверки ничего не принимается. Обрабатывается только то, что пришло не раньше чем за сутки до включения. Кнопки в карточках остаются для ручной работы.</p>
<form method="post" action="/admin/setup/m2m-auto" style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:10px">
<input type="hidden" name="section" value="m2m-auto">
<label style="display:flex;gap:8px;align-items:center;font-weight:600;margin:0"><input type="checkbox" name="enabled" value="1" {{if .Settings.M2MAuto.Enabled}}checked{{end}}> Автоматический режим включён</label>