Входящие M2M: пакет только целиком и отказ M2M08 для бумаг «для квалифицированных инвесторов»

Сверка с DEPO: у бумаги проверяется опция RESTRICTED («Ограничение ценной бумаги по обращению»,
1 — предназначена для квалифицированных инвесторов). Если инвестор не квалифицирован — отказ M2M08;
квалифицирован по всем видам — проходит; по части видов — предупреждение, решает оператор.

Пакет принимается только целиком (решение 16.09): при отказе хотя бы по одной бумаге рекомендация —
отказ по всем с кодом первой отказной, и так же нормализуется отправляемое решение (packageDecisions);
в форме выбор отказа по одной бумаге переключает на отказ все.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-16 14:45:43 +03:00
parent be0081c139
commit 4b62d00ac2
5 changed files with 201 additions and 24 deletions
+80 -22
View File
@@ -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 {
+73
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
}
+33
View File
@@ -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
}
@@ -138,6 +138,7 @@
{{end}}
</tbody>
</table>
<p class="muted" style="font-size:12.5px;margin:10px 0 0">Пакет принимается только целиком: при отказе по одной бумаге отказ ставится всем бумагам с тем же кодом (так и будет отправлено).</p>
<div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">
<button type="submit" class="btn btn-ok">Отправить решение роботу</button>
<button type="button" class="btn" onclick="m2mSetAll('confirm')">Принять все</button>
@@ -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){