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:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+375
View File
@@ -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
}