fdf0ddd498
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
623 lines
31 KiB
Go
623 lines
31 KiB
Go
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,
|
||
accSQL(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 — итог по переводу. Частичный приём (решение 25.09.2026): каждая бумага решается сама
|
||
// по себе — прошедшие проверки принимаются, остальным отказ со своим кодом. НРД так допускает: по
|
||
// каждой принятой бумаге приходит свой черновик 16/1 и подаётся своё поручение. Переводы на ИИС не
|
||
// принимаются совсем — это отдельная проверка по каждой бумаге (отказ всем).
|
||
func finishResult(res *depoResult) {
|
||
conf, rej, code, sec := 0, 0, "", ""
|
||
for _, s := range res.Secs {
|
||
if s.Recommend == "confirm" {
|
||
conf++
|
||
continue
|
||
}
|
||
rej++
|
||
if code == "" {
|
||
code, sec = s.RejectCode, s.Security
|
||
}
|
||
}
|
||
switch {
|
||
case len(res.Secs) == 0:
|
||
case rej == 0:
|
||
res.Recommend, res.Summary = "confirm", "принять"
|
||
case conf == 0:
|
||
res.Recommend, res.Summary = "reject", "отказ "+code
|
||
default:
|
||
res.Recommend = "partial"
|
||
res.Summary = fmt.Sprintf("частично: принять %d, отказ %d (первый — %s по %s)", conf, rej, 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, "у инвестора нет счёта "+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: "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)}
|
||
if incomingIsIIS(it) { // решение 25.09.2026: входящие переводы на ИИС не принимаем совсем
|
||
sr.Checks = append(sr.Checks, depoCheck{Name: "Перевод не на ИИС", OK: false, Code: "M2M07",
|
||
Detail: "перевод на ИИС (в запросе договор ИИС) — входящие переводы на ИИС не принимаем, отказ по всем бумагам"})
|
||
}
|
||
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
|
||
}
|