Входящие 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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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){
|
||||
|
||||
Reference in New Issue
Block a user