Files
Bridge-and-Join-s/internal/lkgateway/depo_check.go
T
zuevav 7ce4c2c79f Защита рецептов: SQL DEPO/SPECTRE/QUIK вынесен в пакет fsql под garble
Реализация варианта B из аудита защиты знаний. Прямой garble всего bj-server
ломает html/template (он обращается к полям структур по имени, garble их
переименовывает). Решение — селективная обфускация: GOGARBLE обфусцирует ТОЛЬКО
пакет с секретами, шаблоны и обработчики не затрагиваются.

95 SQL-запросов к DEPO/SPECTRE/QUIK и трансферным таблицам FTV_M2M вынесены из
lkgateway в отдельный пакет internal/lkgateway/fsql как переменные (именно var,
не const: const компилятор вставил бы обратно в необфусцируемый пакет, и
шифрование бы не сработало — проверено). Сами обработчики, типы и параметры
sql.Named остались на месте; пакет держит только строки запросов.

build-release.sh собирает bj-server с `GOGARBLE=.../fsql garble -literals`
(нужен отдельный SDK go1.26 — модуль-кэшный тулчейн garble не подходит); если
garble/go1.26 не найдены — обычная сборка с предупреждением.

Проверено на реальном garble-бинаре (запускается, /admin и страницы на
структурах работают): рецепт чтения чужих систем из бинаря исчез —
OD_RESTS 13→0, OD_WIRING 5→0, OD_TURNS 1→0, OD_BALANCES 3→0, D_B_CONTRACTS 1→0,
OD_CLIENTS_PORTS 10→0, DepoLimits/MoneyLimits →0. Остаток FTV_M2M — упоминания
имён таблиц в операторских embed-шаблонах и логах, не сам рецепт.

Стенд собран обычной сборкой (без garble) — трассировки остаются читаемыми.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 20:29:47 +03:00

