Files
Bridge-and-Join-s/internal/lkgateway/quikdb.go
T
zuevav da12a9ec1c 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>
2026-09-15 19:41:12 +03:00

376 lines
16 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
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
}