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
+282
View File
@@ -0,0 +1,282 @@
package lkgateway
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// Коннектор bj → QUIK МНП, ПИШУЩАЯ сторона M2M (регистрация/подпись/снятие).
// Читающая часть (Export, справочники) — в quikdb.go.
//
// Жизненный цикл вывода ЦБ (M2M), по «Перевод ЦБ в MOEX МОСТ» v1.1:
//
// 1. quikRegisterWithdrawal — sp_NewInstrSec (@InstructionType=9, @InstrTypeID=100,
// @CheckType=1): QUIK проверяет достаточность и БЛОКИРУЕТ бумаги, возвращает @ActionID.
// 2. quikActionResult — GetActionResult(@ActionID): QueryStatus (2 исполнено / 3 отвергнуто /
// 4 не обработано / 5 проверяется), ReplyText, InstrNo (= Number поручения).
// 3. читаем Export (quikListM2MInstructions) → Number активного поручения → в НРД.
// 4а. НРД подтвердил → quikSignWithdrawal (sp_SignInstrSec, @SignFlag=16 «Депозитарий»,
// @Volume=NULL): статус «Исполнено», блокировка снята, бумаги списаны.
// 4б. НРД отказал → quikDenyInstruction (sp_DenyInstr, @CancelText=причина): снятие,
// блокировка снимается автоматически.
// 5. приём от др. брокера → quikRegisterDeposit (sp_NewInstrSec, @InstructionType=8,
// @InstrTypeID=101, @InSecCode=NULL, @ISIN): регистрируется И сразу исполняется.
//
// БЕЗОПАСНОСТЬ: БД БОЕВАЯ. Все пишущие вызовы идут с @CheckType=1 (реальная
// транзакция на сервер QUIK) и защищены предохранителем quikWritesAllowed —
// пока типы 100/101 выключены (Disabled=y) и нет тестового клиента, писать нельзя.
// Идентификаторы M2M на стороне QUIK (фиксированы инструкцией + наши данные).
const (
quikFirmID = "MC0413600000" // наш код фирмы (таблица Firms, FirmId=2)
quikTypeWithdrawM2M = 100 // InstrTypeID «Вывод ЦБ М2М»
quikTypeDepositM2M = 101 // InstrTypeID «Ввод ЦБ М2М»
quikBaseTypeWithdraw = 9 // базовый Type для вывода (Поставка ЦБ из ДКК)
quikBaseTypeDeposit = 8 // базовый Type для ввода (Получение ЦБ в ДКК)
quikSecWithdraw = "RUROUTM2M" // МНП-инструмент вывода
quikSecDeposit = "RURINM2M" // МНП-инструмент ввода
quikSignFlagDepo = 16 // FLG_DEPO — подпись «Депозитарий»
quikCheckViaAPI = 1 // @CheckType=1 — через QuikAPI (проверка+блокировка)
)
// errQuikWritesDisabled — предохранитель: пишущие операции запрещены, пока
// администратор не завершил настройку в QAdmin (классы/типы) и не выделен
// безопасный контур/клиент для теста. Снимается через quikWritesAllowed.
var errQuikWritesDisabled = errors.New("quik: пишущие операции M2M отключены (нужны: включение типов 100/101 в QAdmin, классы INSTROUTM2M/INSTRINM2M, тестовый клиент)")
// quikWritesAllowed — глобальный предохранитель пишущих вызовов к боевой БД QUIK.
// По умолчанию false. Включается осознанно (флаг/настройка), когда контур готов.
var quikWritesAllowed = false
// QuikWithdrawParams — параметры регистрации поручения «Вывод ЦБ (M2M)».
type QuikWithdrawParams struct {
ExtID string // наш идентификатор заявки (связка с bj_transfer_requests.guid)
ClientID string // код клиента в QUIK
ISIN string // ISIN выводимой бумаги
Quantity float64 // количество (в ШТУКАХ — с 21.4.0)
Account string // торговый счёт клиента
DepoAccount string // счёт депо клиента (составной, при необходимости)
RecipientINN string // ИНН депозитария-получателя (из M2MDepoRecipients)
PassExpenses bool // передавать информацию о расходах (наш CostInfo)
QtyScale int // масштаб дробного количества (0 = целые)
}
// QuikActionResult — итог обработки команды Модулем (GetActionResult / Actions).
type QuikActionResult struct {
QueryStatus int // 1 новая, 2 исполнена, 3 отвергнута, 4 не обработана, 5 проверяется
ReplyText string // текст ответа/ошибки
InstrNo string // номер поручения (= Export.Number)
}
// quikStatusResultName — расшифровка QueryStatus (GetActionResult).
func quikStatusResultName(v int) string {
switch v {
case 1:
return "новая"
case 2:
return "исполнена"
case 3:
return "отвергнута"
case 4:
return "не может быть обработана"
case 5:
return "проверяется на сервере QUIK"
}
return fmt.Sprintf("код %d", v)
}
// quikRegisterWithdrawal регистрирует «Вывод ЦБ (M2M)» — QUIK проверяет
// достаточность и блокирует бумаги. Возвращает @ActionID для GetActionResult.
// НЕ вызывать на боевой БД, пока не снят предохранитель quikWritesAllowed.
func quikRegisterWithdrawal(ctx context.Context, s QuikMNPSettings, p QuikWithdrawParams) (int64, error) {
if !quikWritesAllowed {
return 0, errQuikWritesDisabled
}
if strings.TrimSpace(p.ClientID) == "" || strings.TrimSpace(p.ISIN) == "" || p.Quantity <= 0 {
return 0, errors.New("quik: не заданы ClientID/ISIN/Quantity для вывода")
}
conn, err := quikOpen(s)
if err != nil {
return 0, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var actionID int64
// @QuikClassCode=NULL (обязательно при @CheckType=1 для чужой фирмы) → поиск
// инструмента по @ISIN; @InSecCode=NULL; количество в штуках (@QtyScale).
// @QuikClassCode и @QtyScale вместе задавать НЕЛЬЗЯ — при непустом ISIN оставляем QtyScale.
_, err = conn.ExecContext(cctx, "sp_NewInstrSec",
sql.Named("ActionID", sql.Out{Dest: &actionID}),
sql.Named("ExtID", nullStr(p.ExtID)),
sql.Named("ClientID", p.ClientID),
sql.Named("InstructionType", quikBaseTypeWithdraw),
sql.Named("InstrTypeID", quikTypeWithdrawM2M),
sql.Named("Volume", p.Quantity),
sql.Named("OutSecCode", quikSecWithdraw),
sql.Named("InSecCode", nil),
sql.Named("QuikClassCode", nil),
sql.Named("ISIN", p.ISIN),
sql.Named("QtyScale", nullInt(p.QtyScale)),
sql.Named("Account", nullStr(p.Account)),
sql.Named("QuikFirm", quikFirmID),
sql.Named("FirmID", quikFirmID),
sql.Named("CanSplit", 0),
sql.Named("BlockS", p.Quantity),
sql.Named("M2MDepoRecipientINN", nullStr(p.RecipientINN)),
sql.Named("PassExpensesInfo", p.PassExpenses),
sql.Named("DepoAccount_1", nullStr(p.DepoAccount)),
sql.Named("CheckType", quikCheckViaAPI),
)
if err != nil {
return 0, fmt.Errorf("quik: sp_NewInstrSec (вывод): %s", quikErrorDetail(err))
}
return actionID, nil
}
// quikRegisterDeposit регистрирует «Ввод ЦБ (M2M)» — зачисление бумаг от другого
// брокера. Поиск инструмента по @ISIN (@InSecCode=NULL). Регистрируется и сразу
// исполняется (у типа «Ввод M2M» подписей нет).
func quikRegisterDeposit(ctx context.Context, s QuikMNPSettings, clientID, isin, account string, qty float64) (int64, error) {
if !quikWritesAllowed {
return 0, errQuikWritesDisabled
}
if strings.TrimSpace(clientID) == "" || strings.TrimSpace(isin) == "" || qty <= 0 {
return 0, errors.New("quik: не заданы ClientID/ISIN/qty для ввода")
}
conn, err := quikOpen(s)
if err != nil {
return 0, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var actionID int64
_, err = conn.ExecContext(cctx, "sp_NewInstrSec",
sql.Named("ActionID", sql.Out{Dest: &actionID}),
sql.Named("ClientID", clientID),
sql.Named("InstructionType", quikBaseTypeDeposit),
sql.Named("InstrTypeID", quikTypeDepositM2M),
sql.Named("Volume", qty),
sql.Named("OutSecCode", quikSecDeposit),
sql.Named("InSecCode", nil),
sql.Named("QuikClassCode", nil),
sql.Named("ISIN", isin),
sql.Named("Account", nullStr(account)),
sql.Named("QuikFirm", quikFirmID),
sql.Named("FirmID", quikFirmID),
sql.Named("CanSplit", 0),
sql.Named("BlockS", qty),
sql.Named("CheckType", quikCheckViaAPI),
)
if err != nil {
return 0, fmt.Errorf("quik: sp_NewInstrSec (ввод): %s", quikErrorDetail(err))
}
return actionID, nil
}
// quikSignWithdrawal подписывает (исполняет) поручение на вывод после подтверждения
// НРД: статус → «Исполнено», блокировка снимается, бумаги списываются.
// @Volume ВСЕГДА NULL при подписи; @SignFlag=16 (подпись «Депозитарий»).
func quikSignWithdrawal(ctx context.Context, s QuikMNPSettings, queryNo float64, clientID string) (int64, error) {
if !quikWritesAllowed {
return 0, errQuikWritesDisabled
}
conn, err := quikOpen(s)
if err != nil {
return 0, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var actionID int64
_, err = conn.ExecContext(cctx, "sp_SignInstrSec",
sql.Named("ActionID", sql.Out{Dest: &actionID}),
sql.Named("QueryNo", queryNo),
sql.Named("ClientID", nullStr(clientID)),
sql.Named("Volume", nil), // при подписи всегда NULL
sql.Named("IntSecCode", quikSecWithdraw),
sql.Named("FirmID", quikFirmID),
sql.Named("SignFlag", quikSignFlagDepo),
sql.Named("CheckType", quikCheckViaAPI),
)
if err != nil {
return 0, fmt.Errorf("quik: sp_SignInstrSec: %s", quikErrorDetail(err))
}
return actionID, nil
}
// quikDenyInstruction снимает поручение с указанием причины (отказ НРД): блокировка
// снимается автоматически, клиент видит причину в терминале.
func quikDenyInstruction(ctx context.Context, s QuikMNPSettings, queryNo float64, cancelText string) (int64, error) {
if !quikWritesAllowed {
return 0, errQuikWritesDisabled
}
if strings.TrimSpace(cancelText) == "" {
cancelText = "Отказ по результату перевода M2M"
}
conn, err := quikOpen(s)
if err != nil {
return 0, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var actionID int64
_, err = conn.ExecContext(cctx, "sp_DenyInstr",
sql.Named("ActionID", sql.Out{Dest: &actionID}),
sql.Named("QueryNo", queryNo),
sql.Named("CancelText", cancelText),
sql.Named("CheckType", quikCheckViaAPI),
)
if err != nil {
return 0, fmt.Errorf("quik: sp_DenyInstr: %s", quikErrorDetail(err))
}
return actionID, nil
}
// quikActionResult читает итог обработки команды Модулем (read-only). Безопасно
// на боевой БД — только SELECT из результата GetActionResult / таблицы Actions.
func quikActionResult(ctx context.Context, s QuikMNPSettings, actionID int64) (QuikActionResult, error) {
var r QuikActionResult
conn, err := quikOpen(s)
if err != nil {
return r, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
// Читаем из таблицы Actions по ActionID (эквивалент GetActionResult).
err = conn.QueryRowContext(cctx,
`SELECT ISNULL(QueryStatus,0), ISNULL(ReplyText,''), ISNULL(CAST(InstrNo AS varchar(32)),'')
FROM Actions WHERE ActionID=@p1`, actionID).
Scan(&r.QueryStatus, &r.ReplyText, &r.InstrNo)
if err != nil {
return r, fmt.Errorf("quik: чтение Actions #%d: %s", actionID, quikErrorDetail(err))
}
return r, nil
}
// nullStr → NULL для пустой строки (иначе значение).
func nullStr(v string) any {
if strings.TrimSpace(v) == "" {
return nil
}
return v
}
// nullInt → NULL для нуля (иначе значение). Для @QtyScale: 0 = не задавать.
func nullInt(v int) any {
if v == 0 {
return nil
}
return v
}