1d0f904010
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
290 lines
13 KiB
Go
290 lines
13 KiB
Go
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 (
|
||
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, разрешение записи в МНП в настройках bj)")
|
||
|
||
// 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
|
||
}
|
||
|
||
// quikFirmID — код фирмы в QUIK (таблица Firms) = код участника нашей организации.
|
||
func quikFirmID() string {
|
||
if globalRC == nil {
|
||
return ""
|
||
}
|
||
return orgCode(globalRC.Snapshot())
|
||
}
|