M2M ИИС: приём «всё или ничего» (настройка AcceptIIS, по умолчанию выкл)

Правильная обработка входящих переводов на ИИС вместо прежнего сплошного отказа.
Настройка AcceptIIS (карточка «Кто отвечает на переводы M2M»):
 - выкл (по умолчанию) — поведение не меняется: перевод на ИИС отклоняется целиком (M2M06);
 - вкл — ИИС принимается ТОЛЬКО целиком: все бумаги зачисляются на ИИС-счёт клиента;
   если хоть одной бумаги нет в учёте / не проходит сверку — отказ по ВСЕМУ переводу.

Изменения:
 - iisDecisions: «всё или ничего» (при выкл — отказ всем; при вкл — либо все принять,
   либо все отклонить, сохраняя реальные коды отказа по проблемным бумагам);
 - depo_check: при вкл приёме ИИС убран искусственный отказ «Перевод не на ИИС»,
   добавлена проверка «Счёт зачисления — ИИС» (partyChecks получил флаг wantIIS);
 - настройка AcceptIIS в Settings + геттер/сеттер + обработчик /admin/setup/accept-iis
   + тумблер в part_nsd.html; тесты обновлены.
Жёсткий запрет частичного ИИС в answerIncoming (коммит dc8b785) остаётся как страховка.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fontvielle
2026-10-14 10:43:21 +03:00
parent dc8b785a4b
commit c73f6120d3
8 changed files with 157 additions and 24 deletions
+16 -6
View File
@@ -368,8 +368,18 @@ func finishResult(res *depoResult) {
}
// partyChecks — общие проверки инвестора/счёта/ИИС/квал (одинаковы для обеих сверок).
func partyChecks(p depoParty, ser, num, fio string) []depoCheck {
func partyChecks(p depoParty, ser, num, fio string, wantIIS bool) []depoCheck {
ser = strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
// Счёт зачисления: для обычного перевода должен быть НЕ ИИС; для перевода на ИИС —
// наоборот, обязан быть ИИС-счётом клиента (иначе ИИС-бумаги зачислять некуда).
iisChk := depoCheck{Name: "Счёт не ИИС", OK: p.iisOK, Code: iisRejectCode, Detail: p.iisDet}
if wantIIS {
det := p.iisDet
if p.iisOK {
det = "перевод на ИИС, но счёт зачисления клиента НЕ ИИС-счёт — зачислять ИИС-бумаги некуда (" + p.iisDet + ")"
}
iisChk = depoCheck{Name: "Счёт зачисления — ИИС", OK: !p.iisOK, Code: iisRejectCode, Detail: det}
}
return []depoCheck{
// Инвестор — наш клиент: лицо по паспорту с тем же ФИО И хотя бы один торговый счёт (договор).
// Физлицо в картотеке без счёта у нас — не клиент депозитария: отказ M2M04, а не M2M02 (16.09.2026).
@@ -382,7 +392,7 @@ func partyChecks(p depoParty, ser, num, fio string) []depoCheck {
Detail: pick3(p.accAny != "", p.accAny, pick3(p.faceOK, "у инвестора нет счёта "+depoAccPrefix()+"… в OD_CLIENTS", "не проверяется — инвестор не найден"))},
{Name: "Статус счёта строго «открыт»", OK: p.accOpen != "", Code: "M2M02",
Detail: pick3(p.accAny == "", "не проверяется — счёта нет", pick3(p.accOpen != "", p.accOpen+" — открыт (STATE=0, без даты закрытия)", p.accAny+" — НЕ открыт (закрыт или STATE≠0)"))},
{Name: "Счёт не ИИС", OK: p.iisOK, Code: iisRejectCode, Detail: p.iisDet},
iisChk,
}
}
@@ -414,9 +424,9 @@ 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: входящие переводы на ИИС не принимаем совсем
if incomingIsIIS(it) && !m2mAcceptIIS() { // приём ИИС выключен — отклоняем перевод целиком
sr.Checks = append(sr.Checks, depoCheck{Name: "Перевод не на ИИС", OK: false, Code: iisRejectCode,
Detail: "перевод на ИИС (в запросе договор ИИС) — входящие переводы на ИИС не принимаем, отказ по всем бумагам"})
Detail: "перевод на ИИС (в запросе договор ИИС) — приём ИИС выключен, отказ по всем бумагам"})
}
ruOK := strings.HasPrefix(isin, "RU")
sr.Checks = append(sr.Checks, depoCheck{Name: "Российский выпуск (RU-ISIN)", OK: ruOK, Code: "M2M01",
@@ -431,7 +441,7 @@ func runDepoCheck(it *IncomingTransfer) depoResult {
qualOnly, qdet = depoSecurityQualOnly(ctx, db, valID)
}
sr.Checks = append(sr.Checks, qualOnlyCheck(found, qualOnly, qdet, p))
sr.Checks = append(sr.Checks, partyChecks(p, it.DocSeries, it.DocNumber, fio)...)
sr.Checks = append(sr.Checks, partyChecks(p, it.DocSeries, it.DocNumber, fio, incomingIsIIS(it) && m2mAcceptIIS())...)
blockOK, blockDet := true, "не проверяется — нет открытого счёта или бумаги"
if p.accOpen != "" && found {
blockOK, blockDet = depoBlocking(ctx, db, valID, p.clientID)
@@ -556,7 +566,7 @@ func runDepoCheckOutAccount(last, first, middle, ser, num, birth string, secs []
qualOnly, qdet = depoSecurityQualOnly(ctx, db, valID)
}
sr.Checks = append(sr.Checks, outQualCheck(found, qualOnly, qdet, p))
sr.Checks = append(sr.Checks, partyChecks(p, ser, num, fio)...)
sr.Checks = append(sr.Checks, partyChecks(p, ser, num, fio, false)...)
if birthCheck != nil {
sr.Checks = append(sr.Checks, *birthCheck)
}
+3 -3
View File
@@ -5,17 +5,17 @@ import "testing"
func TestPartyChecksFIO(t *testing.T) {
p := depoParty{faceOK: true, faceName: "Сёмин Пётр Ильич", accAny: "ВТНКЦ00000001"}
p.fioMatch = normFIO(p.faceName) == normFIO("СЕМИН петр ильич")
if c := partyChecks(p, "4500", "123456", "СЕМИН петр ильич")[0]; !c.OK {
if c := partyChecks(p, "4500", "123456", "СЕМИН петр ильич", false)[0]; !c.OK {
t.Errorf("регистр, пробелы и ё не должны давать расхождение: %+v", c)
}
p.fioMatch = normFIO(p.faceName) == normFIO("Сёмин Павел Ильич")
c := partyChecks(p, "4500", "123456", "Сёмин Павел Ильич")[0]
c := partyChecks(p, "4500", "123456", "Сёмин Павел Ильич", false)[0]
if c.OK || c.Code != "M2M04" {
t.Errorf("паспорт совпал, ФИО нет — ожидали отказ M2M04: %+v", c)
}
// Физлицо в картотеке без торгового счёта — не наш клиент: M2M04 первой проверкой.
noAcc := depoParty{faceOK: true, faceName: "Баранкин Сергей Сергеевич", fioMatch: true}
checks := partyChecks(noAcc, "3763", "290089", "Баранкин Сергей Сергеевич")
checks := partyChecks(noAcc, "3763", "290089", "Баранкин Сергей Сергеевич", false)
if c := checks[0]; c.OK || c.Code != "M2M04" {
t.Errorf("лицо без счёта — ожидали отказ M2M04: %+v", c)
}
+8 -5
View File
@@ -41,10 +41,13 @@ func TestAutoDecisionsPartialAndIIS(t *testing.T) {
}
it.IIS = true
d, _ = autoDecisions(it, r)
for _, x := range d {
if x.Confirm || x.RejectCode != "M2M06" {
t.Fatalf("ИИС не принимаем: %+v", d)
}
// Приём ИИС по умолчанию ВЫКЛ → отказ по всему переводу (всё-или-ничего).
// Бумага A (была к приёму) получает код ИИС M2M06, бумага B сохраняет свою причину M2M05.
if d[0].Confirm || d[1].Confirm {
t.Fatalf("ИИС выключен — принимать нельзя: %+v", d)
}
if d[0].RejectCode != "M2M06" || d[1].RejectCode != "M2M05" {
t.Fatalf("коды отказа ИИС: A=M2M06, B сохраняет M2M05: %+v", d)
}
legacy := &IncomingTransfer{RawXML: "<x><IIAAgreementDetails/></x>"}
if !incomingIsIIS(legacy) {
@@ -88,7 +91,7 @@ func TestOutQualCheck(t *testing.T) {
if c := outQualCheck(true, false, "ограничений по обращению нет (опция DEPO)", notQual); !c.OK || c.Warn {
t.Fatalf("бумага без ограничений — зелёный: %+v", c)
}
for _, c := range partyChecks(notQual, "1", "2", "x") {
for _, c := range partyChecks(notQual, "1", "2", "x", false) {
if strings.HasPrefix(c.Name, "Квал") {
t.Fatalf("общая жёлтая строка квалификации больше не нужна: %+v", c)
}
+51 -9
View File
@@ -162,20 +162,62 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
// transferringINN — ИНН нашей организации (передающий депозитарий в запросе, принимающий — в решении).
func transferringINN(s Settings) string { return orgINN(s) }
// iisRejectCode — код отказа по переводу на ИИС: M2M06 «Приостановление операций по счёту депо зачисления»
// (решение депозитария 07.10.2026; до этого — M2M07).
// iisRejectCode — код отказа по переводу на ИИС: M2M06 «Приостановление операций по счёту депо
// зачисления» (решение депозитария 07.10.2026; до этого — M2M07).
const iisRejectCode = "M2M06"
// iisDecisions — входящие переводы на ИИС не принимаем (решение 25.09.2026): отказ iisRejectCode по всем
// бумагам, даже если оператор отметил «принять». Остальные переводы — по каждой бумаге отдельно.
// iisDecisions применяет к ИИС-переводу правило «ВСЁ ИЛИ НИЧЕГО»: ИИС переводится только целиком,
// иначе у клиента окажется два ИИС с потерей льгот.
// - приём ИИС выключен (по умолчанию, AcceptIIS=false) → отказ по всем бумагам (iisRejectCode);
// - приём ИИС включён и подтверждена КАЖДАЯ бумага перевода → принимаем ИИС полностью;
// - приём ИИС включён, но хоть одна бумага не подтверждена → отказ по ВСЕМ бумагам (у бумаг с
// явной причиной отказа её код сохраняется, у остальных — iisRejectCode).
//
// Не-ИИС переводы не трогаем — решение по каждой бумаге отдельно.
func iisDecisions(it *IncomingTransfer, decisions []SecurityDecision) []SecurityDecision {
if it == nil || !incomingIsIIS(it) {
return decisions
}
out := make([]SecurityDecision, len(decisions))
for i, d := range decisions {
d.Confirm, d.RejectCode = false, iisRejectCode
out[i] = d
rejectAll := func() []SecurityDecision {
explicit := make(map[string]SecurityDecision, len(decisions))
for _, d := range decisions {
explicit[d.ReferenceID] = d
}
out := make([]SecurityDecision, 0, len(it.Securities))
for _, sec := range it.Securities {
d := SecurityDecision{ReferenceID: sec.ReferenceID, RejectCode: iisRejectCode}
if e, ok := explicit[sec.ReferenceID]; ok && !e.Confirm && e.RejectCode != "" {
d.RejectCode = e.RejectCode // сохраняем реальную причину отказа оператора/сверки
}
out = append(out, d)
}
return out
}
return out
if !m2mAcceptIIS() {
return rejectAll() // приём ИИС выключен — отклоняем перевод целиком
}
if len(it.Securities) == 0 {
return rejectAll()
}
// Принимаем ИИС, только если подтверждена КАЖДАЯ бумага перевода.
confirm := make(map[string]bool, len(decisions))
for _, d := range decisions {
if d.Confirm {
confirm[d.ReferenceID] = true
}
}
for _, sec := range it.Securities {
if !confirm[sec.ReferenceID] {
return rejectAll() // хоть одна бумага не принимается — отказ по всему ИИС
}
}
return decisions // все бумаги подтверждены — принимаем ИИС полностью
}
// m2mAcceptIIS — включён ли приём входящих переводов на ИИС в текущих настройках.
func m2mAcceptIIS() bool {
if globalRC == nil {
return false
}
return globalRC.AcceptIIS()
}
+41
View File
@@ -289,6 +289,47 @@ func m2mAutoForm16(ctx context.Context, rc *RuntimeConfig, d outgoingDeps, od *O
}
// saveM2MAuto — POST /admin/setup/m2m-auto.
// saveAcceptIIS — POST /admin/setup/accept-iis: приём входящих переводов на ИИС (всё-или-ничего).
func (h *setupHandlers) saveAcceptIIS(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
cur := h.rc.AcceptIIS()
on := r.FormValue("enabled") != ""
if m := r.FormValue("mode"); m != "" {
on = m == "on"
}
ajax := r.Header.Get("X-BJ-Ajax") != ""
reply := func(msg string) {
if ajax {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(msg))
return
}
setupFlash(w, r, "Приём ИИС: "+msg)
}
if on == cur {
reply("без изменений")
return
}
if err := h.rc.UpdateAcceptIIS(on); err != nil {
reply("ошибка сохранения: " + err.Error())
return
}
who := ""
if u := currentUser(w); u != nil {
who = u.Login
}
if on {
log.Printf("accept-iis: приём входящих переводов на ИИС ВКЛЮЧЁН (%s)", who)
reply("включён — ИИС принимается только ЦЕЛИКОМ: все бумаги на ИИС-счёт клиента, иначе отказ по всему переводу")
return
}
log.Printf("accept-iis: приём входящих переводов на ИИС выключен (%s)", who)
reply("выключен — входящие переводы на ИИС отклоняются целиком (M2M06)")
}
func (h *setupHandlers) saveM2MAuto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
+17
View File
@@ -21,6 +21,22 @@ type RuntimeConfig struct {
data Settings
}
// AcceptIIS — принимаем ли входящие переводы на ИИС (режим «всё или ничего»).
func (r *RuntimeConfig) AcceptIIS() bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.data.AcceptIIS
}
// UpdateAcceptIIS — включить/выключить приём входящих переводов на ИИС.
func (r *RuntimeConfig) UpdateAcceptIIS(on bool) error {
r.mu.Lock()
r.data.AcceptIIS = on
r.data.UpdatedAt = time.Now().UTC()
r.mu.Unlock()
return r.save()
}
// Settings — сериализуемое представление настроек.
type Settings struct {
Postgres PostgresSettings `json:"postgres"`
@@ -45,6 +61,7 @@ type Settings struct {
HTTPS HTTPSSettings `json:"https,omitempty"`
Time TimeSettings `json:"time,omitempty"`
WorkHours WorkHoursSettings `json:"work_hours,omitempty"` // рабочее время: запросы M2M вне него — отказ M2M06
AcceptIIS bool `json:"accept_iis,omitempty"` // принимать входящие переводы на ИИС (режим «всё или ничего»); по умолчанию выкл — ИИС отклоняется целиком
OneC OneCSettings `json:"onec,omitempty"` // база 1С бэк-офиса (модуль onec-back)
NSDEmu NSDEmuSettings `json:"nsd_emu,omitempty"` // эмулятор НРД (страница «Эмуляторы», только режим разработки)
MNPEmu MNPEmuSettings `json:"mnp_emu,omitempty"` // эмулятор МНП QUIK (страница «Эмуляторы», только режим разработки)
+1
View File
@@ -117,6 +117,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
registerQuikExportSetup(mux, h)
mux.HandleFunc("/admin/setup/versions/refresh", h.refreshVersionsHandler)
mux.HandleFunc("/admin/setup/m2m-auto", h.saveM2MAuto)
mux.HandleFunc("/admin/setup/accept-iis", h.saveAcceptIIS)
registerUsersSetup(mux, h)
mux.HandleFunc("/admin/setup/npr", h.saveNPR)
mux.HandleFunc("/admin/setup/npr/test", h.testNPR)
+20 -1
View File
@@ -32,14 +32,33 @@
</form>
<details style="margin-top:6px"><summary style="cursor:pointer;color:var(--accent);font-size:13px">Что bj делает сам в автоматическом режиме</summary>
<ul class="muted" style="font-size:13px;line-height:1.5;margin:8px 0 0;padding-left:18px">
<li><b>Входящие переводы</b> — отвечает по свежей сверке с DEPO, по каждой бумаге отдельно (возможен частичный приём). Переводы на ИИС — отказ M2M06; бумага для квалифицированных инвесторов, а у клиента нет квалификации, — отказ M2M08.</li>
<li><b>Входящие переводы</b> — отвечает по свежей сверке с DEPO, по каждой бумаге отдельно (возможен частичный приём). Переводы на ИИС — только ЦЕЛИКОМ (если включён приём ИИС ниже: все бумаги на ИИС-счёт клиента, иначе отказ по всему переводу; выключено — отказ M2M06). Бумага для квалифицированных инвесторов, а у клиента нет квалификации, — отказ M2M08.</li>
<li><b>Поручение 16/1</b> — подаёт, как только НРД пришлёт черновик после нашего согласия.</li>
<li><b>Исходящие переводы</b> — поручение 16 подаёт после согласия контрагента.</li>
<li>Если DEPO или шлюз НРД недоступны, bj повторяет каждые 3 минуты и шлёт оповещение; без сверки ничего не принимается.</li>
<li>При включении берутся переводы, пришедшие не раньше чем за сутки до этого. Кнопки в карточках остаются для ручной работы.</li>
</ul>
</details>
<form method="post" action="/admin/setup/accept-iis" id="accept-iis-form" class="dq" style="margin-top:10px;border-top:1px solid var(--border,#e5e7eb);padding-top:10px">
<label style="display:flex;gap:8px;align-items:flex-start;cursor:pointer">
<input type="checkbox" name="enabled" value="1" {{if .D.Settings.AcceptIIS}}checked{{end}} style="margin-top:3px">
<span><b>Принимать входящие переводы на ИИС</b><br><small class="muted">ИИС принимается только ЦЕЛИКОМ: все бумаги зачисляются на ИИС-счёт клиента, иначе отказ по всему переводу — частичного ИИС (двух ИИС у клиента с потерей льгот) не бывает. Выключено — любой перевод на ИИС отклоняется (M2M06).</small></span>
</label>
<p class="dq-note" id="accept-iis-st" aria-live="polite">{{if .D.Settings.AcceptIIS}}Включено: ИИС принимается целиком.{{else}}Выключено: переводы на ИИС отклоняются.{{end}}</p>
<noscript><button type="submit" class="btn">Сохранить</button></noscript>
</form>
</div>
<script>(function(){
var fi=document.getElementById("accept-iis-form"), si=document.getElementById("accept-iis-st");
if(fi&&window.fetch){
fi.addEventListener("change", function(){
fetch(fi.action,{method:"POST",body:new URLSearchParams(new FormData(fi)),headers:{"Content-Type":"application/x-www-form-urlencoded","X-BJ-Ajax":"1"},cache:"no-store"})
.then(function(r){ return r.text().then(function(t){ si.textContent=(r.ok?"\u2713 ":"\u2717 ")+t; }); })
.catch(function(){ si.textContent="\u2717 Не сохранилось — обновите страницу."; });
});
fi.addEventListener("submit", function(e){ e.preventDefault(); });
}
})();</script>
<script>(function(){
var f=document.getElementById('m2m-auto-form'), st=document.getElementById('m2m-auto-st'); if(!f||!window.fetch) return;
f.addEventListener('change', function(){