Files
Bridge-and-Join-s/internal/lkgateway/quikm2m.go
T

283 lines
13 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"
)
// Коннектор 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, разрешение записи в МНП в настройках 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
}