From 4b62d00ac28c3ee497c0a6230d98dc0c1424d28d Mon Sep 17 00:00:00 2001 From: zuevav Date: Wed, 16 Sep 2026 14:45:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=85=D0=BE=D0=B4=D1=8F=D1=89=D0=B8?= =?UTF-8?q?=D0=B5=20M2M:=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=86=D0=B5=D0=BB=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=B8=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20M2M08=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B1=D1=83=D0=BC=D0=B0=D0=B3=20=C2=AB?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=B2=D0=B0=D0=BB=D0=B8=D1=84=D0=B8?= =?UTF-8?q?=D1=86=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=B8=D0=BD=D0=B2=D0=B5=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сверка с DEPO: у бумаги проверяется опция RESTRICTED («Ограничение ценной бумаги по обращению», 1 — предназначена для квалифицированных инвесторов). Если инвестор не квалифицирован — отказ M2M08; квалифицирован по всем видам — проходит; по части видов — предупреждение, решает оператор. Пакет принимается только целиком (решение 16.09): при отказе хотя бы по одной бумаге рекомендация — отказ по всем с кодом первой отказной, и так же нормализуется отправляемое решение (packageDecisions); в форме выбор отказа по одной бумаге переключает на отказ все. Co-Authored-By: Claude Opus 5 (1M context) --- internal/lkgateway/depo_check.go | 102 ++++++++++++++---- internal/lkgateway/depo_package_test.go | 73 +++++++++++++ internal/lkgateway/depoqual_live_test.go | 2 +- internal/lkgateway/incoming_answer.go | 33 ++++++ .../web/templates/admin_incoming_detail.html | 15 ++- 5 files changed, 201 insertions(+), 24 deletions(-) create mode 100644 internal/lkgateway/depo_package_test.go diff --git a/internal/lkgateway/depo_check.go b/internal/lkgateway/depo_check.go index 4eea5c2..b8d9251 100644 --- a/internal/lkgateway/depo_check.go +++ b/internal/lkgateway/depo_check.go @@ -84,6 +84,7 @@ type depoParty struct { iisDet string qualOK bool qualWarn bool + qualAll bool // квалифицирован по всем видам бумаг qualDet string } @@ -150,7 +151,7 @@ func depoResolveParty(ctx context.Context, db *sql.DB, fs FancyMSSQLSettings, se p.qualWarn, p.qualDet = true, "инвестор не найден — квал не проверялся" return p } - p.qualWarn, p.qualDet = depoFaceQualified(ctx, db, p.faceID) + p.qualWarn, p.qualAll, p.qualDet = depoFaceQualified(ctx, db, p.faceID) return p } @@ -169,8 +170,8 @@ var depoQualKinds = []string{ // depoFaceQualified — признак квалифицированного инвестора: действующая на сегодня опция лица // IS_QUAL («Вхождение в реестр лиц, признанных квалифицированными инвесторами»). Значение — -// маска видов бумаг (511 и -1 — все виды, 0 — ни одного). Возвращает (предупреждение, пояснение). -func depoFaceQualified(ctx context.Context, db *sql.DB, faceID int64) (bool, string) { +// маска видов бумаг (511 и -1 — все виды, 0 — ни одного). Возвращает (предупреждение, по всем видам, пояснение). +func depoFaceQualified(ctx context.Context, db *sql.DB, faceID int64) (warn, all bool, det string) { var val sql.NullString err := db.QueryRowContext(ctx, `SELECT TOP 1 CAST(o.VAL AS varchar(20)) FROM OD_OPTIONS o JOIN OP_FIELDS f ON f.ID = o.DESCR AND f.AS_NAME = 'IS_QUAL' @@ -178,21 +179,20 @@ func depoFaceQualified(ctx context.Context, db *sql.DB, faceID int64) (bool, str ORDER BY o.B_DATE DESC`, sql.Named("p1", faceID)).Scan(&val) const notQual = " — если бумага только для квал., нужен отказ M2M08; решает оператор" if errors.Is(err, sql.ErrNoRows) { - return true, "инвестор НЕ квалифицированный (нет опции «Вхождение в реестр квалифицированных инвесторов» в DEPO)" + notQual + return true, false, "инвестор НЕ квалифицированный (нет опции «Вхождение в реестр квалифицированных инвесторов» в DEPO)" + notQual } if err != nil { - return true, "признак квалифицированного инвестора не прочитан: " + quikErrorDetail(err) + return true, false, "признак квалифицированного инвестора не прочитан: " + quikErrorDetail(err) } mask, e := strconv.ParseInt(strings.TrimSpace(val.String), 10, 64) if e != nil { - return true, fmt.Sprintf("признак квалифицированного инвестора в DEPO не распознан (%q) — решает оператор", val.String) + return true, false, fmt.Sprintf("признак квалифицированного инвестора в DEPO не распознан (%q) — решает оператор", val.String) } if mask == 0 { - return true, "инвестор НЕ квалифицированный (опция в DEPO = 0)" + notQual + return true, false, "инвестор НЕ квалифицированный (опция в DEPO = 0)" + notQual } - all := mask < 0 || mask&511 == 511 - if all { - return false, "инвестор — квалифицированный по всем видам бумаг (опция DEPO)" + if mask < 0 || mask&511 == 511 { + return false, true, "инвестор — квалифицированный по всем видам бумаг (опция DEPO)" } var kinds []string for bit, name := range depoQualKinds { @@ -200,7 +200,7 @@ func depoFaceQualified(ctx context.Context, db *sql.DB, faceID int64) (bool, str kinds = append(kinds, name) } } - return false, "инвестор — квалифицированный (опция DEPO): " + strings.Join(kinds, "; ") + return false, false, "инвестор — квалифицированный (опция DEPO): " + strings.Join(kinds, "; ") } // depoAccountIIS — относится ли торговый счёт (OD_CLIENTS.ID) к договору ИИС: название договора @@ -248,6 +248,46 @@ func depoSecurity(ctx context.Context, db *sql.DB, isin string) (id int64, name return } +// depoSecurityQualOnly — бумага «Предназначена для квалифицированных инвесторов»: действующая на сегодня +// опция бумаги RESTRICTED («Ограничение ценной бумаги по обращению», 1 — для квал. инвесторов). +func depoSecurityQualOnly(ctx context.Context, db *sql.DB, valID int64) (bool, string) { + var val sql.NullString + err := db.QueryRowContext(ctx, `SELECT TOP 1 CAST(o.VAL AS varchar(20)) FROM OD_OPTIONS o + JOIN OP_FIELDS f ON f.ID = o.DESCR AND f.AS_NAME = 'RESTRICTED' + WHERE o.OBJECT = @p1 AND o.B_DATE <= GETDATE() AND (o.E_DATE IS NULL OR o.E_DATE > GETDATE()) + ORDER BY o.B_DATE DESC`, sql.Named("p1", valID)).Scan(&val) + switch { + case errors.Is(err, sql.ErrNoRows): + return false, "ограничений по обращению нет (опция DEPO)" + case err != nil: + return false, "ограничение бумаги по обращению не прочитано: " + quikErrorDetail(err) + case strings.TrimSpace(val.String) == "1": + return true, "бумага предназначена для квалифицированных инвесторов (опция DEPO)" + } + return false, "ограничений по обращению нет (опция DEPO)" +} + +// qualOnlyCheck — бумага только для квал. инвесторов против квалификации инвестора. +func qualOnlyCheck(found, qualOnly bool, qdet string, p depoParty) depoCheck { + c := depoCheck{Name: "Бумага только для квал. инвесторов", OK: true, Code: "M2M08", Detail: qdet} + switch { + case !found: + c.Detail = "не проверяется — бумаги нет в справочнике" + case !qualOnly: + case !p.faceOK: + c.Detail += " — инвестор не найден, квалификация не проверялась" + case p.qualAll: + c.Detail += " — инвестор квалифицированный по всем видам бумаг" + case p.qualWarn: + c.OK = false + c.Detail += " — инвестор НЕ квалифицированный: операция нарушит закон РФ" + default: + c.Warn = true + c.Detail += " — " + p.qualDet + "; вид бумаги сверяет оператор" + } + return c +} + // depoBlocking — есть ли блокировка бумаги на счёте (BLOCK_DATE либо остаток на 98070.05/.07/.08/.09). func depoBlocking(ctx context.Context, db *sql.DB, valID, clientID int64) (ok bool, det string) { var blockDate sql.NullTime @@ -281,22 +321,35 @@ func finishSec(sr *depoSecResult) { } } +// finishResult — итог по переводу. Пакет принимается только целиком (решение 16.09.2026): если хотя +// бы по одной бумаге отказ, отказ рекомендуется по всем бумагам с кодом первой отказной. func finishResult(res *depoResult) { - confirmN, rejectN := 0, 0 - for _, s := range res.Secs { - if s.Recommend == "confirm" { - confirmN++ - } else { - rejectN++ + first := -1 + for i, s := range res.Secs { + if s.Recommend != "confirm" { + first = i + break } } switch { - case rejectN == 0 && confirmN > 0: + case len(res.Secs) == 0: + case first < 0: res.Recommend, res.Summary = "confirm", "принять" - case confirmN == 0 && rejectN > 0: - res.Recommend, res.Summary = "reject", "отказ "+res.Secs[0].RejectCode - case confirmN > 0 && rejectN > 0: - res.Recommend, res.Summary = "mixed", fmt.Sprintf("принять %d, отказ %d", confirmN, rejectN) + 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 + ")"}) + } + } + res.Recommend, res.Summary = "reject", "отказ "+code + if passed > 0 { + res.Summary = fmt.Sprintf("отказ пакетом %s (из-за %s)", code, sec) + } } } @@ -352,6 +405,11 @@ func runDepoCheck(it *IncomingTransfer) depoResult { Detail: pick3(found, vname+" (ID "+fmt.Sprint(valID)+")", "ISIN "+isin+" в справочнике OD_VALUES нет")}) sr.Checks = append(sr.Checks, depoCheck{Name: "Принята на обслуживание (галка)", OK: accepted, Code: "M2M05", Detail: pick3(!found, "не проверяется — бумаги нет в справочнике", pick3(accepted, "галка стоит (IS_IN=1)", "бумага есть, но галка НЕ стоит (IS_IN≠1)"))}) + qualOnly, qdet := false, "" + if found { + 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)...) blockOK, blockDet := true, "не проверяется — нет открытого счёта или бумаги" if p.accOpen != "" && found { diff --git a/internal/lkgateway/depo_package_test.go b/internal/lkgateway/depo_package_test.go new file mode 100644 index 0000000..0fc18a7 --- /dev/null +++ b/internal/lkgateway/depo_package_test.go @@ -0,0 +1,73 @@ +package lkgateway + +import ( + "strings" + "testing" +) + +// Пакет принимается только целиком: отказ по одной бумаге — отказ по всем с тем же кодом. +func TestFinishResultWholePackage(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") { + 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 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) + } +} + +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) + } + } + 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) + } +} + +func TestQualOnlyCheck(t *testing.T) { + notQual := depoParty{faceOK: true, qualWarn: true, qualDet: "инвестор НЕ квалифицированный"} + qualAll := depoParty{faceOK: true, qualAll: true} + partial := depoParty{faceOK: true, qualDet: "инвестор — квалифицированный (опция DEPO): облигации"} + const q = "бумага предназначена для квалифицированных инвесторов (опция DEPO)" + + if c := qualOnlyCheck(true, true, q, notQual); c.OK || c.Code != "M2M08" { + t.Fatalf("неквал + бумага для квал: %+v", c) + } + if c := qualOnlyCheck(true, true, q, qualAll); !c.OK || c.Warn { + t.Fatalf("квал по всем видам: %+v", c) + } + if c := qualOnlyCheck(true, true, q, partial); !c.OK || !c.Warn { + t.Fatalf("квал по части видов — решает оператор: %+v", c) + } + if c := qualOnlyCheck(true, false, "ограничений по обращению нет (опция DEPO)", notQual); !c.OK || c.Warn { + t.Fatalf("бумага без ограничений: %+v", c) + } + if c := qualOnlyCheck(true, true, q, depoParty{}); !c.OK { + t.Fatalf("инвестор не найден — отказ даёт M2M04, не эта проверка: %+v", c) + } +} diff --git a/internal/lkgateway/depoqual_live_test.go b/internal/lkgateway/depoqual_live_test.go index c1f731b..c236d20 100644 --- a/internal/lkgateway/depoqual_live_test.go +++ b/internal/lkgateway/depoqual_live_test.go @@ -36,7 +36,7 @@ func TestDepoQualifiedLive(t *testing.T) { if rows.Scan(&v, &face) != nil { continue } - warn, det := depoFaceQualified(ctx, db, face) + warn, _, det := depoFaceQualified(ctx, db, face) t.Logf("VAL=%s лицо %d: предупреждение=%v — %s", v, face, warn, det) } } diff --git a/internal/lkgateway/incoming_answer.go b/internal/lkgateway/incoming_answer.go index c60bf0b..fef1a9b 100644 --- a/internal/lkgateway/incoming_answer.go +++ b/internal/lkgateway/incoming_answer.go @@ -47,6 +47,8 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions } nsd := cfg.NSD + decisions = packageDecisions(decisions) + // Карта решений по ReferenceID. byRef := make(map[string]SecurityDecision, len(decisions)) for _, d := range decisions { @@ -151,3 +153,34 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions func transferringINN(_ Settings) string { return "7703807489" } + +// 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 == "" { + return decisions + } + out := make([]SecurityDecision, len(decisions)) + for i, d := range decisions { + if d.Confirm { + d.Confirm, d.RejectCode = false, code + } + out[i] = d + } + return out +} diff --git a/internal/lkgateway/web/templates/admin_incoming_detail.html b/internal/lkgateway/web/templates/admin_incoming_detail.html index b44aa55..feec25c 100644 --- a/internal/lkgateway/web/templates/admin_incoming_detail.html +++ b/internal/lkgateway/web/templates/admin_incoming_detail.html @@ -138,6 +138,7 @@ {{end}} +

Пакет принимается только целиком: при отказе по одной бумаге отказ ставится всем бумагам с тем же кодом (так и будет отправлено).

@@ -155,7 +156,19 @@ if(dash) dash.style.display = show ? 'none' : ''; } document.querySelectorAll('input.dec').forEach(function(r){ - r.addEventListener('change', function(){ toggle(r.getAttribute('data-ref')); }); + 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){ document.querySelectorAll('input.dec[value="'+val+'"]').forEach(function(r){