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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (страница «Эмуляторы», только режим разработки)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(){
|
||||
|
||||
Reference in New Issue
Block a user