623 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
// depo_check.go — сверка переводов с учётной базой Fancy DEPO и
// ЭМУЛЯЦИЯ ответа. Регламент депозитария:
//
// ЗАЧИСЛЕНИЕ (входящий, мы принимаем):
// RU-ISIN · бумага есть в справочнике · принята на обслуживание (галка IS_IN) ·
// ФИО+документ инвестора · торговый счёт ВТНКЦ… ЕСТЬ · статус строго «открыт» ·
// счёт не ИИС · квал-инвестор (если бумага только для квал.) · нет блокировок.
//
// СПИСАНИЕ (исходящий, мы отправляем): те же проверки инвестора/счёта/бумаги плюс
// остаток K ≥ количеству на торговом счёте (98070.04) · место хранения строго
// счёт HL171004001C / раздел с началом 36MC04136 · нет блокировки на синтетике
// 98070.05/.07/.08/.09 · счёт не ИИС. (Комиссии — отложено.)
//
// Где что лежит: инвестор/счета/бумаги/остатки — DEPO (OD_FACES, OD_CLIENTS,
// OD_VALUES, OD_RESTS.K); ИИС — название брокерского договора счёта в DEPO
// (OD_CLIENTS_PORTS.PORT_NUM, признак из настроек, у нас «БРИИС-»); квал — опция лица в DEPO
// «Вхождение в реестр лиц, признанных квалифицированными инвесторами» (OP_FIELDS.AS_NAME=IS_QUAL,
// значение в OD_OPTIONS — маска видов бумаг). Сопоставление бумаги с видом пока не делаем, поэтому
// неквал — предупреждение, а не отказ. НИЧЕГО НЕ ОТПРАВЛЯЕМ: рекомендация показывается оператору.
import (
"context"
"database/sql"
"errors"
"fmt"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
"strconv"
"strings"
"time"
)
// Синтетика 98070.xx (OD_BALANCES.ID): .04 «на торгах» = торговый; блокирующие:
// .05 к поставке (837), .07 без первичных документов (3410), .08 транзит (4342),
// .09 ограниченные в обращении (5647).
const depoTradeBalAcc = 836
var depoBlockingBalAccs = []int64{837, 3410, 4342, 5647}
// depoCheck — одна проверка по бумаге.
type depoCheck struct {
Name string
Detail string
OK bool
Code string // код причины отказа (M2Mxx или QTY/PLACE)
Warn bool // пройдено с предупреждением
Strict bool // предупреждение, которое автоматический режим считает отказом
}
// depoSecResult — результат по одной бумаге.
type depoSecResult struct {
ReferenceID string
Security string
Checks []depoCheck
Recommend string // "confirm" | "reject"
RejectCode string
}
// depoResult — результат сверки по переводу.
type depoResult struct {
Ran bool
Err string
Mode string // "in" зачисление | "out" списание
Investor string
Account string
Secs []depoSecResult
Recommend string // "confirm" | "reject" | "mixed"
Summary string
}
// depoOutSec — бумага исходящего перевода для сверки списания.
type depoOutSec struct{ ReferenceID, ISIN, Code, Qty string }
// depoParty — инвестор и его счёт/договоры, общая часть обеих сверок.
type depoParty struct {
faceOK bool
faceID int64
faceName string
birth sql.NullTime // OD_FACES.DATE_BIRTH
fioMatch bool
accAny string // любой счёт ВТНКЦ… (есть ли вообще)
accOpen string // открытый счёт ВТНКЦ…
clientID int64 // OD_CLIENTS.ID открытого счёта = OD_RESTS.REG_1
iisOK bool
iisDet string
qualOK bool
qualWarn bool
qualAll bool // квалифицирован по всем видам бумаг
qualDet string
}
func depoOpen(fs FancyMSSQLSettings) (*sql.DB, context.Context, context.CancelFunc, string) {
db, err := fancyOpen(fs, fs.DepoDB())
if err != nil {
return nil, nil, nil, "DEPO недоступна: " + err.Error()
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := db.PingContext(ctx); err != nil {
cancel()
db.Close()
return nil, nil, nil, "DEPO недоступна: " + err.Error()
}
return db, ctx, cancel, ""
}
// depoResolveParty находит инвестора (DEPO), его торговые счета и признаки
// ИИС (договор счёта) и квал (опция лица) — всё в DEPO.
func depoResolveParty(ctx context.Context, db *sql.DB, fs FancyMSSQLSettings, ser, num, fio string) depoParty {
var p depoParty
ser = strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
num = strings.TrimSpace(num)
if ser != "" && num != "" {
e := db.QueryRowContext(ctx,
// Карточка лица в DEPO версионная: действующая версия (LAST_FLAG=1) даёт ФИО и дату рождения,
// а счета и опции привязаны к исходной записи (SELF_ID).
fsql.Chk01,
sql.Named("p1", ser), sql.Named("p2", num)).Scan(&p.faceID, &p.faceName, &p.birth)
p.faceOK = e == nil
}
p.fioMatch = p.faceOK && normFIO(p.faceName) == normFIO(fio)
if p.faceOK {
rows, e := db.QueryContext(ctx,
fsql.Chk02,
sql.Named("p1", p.faceID))
if e == nil {
for rows.Next() {
var id, state int64
var number string
var dclose sql.NullTime
if rows.Scan(&id, &number, &state, &dclose) != nil {
continue
}
if p.accAny == "" {
p.accAny = number
}
if p.accOpen == "" && state == 0 && !dclose.Valid {
p.accOpen, p.clientID = number, id
}
}
rows.Close()
}
}
// ИИС — по названию брокерского договора торгового счёта в DEPO (признак из настроек).
p.iisOK, p.iisDet = true, "инвестор не найден — ИИС не проверялся"
if p.faceOK {
p.iisOK, p.iisDet = depoAccountIIS(ctx, db, fs, p.clientID)
}
// Квалифицированный инвестор — опция лица в DEPO.
p.qualOK = true
if !p.faceOK {
p.qualWarn, p.qualDet = true, "инвестор не найден — квал не проверялся"
return p
}
p.qualWarn, p.qualAll, p.qualDet = depoFaceQualified(ctx, db, p.faceID)
return p
}
// depoQualKinds — виды бумаг опции IS_QUAL по номерам битов (справочник поля в DEPO).
var depoQualKinds = []string{
"финансовые инструменты, не квалифицированные в качестве ценных бумаг",
"государственные и муниципальные ценные бумаги",
"акции и облигации российских эмитентов",
"государственные ценные бумаги иностранных государств",
"акции и облигации иностранных эмитентов",
"депозитарные расписки",
"паи инвестиционных фондов",
"ипотечные сертификаты участия",
"договоры, являющиеся производными финансовыми инструментами",
}
// depoFaceQualified — признак квалифицированного инвестора: действующая на сегодня опция лица
// IS_QUAL («Вхождение в реестр лиц, признанных квалифицированными инвесторами»). Значение —
// маска видов бумаг (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, fsql.Chk03, sql.Named("p1", faceID)).Scan(&val)
const notQual = " — если бумага только для квал., нужен отказ M2M08; решает оператор"
if errors.Is(err, sql.ErrNoRows) {
return true, false, "инвестор НЕ квалифицированный (нет опции «Вхождение в реестр квалифицированных инвесторов» в DEPO)" + notQual
}
if err != nil {
return true, false, "признак квалифицированного инвестора не прочитан: " + quikErrorDetail(err)
}
mask, e := strconv.ParseInt(strings.TrimSpace(val.String), 10, 64)
if e != nil {
return true, false, fmt.Sprintf("признак квалифицированного инвестора в DEPO не распознан (%q) — решает оператор", val.String)
}
if mask == 0 {
return true, false, "инвестор НЕ квалифицированный (опция в DEPO = 0)" + notQual
}
if mask < 0 || mask&511 == 511 {
return false, true, "инвестор — квалифицированный по всем видам бумаг (опция DEPO)"
}
var kinds []string
for bit, name := range depoQualKinds {
if mask&(1<<bit) != 0 {
kinds = append(kinds, name)
}
}
return false, false, "инвестор — квалифицированный (опция DEPO): " + strings.Join(kinds, "; ")
}
// depoAccountIIS — относится ли торговый счёт (OD_CLIENTS.ID) к договору ИИС: название договора
// (OD_CLIENTS_PORTS.PORT_NUM) начинается с признака ИИС из настроек (у нас «БРИИС-»). У договора
// ИИС в DEPO свой торговый счёт, поэтому проверяется именно счёт перевода, а не клиент целиком.
func depoAccountIIS(ctx context.Context, db *sql.DB, fs FancyMSSQLSettings, clientID int64) (bool, string) {
if clientID == 0 {
return true, "не проверяется — нет открытого счёта клиента"
}
rows, err := db.QueryContext(ctx, fsql.Chk04, sql.Named("p1", clientID))
if err != nil {
return true, "договор счёта не прочитан: " + quikErrorDetail(err)
}
defer rows.Close()
var contracts []string
for rows.Next() {
var port string
if rows.Scan(&port) != nil {
continue
}
port = strings.TrimSpace(port)
contract, _, _ := strings.Cut(port, " ")
for _, m := range fs.IISMarkers() {
if strings.HasPrefix(strings.ToUpper(port), strings.ToUpper(m)) {
return false, "счёт относится к договору ИИС " + contract + " — перевод с ИИС не допускается"
}
}
if contract != "" && !containsStr(contracts, contract) {
contracts = append(contracts, contract)
}
}
if len(contracts) == 0 {
return true, "договор счёта в DEPO не найден — признаков ИИС нет"
}
return true, "договор " + strings.Join(contracts, ", ") + " — не ИИС"
}
// depoSecurity — бумага в справочнике DEPO.
func depoSecurity(ctx context.Context, db *sql.DB, isin string) (id int64, name string, found, accepted bool) {
var isIn sql.NullInt64
e := db.QueryRowContext(ctx, fsql.Chk05,
sql.Named("p1", isin)).Scan(&id, &name, &isIn)
found = e == nil
accepted = found && isIn.Valid && isIn.Int64 == 1
return
}
// depoSecurityQualOnly — бумага «Предназначена для квалифицированных инвесторов»: действующая на сегодня
// опция бумаги RESTRICTED («Ограничение ценной бумаги по обращению», 1 — для квал. инвесторов).
func depoSecurityQualOnly(ctx context.Context, db *sql.DB, valID int64) (bool, string) {
var val sql.NullString
err := db.QueryRowContext(ctx, fsql.Chk06, 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 — ЗАЧИСЛЕНИЕ: бумага только для квал. инвесторов против квалификации инвестора у нас
// (не квалифицирован — отказ M2M08; бумага без ограничений — квалификация не требуется).
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:
c.Detail += " — квалификация клиента не требуется"
case !p.faceOK:
c.Detail += " — инвестор не найден, квалификация не проверялась"
case p.qualAll:
c.Detail += " — инвестор квалифицированный по всем видам бумаг"
case p.qualWarn:
c.OK = false
c.Detail += " — инвестор НЕ квалифицированный: операция нарушит закон РФ"
default:
c.Warn, c.Strict = true, true
c.Detail += " — " + p.qualDet + "; вид бумаги сверяет оператор (в автоматическом режиме — отказ M2M08)"
}
return c
}
// outQualCheck — СПИСАНИЕ: бумага только для квал. инвесторов, а наш клиент у нас не квалифицирован —
// предупреждение, не отказ: у принимающего депозитария клиент может быть квалифицирован, решает он.
func outQualCheck(found, qualOnly bool, qdet string, p depoParty) depoCheck {
c := depoCheck{Name: "Бумага только для квал. инвесторов", OK: true, Code: "M2M08", Detail: qdet}
switch {
case !found:
c.Detail = "не проверяется — бумаги нет в справочнике"
case !qualOnly:
c.Detail += " — квалификация клиента не требуется"
case !p.faceOK:
c.Detail += " — инвестор не найден, квалификация не проверялась"
case p.qualAll:
c.Detail += " — клиент квалифицированный по всем видам бумаг"
default:
c.Warn = true
c.Detail += " — у нас клиент не квалифицирован полностью (" + p.qualDet + "); принимающий депозитарий проверит квалификацию у себя и может отказать M2M08"
}
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
var blockReason sql.NullString
var balAcc sql.NullInt64
var k sql.NullFloat64
er := db.QueryRowContext(ctx,
fsql.Chk07,
sql.Named("p1", valID), sql.Named("p2", clientID)).Scan(&blockDate, &blockReason, &balAcc, &k)
if er != nil {
return true, "регистра бумаги на счёте нет — блокировок нет"
}
switch {
case blockDate.Valid:
return false, "блокировка с " + blockDate.Time.Format("02.01.2006") + " " + strings.TrimSpace(blockReason.String)
case balAcc.Valid && isBlockingBal(balAcc.Int64) && k.Valid && k.Float64 != 0:
return false, fmt.Sprintf("остаток %.0f на синтетике 98070.xx (BAL_ACC %d) — бумага блокирована", k.Float64, balAcc.Int64)
}
return true, "блокировок нет (BLOCK_DATE пуст, на 98070.05/.07/.08/.09 остатка нет)"
}
func finishSec(sr *depoSecResult) {
sr.Recommend = "confirm"
for _, c := range sr.Checks {
if !c.OK {
sr.Recommend, sr.RejectCode = "reject", c.Code
return
}
}
}
// finishResult — итог по переводу. Пакет принимается только целиком (решение 16.09.2026): если хотя
// бы по одной бумаге отказ, отказ рекомендуется по всем бумагам с кодом первой отказной.
func finishResult(res *depoResult) {
first := -1
for i, s := range res.Secs {
if s.Recommend != "confirm" {
first = i
break
}
}
switch {
case len(res.Secs) == 0:
case first < 0:
res.Recommend, res.Summary = "confirm", "принять"
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)
}
}
}
// partyChecks — общие проверки инвестора/счёта/ИИС/квал (одинаковы для обеих сверок).
func partyChecks(p depoParty, ser, num, fio string) []depoCheck {
ser = strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
return []depoCheck{
// Инвестор — наш клиент: лицо по паспорту с тем же ФИО И хотя бы один торговый счёт (договор).
// Физлицо в картотеке без счёта у нас — не клиент депозитария: отказ M2M04, а не M2M02 (16.09.2026).
{Name: "ФИО + документ инвестора", OK: p.faceOK && p.fioMatch && p.accAny != "", Code: "M2M04",
Detail: pick3(p.faceOK, pick3(p.fioMatch,
pick3(p.accAny != "", p.faceName, p.faceName+" — есть в картотеке лиц, но торгового счёта (договора) у нас нет: не наш клиент"),
"паспорт найден, но ФИО не совпадает: в картотеке «"+p.faceName+"», в запросе «"+fio+"»"),
"паспорт "+ser+" № "+num+" не найден в картотеке (OD_FACES)")},
{Name: "Торговый счёт депо есть", OK: p.accAny != "", Code: "M2M02",
Detail: pick3(p.accAny != "", p.accAny, pick3(p.faceOK, "у инвестора нет счёта ВТНКЦ… в 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: "M2M08", Detail: p.iisDet},
}
}
// runDepoCheck — сверка ВХОДЯЩЕГО (зачисление). Не отправляет ничего.
func runDepoCheck(it *IncomingTransfer) depoResult {
res := depoResult{Mode: "in"}
if globalRC == nil {
res.Err = "настройки недоступны"
return res
}
fs := globalRC.Snapshot().Fancy
if strings.TrimSpace(fs.Server) == "" {
res.Err = "подключение к MSSQL (Обмен Fancy) не настроено"
return res
}
db, ctx, cancel, errS := depoOpen(fs)
if errS != "" {
res.Err = errS
return res
}
defer cancel()
defer db.Close()
res.Ran = true
fio := strings.TrimSpace(it.InvestorLast + " " + it.InvestorFirst + " " + it.InvestorMiddle)
p := depoResolveParty(ctx, db, fs, it.DocSeries, it.DocNumber, fio)
res.Investor, res.Account = p.faceName, p.accOpen
for _, s := range it.Securities {
isin := strings.ToUpper(strings.TrimSpace(s.ISIN))
sr := depoSecResult{ReferenceID: s.ReferenceID, Security: pick(isin, s.SecurityCode)}
ruOK := strings.HasPrefix(isin, "RU")
sr.Checks = append(sr.Checks, depoCheck{Name: "Российский выпуск (RU-ISIN)", OK: ruOK, Code: "M2M01",
Detail: pick3(ruOK, "допускается к переводам M2M", "не-RU ISIN — к M2M не допускается")})
valID, vname, found, accepted := depoSecurity(ctx, db, isin)
sr.Checks = append(sr.Checks, depoCheck{Name: "Бумага есть в справочнике", OK: found, Code: "M2M05",
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 {
blockOK, blockDet = depoBlocking(ctx, db, valID, p.clientID)
}
sr.Checks = append(sr.Checks, depoCheck{Name: "Нет блокировок по бумаге", OK: blockOK, Code: "M2M06", Detail: blockDet})
finishSec(&sr)
res.Secs = append(res.Secs, sr)
}
finishResult(&res)
return res
}
// normFIO — ФИО для сравнения: регистр, лишние пробелы, ё = е.
func normFIO(s string) string {
s = strings.NewReplacer("ё", "е", "Ё", "Е").Replace(s)
return strings.ToUpper(strings.Join(strings.Fields(s), " "))
}
// runDepoCheckOut — сверка ИСХОДЯЩЕГО (списание) без счёта и даты рождения клиента (песочница, тесты).
func runDepoCheckOut(last, first, middle, ser, num string, secs []depoOutSec, accountID, sectionID string) depoResult {
return runDepoCheckOutAccount(last, first, middle, ser, num, "", secs, accountID, sectionID, "")
}
// runDepoCheckOutAccount — сверка ИСХОДЯЩЕГО (списание). accountID/sectionID — место расчётов из
// запроса; ожидаемые значения берём из настроек НРД (наш счёт). clientAccount — торговый счёт
// клиента из заявки (ЛК): должен принадлежать найденному по паспорту инвестору и быть открыт,
// остаток и блокировки считаются по нему. birth — дата рождения клиента из ЛК (ГГГГ-ММ-ДД),
// сверяется с картотекой DEPO.
func runDepoCheckOutAccount(last, first, middle, ser, num, birth string, secs []depoOutSec, accountID, sectionID, clientAccount string) depoResult {
res := depoResult{Mode: "out"}
if globalRC == nil {
res.Err = "настройки недоступны"
return res
}
snap := globalRC.Snapshot()
fs := snap.Fancy
if strings.TrimSpace(fs.Server) == "" {
res.Err = "подключение к MSSQL (Обмен Fancy) не настроено"
return res
}
db, ctx, cancel, errS := depoOpen(fs)
if errS != "" {
res.Err = errS
return res
}
defer cancel()
defer db.Close()
res.Ran = true
fio := strings.TrimSpace(last + " " + first + " " + middle)
p := depoResolveParty(ctx, db, fs, ser, num, fio)
var accCheck *depoCheck
if want := strings.ToUpper(strings.TrimSpace(clientAccount)); want != "" {
c := depoCheck{Name: "Счёт из заявки принадлежит инвестору и открыт", Code: "M2M02"}
switch {
case !p.faceOK:
c.Detail = "не проверяется — инвестор не найден"
default:
var id, state int64
var dclose sql.NullTime
err := db.QueryRowContext(ctx, fsql.Chk08,
sql.Named("p1", p.faceID), sql.Named("p2", want)).Scan(&id, &state, &dclose)
switch {
case err != nil:
c.Detail = "счёт " + want + " из заявки не принадлежит инвестору" + pick3(p.accAny != "", " (его счёт в DEPO: "+p.accAny+")", "")
p.accOpen, p.clientID = "", 0
case state != 0 || dclose.Valid:
c.Detail = "счёт " + want + " из заявки принадлежит инвестору, но не открыт (закрыт или STATE≠0)"
p.accOpen, p.clientID = "", 0
default:
c.OK, c.Detail = true, want+" — совпадает со счётом инвестора в DEPO, открыт"
p.accOpen, p.clientID = want, id
}
}
accCheck = &c
if p.faceOK { // ИИС — по договору именно этого счёта
p.iisOK, p.iisDet = depoAccountIIS(ctx, db, fs, p.clientID)
}
}
var birthCheck *depoCheck
if b := strings.TrimSpace(birth); b != "" {
c := depoCheck{Name: "Дата рождения совпадает с картотекой", Code: "M2M04", OK: true}
want, err := time.Parse("2006-01-02", b[:min(10, len(b))])
switch {
case err != nil:
c.Warn, c.Detail = true, "дата рождения из заявки «"+b+"» не разобрана — не сверялась"
case !p.faceOK:
c.Detail = "не проверяется — инвестор не найден"
case !p.birth.Valid:
c.Warn, c.Detail = true, "в картотеке DEPO дата рождения не заполнена — не сверялась"
case p.birth.Time.Format("2006-01-02") != want.Format("2006-01-02"):
c.OK, c.Detail = false, "в заявке "+want.Format("02.01.2006")+", в картотеке "+p.birth.Time.Format("02.01.2006")
default:
c.Detail = want.Format("02.01.2006") + " — совпадает"
}
birthCheck = &c
}
res.Investor, res.Account = p.faceName, p.accOpen
wantAcc := strings.ToUpper(strings.TrimSpace(pick(snap.NSD.AccountID, "HL171004001C")))
wantSecPfx := strings.ToUpper(strings.TrimSpace(snap.NSD.SectionID))
if len(wantSecPfx) >= 9 {
wantSecPfx = wantSecPfx[:9] // 36MC04136 — дальше «X» по регламенту
} else {
wantSecPfx = "36MC04136"
}
gotAcc := strings.ToUpper(strings.TrimSpace(accountID))
gotSec := strings.ToUpper(strings.TrimSpace(sectionID))
placeOK := gotAcc == wantAcc && strings.HasPrefix(gotSec, wantSecPfx)
for _, s := range secs {
isin := strings.ToUpper(strings.TrimSpace(s.ISIN))
sr := depoSecResult{ReferenceID: s.ReferenceID, Security: pick(isin, s.Code)}
ruOK := strings.HasPrefix(isin, "RU")
sr.Checks = append(sr.Checks, depoCheck{Name: "Российский выпуск (RU-ISIN)", OK: ruOK, Code: "M2M01",
Detail: pick3(ruOK, "допускается к переводам M2M", "не-RU ISIN — к M2M не допускается")})
valID, vname, found, _ := depoSecurity(ctx, db, isin)
sr.Checks = append(sr.Checks, depoCheck{Name: "Бумага есть в справочнике", OK: found, Code: "M2M05",
Detail: pick3(found, vname+" (ID "+fmt.Sprint(valID)+")", "ISIN "+isin+" в справочнике OD_VALUES нет")})
qualOnly, qdet := false, ""
if found {
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)...)
if birthCheck != nil {
sr.Checks = append(sr.Checks, *birthCheck)
}
if accCheck != nil {
sr.Checks = append(sr.Checks, *accCheck)
}
// остаток K ≥ количеству на торговом счёте (98070.04)
need, _ := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(s.Qty), ",", "."), 64)
qtyOK, qtyDet := false, pick3(!found, "не проверяется — бумаги нет в справочнике", "не проверяется — нет открытого счёта клиента")
if p.accOpen != "" && found {
var have float64
_ = db.QueryRowContext(ctx,
fsql.Chk09,
sql.Named("p1", valID), sql.Named("p2", p.clientID), sql.Named("p3", depoTradeBalAcc)).Scan(&have)
qtyOK = have >= need && need > 0
qtyDet = fmt.Sprintf("на торговом счёте %s: %.0f шт., списываем %.0f", p.accOpen, have, need)
if !qtyOK {
qtyDet += " — недостаточно"
}
}
sr.Checks = append(sr.Checks, depoCheck{Name: "Остаток ≥ количеству (98070.04)", OK: qtyOK, Code: "QTY", Detail: qtyDet})
// место хранения строго: наш счёт в НРД / раздел с началом 36MC04136
sr.Checks = append(sr.Checks, depoCheck{Name: "Место хранения строго наш торговый счёт", OK: placeOK, Code: "PLACE",
Detail: pick3(placeOK, gotAcc+" / "+gotSec+" — совпадает", "в запросе "+pick(gotAcc, "—")+" / "+pick(gotSec, "—")+", ожидается "+wantAcc+" / "+wantSecPfx+"…")})
blockOK, blockDet := true, pick3(!found, "не проверяется — бумаги нет в справочнике", "не проверяется — нет открытого счёта клиента")
if p.accOpen != "" && found {
blockOK, blockDet = depoBlocking(ctx, db, valID, p.clientID)
}
sr.Checks = append(sr.Checks, depoCheck{Name: "Нет блокировки на синтетике 98070.xx", OK: blockOK, Code: "M2M06", Detail: blockDet})
sr.Checks = append(sr.Checks, depoCheck{Name: "Задолженность по комиссиям", OK: true, Warn: true, Code: "",
Detail: "отложено по регламенту — информация у бэк-офиса"})
finishSec(&sr)
res.Secs = append(res.Secs, sr)
}
finishResult(&res)
return res
}
func isBlockingBal(id int64) bool {
for _, b := range depoBlockingBalAccs {
if b == id {
return true
}
}
return false
}
func pick(a, b string) string {
if a != "" {
return a
}
return b
}
func pick3(cond bool, yes, no string) string {
if cond {
return yes
}
return no
}