da12a9ec1c
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
376 lines
16 KiB
Go
376 lines
16 KiB
Go
package lkgateway
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
mssql "github.com/microsoft/go-mssqldb"
|
||
)
|
||
|
||
// mssql18456States — расшифровка State для ошибки 18456 (Login failed).
|
||
// Помогает коллегам понять точную причину отказа логина.
|
||
var mssql18456States = map[uint8]string{
|
||
1: "сервер СКРЫВАЕТ точную причину от внешних клиентов (защита). Реальный State — только в SQL Server ERRORLOG на стороне QUIK. Попросите коллег глянуть ERRORLOG: там будет 8=неверный пароль, 5=нет логина, 38/40=недоступна БД по умолчанию",
|
||
2: "неверный ID пользователя (логина нет)",
|
||
5: "неверный ID пользователя (логина нет)",
|
||
6: "попытка входа SQL-логином, но настроена только Windows-аутентификация",
|
||
7: "логин отключён И неверный пароль",
|
||
8: "НЕВЕРНЫЙ ПАРОЛЬ",
|
||
9: "недопустимый пароль",
|
||
11: "вход разрешён, но доступ к серверу запрещён (нет прав)",
|
||
12: "вход разрешён, но доступ к серверу запрещён (нет прав)",
|
||
18: "требуется смена пароля (пароль истёк)",
|
||
38: "не найдена/недоступна база данных, указанная в подключении",
|
||
40: "не удалось открыть БД по умолчанию для этого логина",
|
||
46: "не найдена БД по умолчанию для логина",
|
||
}
|
||
|
||
// quikErrorDetail разбирает ошибку подключения и возвращает подробную
|
||
// диагностику (для копирования и показа коллегам/аутсорсеру).
|
||
func quikErrorDetail(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
var b strings.Builder
|
||
var me mssql.Error
|
||
if errors.As(err, &me) {
|
||
fmt.Fprintf(&b, "Ответ сервера MSSQL:\n")
|
||
fmt.Fprintf(&b, " Сообщение: %s\n", strings.TrimSpace(me.Message))
|
||
fmt.Fprintf(&b, " Номер ошибки: %d\n", me.Number)
|
||
fmt.Fprintf(&b, " State: %d", me.State)
|
||
if me.Number == 18456 {
|
||
if s, ok := mssql18456States[me.State]; ok {
|
||
fmt.Fprintf(&b, " → %s", s)
|
||
} else {
|
||
fmt.Fprintf(&b, " → (см. таблицу state для 18456)")
|
||
}
|
||
}
|
||
b.WriteByte('\n')
|
||
fmt.Fprintf(&b, " Class (severity):%d\n", me.Class)
|
||
if me.ServerName != "" {
|
||
fmt.Fprintf(&b, " Сервер: %s\n", me.ServerName)
|
||
}
|
||
if me.ProcName != "" {
|
||
fmt.Fprintf(&b, " Процедура: %s\n", me.ProcName)
|
||
}
|
||
if me.LineNo != 0 {
|
||
fmt.Fprintf(&b, " Строка: %d\n", me.LineNo)
|
||
}
|
||
}
|
||
fmt.Fprintf(&b, "Сырая ошибка драйвера:\n %s", err.Error())
|
||
return b.String()
|
||
}
|
||
|
||
// Коннектор к БД Модуля неторговых поручений QUIK (MSSQL). БД БОЕВАЯ — здесь
|
||
// только чтение (версия, метаданные, справочники) и, позже, штатные хранимые
|
||
// процедуры API МНП (роль InstrEu). Прямых INSERT/UPDATE в таблицы нет.
|
||
|
||
// quikDSN строит ADO-строку подключения (пароль как есть, без URL-кодирования —
|
||
// у BROKER_88 пароль со спецсимволами). db="" → без указания БД.
|
||
func quikDSN(s QuikMNPSettings) string {
|
||
port := s.Port
|
||
if port == 0 {
|
||
port = 1433
|
||
}
|
||
enc := s.Encrypt
|
||
if enc == "" {
|
||
enc = "true" // боевой сервер обычно требует шифрование
|
||
}
|
||
parts := []string{
|
||
fmt.Sprintf("server=%s", s.Server),
|
||
fmt.Sprintf("port=%d", port),
|
||
fmt.Sprintf("user id=%s", s.User),
|
||
fmt.Sprintf("password=%s", s.Password),
|
||
fmt.Sprintf("encrypt=%s", enc),
|
||
"connection timeout=12",
|
||
"app name=bj-server",
|
||
}
|
||
if s.Trust {
|
||
parts = append(parts, "TrustServerCertificate=true")
|
||
}
|
||
if s.Database != "" {
|
||
parts = append(parts, fmt.Sprintf("database=%s", s.Database))
|
||
}
|
||
return strings.Join(parts, ";")
|
||
}
|
||
|
||
func quikOpen(s QuikMNPSettings) (*sql.DB, error) {
|
||
conn, err := sql.Open("sqlserver", quikDSN(s))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
conn.SetConnMaxLifetime(5 * time.Minute)
|
||
conn.SetMaxOpenConns(3)
|
||
return conn, nil
|
||
}
|
||
|
||
// QuikInstruction — неторговое поручение M2M из экспортной таблицы Export.
|
||
// Только чтение: bj забирает поручения на вывод ЦБ (M2M), которые клиент подал
|
||
// через QUIK/ЛК и по которым бумаги уже заблокированы на позиции клиента.
|
||
type QuikInstruction struct {
|
||
ExtID string // внешний ID (наш идентификатор, если поручение заводили мы)
|
||
InstrID int64 // ID поручения в МНП
|
||
Number string // регистрационный номер
|
||
Type int // тип поручения
|
||
Status int // статус (см. quikStatusName)
|
||
StatusName string
|
||
ClientID string // код клиента QUIK
|
||
AgreeServ string // договор обслуживания (= contract_number, напр. BR200)
|
||
ISIN string
|
||
SecCode string
|
||
ClassCode string
|
||
Volume float64 // количество (с 21.4.0 для Ввод/Вывод ЦБ — в ШТУКАХ)
|
||
QtyBlocked int // сколько заблокировано
|
||
QtyScale int // масштаб (дробные)
|
||
Account string // торговый счёт
|
||
DepoAccount string // счёт депо (составной)
|
||
DepoCP string // счёт депо контрагента (получателя)
|
||
SubDepoCP string // раздел счёта контрагента
|
||
RecipINN string // M2MDepoRecipientINN — ИНН депозитария получателя
|
||
PassExpense bool // PassExpensesInfo — передавать информацию о расходах
|
||
CloseIIA bool // AgreeCloseIIA — согласие на закрытие ИИС
|
||
Registered string // дата/время регистрации (ГГГГ-ММ-ДД ЧЧ:ММ:СС)
|
||
Deny string // причина отказа/снятия (QueryDeny/CommentDeny)
|
||
}
|
||
|
||
// quikStatusName — человекочитаемый статус поручения МНП.
|
||
func quikStatusName(v int) string {
|
||
switch v {
|
||
case 0:
|
||
return "Активно"
|
||
case 1:
|
||
return "Исполнено"
|
||
case 2:
|
||
return "Снято"
|
||
case 3:
|
||
return "Отменено"
|
||
case 4:
|
||
return "Запрос на отмену"
|
||
default:
|
||
return fmt.Sprintf("код %d", v)
|
||
}
|
||
}
|
||
|
||
// quikFmtDateTime собирает дату/время из формата МНП (ГГГГММДД + ЧЧММСС).
|
||
func quikFmtDateTime(d, t int) string {
|
||
if d == 0 {
|
||
return ""
|
||
}
|
||
s := fmt.Sprintf("%02d.%02d.%04d", d%100, (d/100)%100, d/10000)
|
||
if t > 0 {
|
||
s += fmt.Sprintf(" %02d:%02d:%02d", t/10000, (t/100)%100, t%100)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// quikListM2MInstructions читает поручения M2M из Export (ТОЛЬКО SELECT).
|
||
// onlyActive=true — только ожидающие обработки (статус «Активно»).
|
||
func quikListM2MInstructions(ctx context.Context, s QuikMNPSettings, limit int, onlyActive bool) ([]QuikInstruction, error) {
|
||
conn, err := quikOpen(s)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer conn.Close()
|
||
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||
defer cancel()
|
||
if limit <= 0 || limit > 500 {
|
||
limit = 200
|
||
}
|
||
where := "WHERE ISNULL(IsM2MInstruction,0)=1"
|
||
if onlyActive {
|
||
where += " AND ISNULL(Status,0)=0"
|
||
}
|
||
q := fmt.Sprintf(`SELECT TOP (%d)
|
||
ISNULL(ExtID,''), ISNULL(InstrID,0), ISNULL(Number,0), ISNULL(Type,0), ISNULL(Status,0),
|
||
ISNULL(ClientID,''), ISNULL(AgreeServ,''), ISNULL(ISIN,''), ISNULL(SecCode,''), ISNULL(ClassCode,''),
|
||
ISNULL(Volume,0), ISNULL(QtyBlocked,0), ISNULL(QtyScale,0),
|
||
ISNULL(Account,''), ISNULL(DepoAccount,''), ISNULL(DepoCP,''), ISNULL(SubDepoCP,''),
|
||
ISNULL(M2MDepoRecipientINN,''), ISNULL(PassExpensesInfo,0), ISNULL(AgreeCloseIIA,0),
|
||
ISNULL(RegisterDate,0), ISNULL(RegisterTime,0),
|
||
ISNULL(QueryDeny,''), ISNULL(CommentDeny,'')
|
||
FROM Export %s ORDER BY InstrID DESC`, limit, where)
|
||
|
||
rows, err := conn.QueryContext(cctx, q)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []QuikInstruction
|
||
for rows.Next() {
|
||
var it QuikInstruction
|
||
var num float64
|
||
var rd, rt int
|
||
var qd, cd string
|
||
if err := rows.Scan(&it.ExtID, &it.InstrID, &num, &it.Type, &it.Status,
|
||
&it.ClientID, &it.AgreeServ, &it.ISIN, &it.SecCode, &it.ClassCode,
|
||
&it.Volume, &it.QtyBlocked, &it.QtyScale,
|
||
&it.Account, &it.DepoAccount, &it.DepoCP, &it.SubDepoCP,
|
||
&it.RecipINN, &it.PassExpense, &it.CloseIIA,
|
||
&rd, &rt, &qd, &cd); err != nil {
|
||
return out, err
|
||
}
|
||
it.Number = strings.TrimSuffix(fmt.Sprintf("%.0f", num), ".0")
|
||
it.StatusName = quikStatusName(it.Status)
|
||
it.Registered = quikFmtDateTime(rd, rt)
|
||
it.Deny = strings.TrimSpace(qd + " " + cd)
|
||
out = append(out, it)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// QuikSecCheck — результат сверки одной бумаги заявки со справочником QUIK.
|
||
type QuikSecCheck struct {
|
||
ISIN string
|
||
Requested float64
|
||
Found bool // бумага есть в справочнике QUIK
|
||
SecCode string // код инструмента QUIK (напр. GAZP)
|
||
Name string // наименование выпуска по данным QUIK
|
||
SecType string // тип бумаги
|
||
GosRegNum string
|
||
ClassID int
|
||
LotSize int
|
||
Note string
|
||
}
|
||
|
||
// QuikCheckResult — итог сверки заявки с данными QUIK (ТОЛЬКО ЧТЕНИЕ).
|
||
// Проверяет наличие клиента и бумаг в справочниках QUIK. Остаток бумаг здесь
|
||
// не проверяется — он проверяется сервером QUIK при регистрации поручения.
|
||
type QuikCheckResult struct {
|
||
Contract string
|
||
ClientFound bool
|
||
ClientID string // код клиента в QUIK (из Contracts)
|
||
ClientName string // ФИО по данным QUIK (из ClientInfo)
|
||
DepoAccount string // счёт депо клиента в QUIK
|
||
Securities []QuikSecCheck
|
||
AllSecsFound bool
|
||
Summary string
|
||
}
|
||
|
||
// quikVerifyRequest сверяет заявку (клиент + бумаги) со справочниками QUIK.
|
||
// Только SELECT: Contracts, ClientInfo, DepoClientAccounts, QuikSecurities.
|
||
func quikVerifyRequest(ctx context.Context, s QuikMNPSettings, contract string, secs []QuikSecCheck) (QuikCheckResult, error) {
|
||
res := QuikCheckResult{Contract: contract, AllSecsFound: true}
|
||
conn, err := quikOpen(s)
|
||
if err != nil {
|
||
return res, err
|
||
}
|
||
defer conn.Close()
|
||
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||
defer cancel()
|
||
|
||
// 1. Клиент по номеру договора.
|
||
err = conn.QueryRowContext(cctx,
|
||
"SELECT TOP 1 ClientID FROM Contracts WHERE Number=@p1", contract).Scan(&res.ClientID)
|
||
if err == nil && res.ClientID != "" {
|
||
res.ClientFound = true
|
||
_ = conn.QueryRowContext(cctx,
|
||
"SELECT TOP 1 ISNULL(FullName,'') FROM ClientInfo WHERE ClientCode=@p1", res.ClientID).Scan(&res.ClientName)
|
||
_ = conn.QueryRowContext(cctx,
|
||
"SELECT TOP 1 ISNULL(AccountNumber,'') FROM DepoClientAccounts WHERE ClientID=@p1", res.ClientID).Scan(&res.DepoAccount)
|
||
}
|
||
|
||
// 2. Бумаги по ISIN.
|
||
for i := range secs {
|
||
sc := &secs[i]
|
||
var classID, lot int
|
||
var code, name, typ, gos string
|
||
e := conn.QueryRowContext(cctx, `SELECT TOP 1 ISNULL(SecCode,''), ISNULL(DepoName,''),
|
||
ISNULL(SecTypeDesc,''), ISNULL(GosRegNum,''), ISNULL(ClassId,0), ISNULL(LotSize,0)
|
||
FROM QuikSecurities WHERE ISIN=@p1 ORDER BY LotSize`, sc.ISIN).
|
||
Scan(&code, &name, &typ, &gos, &classID, &lot)
|
||
if e != nil {
|
||
sc.Found = false
|
||
sc.Note = "нет в справочнике QUIK"
|
||
res.AllSecsFound = false
|
||
} else {
|
||
sc.Found = true
|
||
sc.SecCode, sc.Name, sc.SecType, sc.GosRegNum = code, name, typ, gos
|
||
sc.ClassID, sc.LotSize = classID, lot
|
||
}
|
||
}
|
||
res.Securities = secs
|
||
switch {
|
||
case !res.ClientFound && !res.AllSecsFound:
|
||
res.Summary = "клиент не найден в QUIK, часть бумаг отсутствует в справочнике"
|
||
case !res.ClientFound:
|
||
res.Summary = "бумаги в справочнике QUIK есть, но клиент по договору не найден"
|
||
case !res.AllSecsFound:
|
||
res.Summary = "клиент найден, но часть бумаг отсутствует в справочнике QUIK"
|
||
default:
|
||
res.Summary = "сверка пройдена: клиент и все бумаги есть в QUIK"
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// QuikProbeResult — результат тест-подключения к БД МНП QUIK (только чтение).
|
||
type QuikProbeResult struct {
|
||
OK bool
|
||
Version string // @@VERSION (первая строка)
|
||
DBName string // DB_NAME()
|
||
Login string // SUSER_SNAME()
|
||
M2MReady bool // найдены ли ключевые M2M-объекты МНП
|
||
Objects map[string]bool
|
||
Note string
|
||
}
|
||
|
||
// quikProbe — тест подключения к БД МНП QUIK. ТОЛЬКО ЧТЕНИЕ: версия, текущая БД,
|
||
// логин, наличие ключевых объектов МНП/M2M (Export, M2MDepoRecipients, Contracts,
|
||
// класс INSTROUTM2M, хранимые процедуры). Никаких вызовов процедур и записи.
|
||
func quikProbe(ctx context.Context, s QuikMNPSettings) (QuikProbeResult, error) {
|
||
var r QuikProbeResult
|
||
r.Objects = map[string]bool{}
|
||
conn, err := quikOpen(s)
|
||
if err != nil {
|
||
return r, err
|
||
}
|
||
defer conn.Close()
|
||
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||
defer cancel()
|
||
|
||
if err := conn.PingContext(cctx); err != nil {
|
||
return r, err
|
||
}
|
||
// Версия, текущая БД, логин.
|
||
if err := conn.QueryRowContext(cctx,
|
||
"SELECT @@VERSION, DB_NAME(), SUSER_SNAME()").Scan(&r.Version, &r.DBName, &r.Login); err != nil {
|
||
return r, err
|
||
}
|
||
if i := strings.IndexAny(r.Version, "\r\n"); i > 0 {
|
||
r.Version = strings.TrimSpace(r.Version[:i])
|
||
}
|
||
r.OK = true
|
||
|
||
// Наличие ключевых таблиц МНП/M2M (read-only метаданные).
|
||
tables := []string{"Export", "Export2", "M2MDepoRecipients", "Contracts", "Instructions", "QuikSecurities"}
|
||
for _, t := range tables {
|
||
var n int
|
||
_ = conn.QueryRowContext(cctx,
|
||
"SELECT COUNT(*) FROM sys.tables WHERE name=@p1", t).Scan(&n)
|
||
r.Objects["table:"+t] = n > 0
|
||
}
|
||
// Класс INSTROUTM2M в справочнике классов (по нему регистрируется Вывод ЦБ М2М).
|
||
var cls int
|
||
_ = conn.QueryRowContext(cctx,
|
||
"SELECT COUNT(*) FROM sys.tables WHERE name IN ('IClasses','Classes')").Scan(&cls)
|
||
// Ключевые хранимые процедуры API.
|
||
procs := []string{"sp_NewInstrSec", "sp_SignInstrSec", "cancel_instruction"}
|
||
for _, p := range procs {
|
||
var n int
|
||
_ = conn.QueryRowContext(cctx,
|
||
"SELECT COUNT(*) FROM sys.procedures WHERE name=@p1", p).Scan(&n)
|
||
r.Objects["proc:"+p] = n > 0
|
||
}
|
||
|
||
r.M2MReady = r.Objects["table:Export"] && r.Objects["table:Contracts"] && r.Objects["proc:sp_NewInstrSec"]
|
||
if r.M2MReady {
|
||
r.Note = "БД похожа на МНП с поддержкой M2M — ключевые объекты найдены."
|
||
} else {
|
||
r.Note = "Подключение есть, но не все ключевые объекты МНП/M2M видны под этим логином (проверьте права/имя БД)."
|
||
}
|
||
return r, nil
|
||
}
|