diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go
index a12f745..f09f2f1 100644
--- a/internal/lkgateway/admin.go
+++ b/internal/lkgateway/admin.go
@@ -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 },
diff --git a/internal/lkgateway/depo_check.go b/internal/lkgateway/depo_check.go
index 6a20f68..f4f8f03 100644
--- a/internal/lkgateway/depo_check.go
+++ b/internal/lkgateway/depo_check.go
@@ -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 не допускается")})
diff --git a/internal/lkgateway/depo_package_test.go b/internal/lkgateway/depo_package_test.go
index 19633bf..73a33aa 100644
--- a/internal/lkgateway/depo_package_test.go
+++ b/internal/lkgateway/depo_package_test.go
@@ -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: ""}
+ if !incomingIsIIS(legacy) {
+ t.Fatal("ИИС у сохранённого раньше перевода — по исходному XML")
}
}
diff --git a/internal/lkgateway/incoming.go b/internal/lkgateway/incoming.go
index d3556e1..68cddad 100644
--- a/internal/lkgateway/incoming.go
+++ b/internal/lkgateway/incoming.go
@@ -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")
+}
diff --git a/internal/lkgateway/incoming_answer.go b/internal/lkgateway/incoming_answer.go
index 300c256..0a1904c 100644
--- a/internal/lkgateway/incoming_answer.go
+++ b/internal/lkgateway/incoming_answer.go
@@ -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
diff --git a/internal/lkgateway/m2mauto.go b/internal/lkgateway/m2mauto.go
index ddee18c..186e2b2 100644
--- a/internal/lkgateway/m2mauto.go
+++ b/internal/lkgateway/m2mauto.go
@@ -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
}
diff --git a/internal/lkgateway/m2mauto_test.go b/internal/lkgateway/m2mauto_test.go
index 07f3b21..fd07268 100644
--- a/internal/lkgateway/m2mauto_test.go
+++ b/internal/lkgateway/m2mauto_test.go
@@ -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)
}
}
diff --git a/internal/lkgateway/web/templates/admin_depo_sim.html b/internal/lkgateway/web/templates/admin_depo_sim.html
index 87ac33d..b4bc12b 100644
--- a/internal/lkgateway/web/templates/admin_depo_sim.html
+++ b/internal/lkgateway/web/templates/admin_depo_sim.html
@@ -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)}
diff --git a/internal/lkgateway/web/templates/admin_help_manual.html b/internal/lkgateway/web/templates/admin_help_manual.html
index 3ad41fe..418bf4c 100644
--- a/internal/lkgateway/web/templates/admin_help_manual.html
+++ b/internal/lkgateway/web/templates/admin_help_manual.html
@@ -65,7 +65,7 @@
Ход исполнения
Пакет отправлен в НРД. Сервис MOEX МОСТ проверяет формат и присылает квитанцию. Если есть ошибка, поручение завершается отказом с кодом, а блокировка снимается.
-
Решение принимающего депозитария. Депозитарий соглашается или отказывает (код отказа виден в карточке). Пакет принимается только целиком.
+
Решение принимающего депозитария. Депозитарий соглашается или отказывает по каждой бумаге отдельно (код отказа виден в карточке): возможен частичный приём. Переводы на ИИС не принимаются.
Черновик поручения 16. После согласия НРД присылает черновик поручения на списание.
Поручение 16 подано. Нажмите «Подать поручение 16 по черновику». Если включён автоматический режим M2M, bj подаёт поручение сам.
Отчёт НРД об исполнении. Когда отчёт (MS010) получен, бумаги списаны. Кнопка «Документы НРД» скачивает архив с квитанциями и отчётом, в том числе в PDF.
Сверка с DEPO — {{if devOn}}эмуляция ответа{{else}}рекомендация по ответу{{end}}
@@ -141,7 +141,7 @@
{{end}}
-
Пакет принимается только целиком: при отказе по одной бумаге отказ ставится всем бумагам с тем же кодом (так и будет отправлено).
+
Решение по каждой бумаге отдельно: принятые бумаги НРД переведёт (по каждой придёт свой черновик 16/1), по остальным уйдёт отказ с выбранным кодом.{{if and $.It (isIIS $.It)}} Перевод на ИИС — такие мы не принимаем: отказ M2M07 по всем бумагам будет отправлен в любом случае.{{end}}
@@ -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){
diff --git a/internal/lkgateway/web/templates/admin_m2mpreview.html b/internal/lkgateway/web/templates/admin_m2mpreview.html
index 344ca33..a665401 100644
--- a/internal/lkgateway/web/templates/admin_m2mpreview.html
+++ b/internal/lkgateway/web/templates/admin_m2mpreview.html
@@ -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)}
Без участия оператора, чтобы соблюдать сроки: ответ на входящие переводы по свежей сверке с DEPO (пакет принимается только целиком; бумага для квалифицированных инвесторов при неполной или непрочитанной квалификации клиента — отказ M2M08), подача поручения 16/1, как только НРД пришлёт черновик после нашего согласия, и подача поручения 16 по исходящим после согласия контрагента. Если DEPO или ИШ недоступны — bj повторяет каждые 3 минуты и шлёт оповещение; без сверки ничего не принимается. Обрабатывается только то, что пришло не раньше чем за сутки до включения. Кнопки в карточках остаются для ручной работы.
+
Без участия оператора, чтобы соблюдать сроки: ответ на входящие переводы по свежей сверке с DEPO (решение по каждой бумаге отдельно — возможен частичный приём; переводы на ИИС — отказ M2M07; бумага для квалифицированных инвесторов при неполной или непрочитанной квалификации клиента — отказ M2M08), подача поручения 16/1, как только НРД пришлёт черновик после нашего согласия, и подача поручения 16 по исходящим после согласия контрагента. Если DEPO или ИШ недоступны — bj повторяет каждые 3 минуты и шлёт оповещение; без сверки ничего не принимается. Обрабатывается только то, что пришло не раньше чем за сутки до включения. Кнопки в карточках остаются для ручной работы.