feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- 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>
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user