From c73f6120d35eeeeab10ac4f1c7eb564b1b0339d1 Mon Sep 17 00:00:00 2001 From: fontvielle Date: Wed, 14 Oct 2026 10:43:21 +0300 Subject: [PATCH] =?UTF-8?q?M2M=20=D0=98=D0=98=D0=A1:=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D1=91=D0=BC=20=C2=AB=D0=B2=D1=81=D1=91=20=D0=B8=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B8=D1=87=D0=B5=D0=B3=D0=BE=C2=BB=20(=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20AcceptIIS,=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8E=20=D0=B2=D1=8B=D0=BA=D0=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правильная обработка входящих переводов на ИИС вместо прежнего сплошного отказа. Настройка 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 --- internal/lkgateway/depo_check.go | 22 +++++-- internal/lkgateway/depo_check_test.go | 6 +- internal/lkgateway/depo_package_test.go | 13 ++-- internal/lkgateway/incoming_answer.go | 60 ++++++++++++++++--- internal/lkgateway/m2mauto.go | 41 +++++++++++++ internal/lkgateway/runtimeconfig.go | 17 ++++++ internal/lkgateway/setup.go | 1 + .../lkgateway/web/templates/part_nsd.html | 21 ++++++- 8 files changed, 157 insertions(+), 24 deletions(-) diff --git a/internal/lkgateway/depo_check.go b/internal/lkgateway/depo_check.go index 1572c80..e70eddb 100644 --- a/internal/lkgateway/depo_check.go +++ b/internal/lkgateway/depo_check.go @@ -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) } diff --git a/internal/lkgateway/depo_check_test.go b/internal/lkgateway/depo_check_test.go index 3ce4ce3..5147da2 100644 --- a/internal/lkgateway/depo_check_test.go +++ b/internal/lkgateway/depo_check_test.go @@ -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) } diff --git a/internal/lkgateway/depo_package_test.go b/internal/lkgateway/depo_package_test.go index cee3e8b..c6dedfe 100644 --- a/internal/lkgateway/depo_package_test.go +++ b/internal/lkgateway/depo_package_test.go @@ -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: ""} 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) } diff --git a/internal/lkgateway/incoming_answer.go b/internal/lkgateway/incoming_answer.go index 3137211..4c61aee 100644 --- a/internal/lkgateway/incoming_answer.go +++ b/internal/lkgateway/incoming_answer.go @@ -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() } diff --git a/internal/lkgateway/m2mauto.go b/internal/lkgateway/m2mauto.go index e2c6a3e..11739ec 100644 --- a/internal/lkgateway/m2mauto.go +++ b/internal/lkgateway/m2mauto.go @@ -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) diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index be78518..9e2d8ed 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -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 (страница «Эмуляторы», только режим разработки) diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 2e0054a..ac02af6 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -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) diff --git a/internal/lkgateway/web/templates/part_nsd.html b/internal/lkgateway/web/templates/part_nsd.html index 99770ff..1a0f70a 100644 --- a/internal/lkgateway/web/templates/part_nsd.html +++ b/internal/lkgateway/web/templates/part_nsd.html @@ -32,14 +32,33 @@
Что bj делает сам в автоматическом режиме
    -
  • Входящие переводы — отвечает по свежей сверке с DEPO, по каждой бумаге отдельно (возможен частичный приём). Переводы на ИИС — отказ M2M06; бумага для квалифицированных инвесторов, а у клиента нет квалификации, — отказ M2M08.
  • +
  • Входящие переводы — отвечает по свежей сверке с DEPO, по каждой бумаге отдельно (возможен частичный приём). Переводы на ИИС — только ЦЕЛИКОМ (если включён приём ИИС ниже: все бумаги на ИИС-счёт клиента, иначе отказ по всему переводу; выключено — отказ M2M06). Бумага для квалифицированных инвесторов, а у клиента нет квалификации, — отказ M2M08.
  • Поручение 16/1 — подаёт, как только НРД пришлёт черновик после нашего согласия.
  • Исходящие переводы — поручение 16 подаёт после согласия контрагента.
  • Если DEPO или шлюз НРД недоступны, bj повторяет каждые 3 минуты и шлёт оповещение; без сверки ничего не принимается.
  • При включении берутся переводы, пришедшие не раньше чем за сутки до этого. Кнопки в карточках остаются для ручной работы.
+
+ +

{{if .D.Settings.AcceptIIS}}Включено: ИИС принимается целиком.{{else}}Выключено: переводы на ИИС отклоняются.{{end}}

+ +
+