Files
Bridge-and-Join-s/internal/lkgateway/quikcycle.go
T
zuevav d47229c9a5 Автоматический режим M2M и имитация QUIK МНП
Автоматический режим (Настройка → НРД, выключен по умолчанию): входящие переводы — решение по свежей
сверке с DEPO без оператора (пакет только целиком; бумага для квал. инвесторов при неполной или
непрочитанной квалификации — отказ M2M08), поручение 16/1 — как только пришёл черновик MF010,
поручение 16 по исходящим — по черновику. Нет сверки или ИШ — повтор каждые 3 минуты и оповещение,
без сверки ничего не принимается. Обрабатывается только появившееся не раньше суток до включения
(старые черновики в INBOX не подаются). Решение не отправляется повторно по уже отвеченному переводу.

Имитация QUIK МНП (Настройка → QUIK МНП → цикл): регистрация, подпись, снятие и зачисление проходят
как выполненные с пометкой «имитация», в МНП ничего не пишется; события в DEPO — как обычно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:59:23 +03:00

652 lines
26 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
// Автоматический цикл M2M в QUIK МНП. Проверено на боевом МНП 08–09.09.2026:
//
// исходящий перевод (мы списываем): quikOutRegister → [НРД] → quikOutSign | quikOutDeny
// входящий перевод (мы зачисляем): quikInDeposit (регистрируется и исполняется сразу)
//
// Все пишущие вызовы разрешены только при Quik.WritesEnabled. Каждый шаг
// фиксируется в журнале связок (quik-m2m.json) по GUID перевода, ошибки —
// в ленту новостей, чтобы оператор видел, где цикл остановился.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
const (
quikLinkFile = "/var/lib/bj/.bj/quik-m2m.json"
quikActionWait = 90 * time.Second
quikDefaultAcct = "L01+00000F00"
quikInstrCacheTTL = 10 * time.Minute
)
// ---- журнал связок «перевод ↔ поручение МНП» ----
type QuikStep struct {
At time.Time `json:"at"`
Action string `json:"action"` // register | sign | deny | deposit | recipient
ActionID int64 `json:"action_id,omitempty"`
Status int `json:"status,omitempty"` // QueryStatus МНП
Reply string `json:"reply,omitempty"`
OK bool `json:"ok"`
}
type QuikLink struct {
GUID string `json:"guid"`
Direction string `json:"direction"` // out | in
ClientID string `json:"client_id"`
Contract string `json:"contract,omitempty"` // номер портфеля DEPO (БР-859)
ISIN string `json:"isin"`
Qty float64 `json:"qty"`
InstrNo string `json:"instr_no,omitempty"` // номер поручения в МНП
State string `json:"state"` // registered | signed | denied | deposited | error | manual
Err string `json:"err,omitempty"`
Steps []QuikStep `json:"steps"`
UpdatedAt time.Time `json:"updated_at"`
}
// QuikStateRu — статус связки по-русски (для карточек).
func (l *QuikLink) StateRu() string {
if l == nil {
return "нет"
}
switch l.State {
case "registered":
return "зарегистрировано, бумаги заблокированы"
case "signed":
return "исполнено, бумаги списаны"
case "denied":
return "снято, блокировка снята"
case "deposited":
return "исполнено, бумаги зачислены"
case "error":
return "ошибка"
case "manual":
return "автоматика выключена"
}
return l.State
}
type QuikLinkStore struct {
mu sync.Mutex
path string
links map[string]*QuikLink
}
var globalQuikLinks = newQuikLinkStore(quikLinkFile)
func newQuikLinkStore(path string) *QuikLinkStore {
s := &QuikLinkStore{path: path, links: map[string]*QuikLink{}}
if b, err := os.ReadFile(path); err == nil {
var list []*QuikLink
if json.Unmarshal(b, &list) == nil {
for _, l := range list {
s.links[quikKey(l.GUID, l.ISIN)] = l
}
}
}
return s
}
func (s *QuikLinkStore) saveLocked() {
list := make([]*QuikLink, 0, len(s.links))
for _, l := range s.links {
list = append(list, l)
}
sort.Slice(list, func(i, j int) bool { return list[i].UpdatedAt.After(list[j].UpdatedAt) })
b, _ := json.MarshalIndent(list, "", " ")
_ = os.MkdirAll(filepath.Dir(s.path), 0o755)
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o640); err == nil {
_ = os.Rename(tmp, s.path)
}
}
func quikKey(guid, isin string) string {
return strings.ToUpper(strings.TrimSpace(guid)) + "|" + strings.ToUpper(strings.TrimSpace(isin))
}
func (s *QuikLinkStore) Get(guid, isin string) *QuikLink {
s.mu.Lock()
defer s.mu.Unlock()
if l, ok := s.links[quikKey(guid, isin)]; ok {
cp := *l
cp.Steps = append([]QuikStep(nil), l.Steps...)
return &cp
}
return nil
}
// ForGUID — все связки перевода (по бумаге на каждую).
func (s *QuikLinkStore) ForGUID(guid string) []*QuikLink {
s.mu.Lock()
defer s.mu.Unlock()
g := strings.ToUpper(strings.TrimSpace(guid))
var out []*QuikLink
for _, l := range s.links {
if strings.ToUpper(l.GUID) == g {
cp := *l
out = append(out, &cp)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ISIN < out[j].ISIN })
return out
}
// ByInstrNo — связка по номеру поручения МНП.
func (s *QuikLinkStore) ByInstrNo(no string) *QuikLink {
s.mu.Lock()
defer s.mu.Unlock()
for _, l := range s.links {
if l.InstrNo == no && no != "" {
cp := *l
return &cp
}
}
return nil
}
func (s *QuikLinkStore) List(limit int) []*QuikLink {
s.mu.Lock()
defer s.mu.Unlock()
list := make([]*QuikLink, 0, len(s.links))
for _, l := range s.links {
cp := *l
list = append(list, &cp)
}
sort.Slice(list, func(i, j int) bool { return list[i].UpdatedAt.After(list[j].UpdatedAt) })
if limit > 0 && len(list) > limit {
list = list[:limit]
}
return list
}
func (s *QuikLinkStore) update(guid, isin string, fn func(l *QuikLink)) *QuikLink {
s.mu.Lock()
defer s.mu.Unlock()
key := quikKey(guid, isin)
l, ok := s.links[key]
if !ok {
l = &QuikLink{GUID: guid, ISIN: isin}
s.links[key] = l
}
fn(l)
l.UpdatedAt = time.Now()
s.saveLocked()
cp := *l
return &cp
}
// ---- низкий уровень: процедуры МНП (параметры, проверенные на боевом МНП) ----
var (
quikInstrMu sync.Mutex
quikInstrIn string
quikInstrOut string
quikInstrLoaded time.Time
)
// quikM2MInstruments — коды МНП-инструментов для классов INSTRINM2M / INSTROUTM2M.
// Администратор может назвать их не по документу (у нас: INSTRINM2M/INSTROUTM2M
// вместо RURINM2M/RUROUTM2M), поэтому берём из справочника, настройка — приоритетнее.
func quikM2MInstruments(ctx context.Context, s QuikMNPSettings) (in, out string, err error) {
quikInstrMu.Lock()
defer quikInstrMu.Unlock()
if time.Since(quikInstrLoaded) < quikInstrCacheTTL && quikInstrIn != "" && quikInstrOut != "" {
in, out = quikInstrIn, quikInstrOut
} else {
conn, e := quikOpen(s)
if e != nil {
return "", "", e
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
rows, e := conn.QueryContext(cctx, `SELECT ic.ClassCode, sec.SecCode FROM ISecurities sec JOIN IClasses ic ON ic.ClassId=sec.ClassId
WHERE sec.Enable=1 AND ic.ClassCode IN ('INSTRINM2M','INSTROUTM2M') ORDER BY sec.SecurityId`)
if e != nil {
return "", "", fmt.Errorf("quik: справочник инструментов M2M: %s", quikErrorDetail(e))
}
defer rows.Close()
for rows.Next() {
var cls, sec string
if rows.Scan(&cls, &sec) == nil {
if cls == "INSTRINM2M" && quikInstrIn == "" {
quikInstrIn = sec
}
if cls == "INSTROUTM2M" && quikInstrOut == "" {
quikInstrOut = sec
}
}
}
quikInstrLoaded = time.Now()
in, out = quikInstrIn, quikInstrOut
}
if strings.TrimSpace(s.SecIn) != "" {
in = strings.TrimSpace(s.SecIn)
}
if strings.TrimSpace(s.SecOut) != "" {
out = strings.TrimSpace(s.SecOut)
}
if in == "" {
in = quikSecDeposit
}
if out == "" {
out = quikSecWithdraw
}
return in, out, nil
}
func quikAccount(s QuikMNPSettings) string {
if a := strings.TrimSpace(s.Account); a != "" {
return a
}
return quikDefaultAcct
}
// quikEnsureRecipient — депозитарий-контрагент должен быть в M2MDepoRecipients (иначе
// «Вывод ЦБ (M2M)» не примет ИНН). Добавление идемпотентно (@Operation=1 = добавить/изменить).
func quikEnsureRecipient(ctx context.Context, s QuikMNPSettings, inn, name string) error {
inn = strings.TrimSpace(inn)
if inn == "" {
return errors.New("quik: не задан ИНН депозитария-контрагента")
}
conn, err := quikOpen(s)
if err != nil {
return err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
var n int
if err := conn.QueryRowContext(cctx, "SELECT COUNT(*) FROM M2MDepoRecipients WHERE INN=@p1", inn).Scan(&n); err == nil && n > 0 {
return nil
}
if strings.TrimSpace(name) == "" {
name = "Депозитарий ИНН " + inn
}
if len([]rune(name)) > 128 {
name = string([]rune(name)[:128])
}
if _, err := conn.ExecContext(cctx, "exec dbo.imp_M2MDepoRecipients @Operation=1, @INN=@inn, @Name=@nm", sql.Named("inn", inn), sql.Named("nm", name)); err != nil {
return fmt.Errorf("quik: imp_M2MDepoRecipients: %s", quikErrorDetail(err))
}
return nil
}
// quikWaitAction ждёт окончательного ответа модуля по заявке (QueryStatus 2/3/4);
// 1 и 5 — промежуточные («новая», «проверяется на сервере Quik»).
func quikWaitAction(ctx context.Context, s QuikMNPSettings, actionID int64, d time.Duration) QuikActionResult {
deadline := time.Now().Add(d)
var last QuikActionResult
for {
r, err := quikActionResult(ctx, s, actionID)
if err == nil {
last = r
if r.QueryStatus == 2 || r.QueryStatus == 3 || r.QueryStatus == 4 {
return r
}
}
if time.Now().After(deadline) || ctx.Err() != nil {
if last.ReplyText == "" {
last.ReplyText = "нет ответа модуля за отведённое время"
}
return last
}
time.Sleep(5 * time.Second)
}
}
const quikNewInstrSQL = `exec dbo.sp_NewInstrSec @ActionID=@aid output, @ExtID=@ext, @ClientID=@cid, @ClientBuyID=NULL, @InstructionType=@it, @AgreeServ=NULL,
@Volume=@vol, @ValFee=NULL, @ValDepoPay=NULL, @ValCashPay=NULL, @PayNumber=NULL, @QuikClassCode=NULL, @OutSecCode=@out, @InSecCode=NULL, @QuikFirm=@firm,
@Account=@acc, @AccountIn=NULL, @CanSplit=0, @BlockS=@vol, @Place=NULL, @DepoAccount_1=NULL, @DepoAccount_2=NULL, @DepoAccount_3=NULL, @DepoAccount_4=NULL, @PutReason=NULL,
@DealCurr=NULL, @DealSum=NULL, @Payer=NULL, @CP=NULL, @Country=NULL, @DepoCP=NULL, @FirmID=@firm, @SignFlag=NULL, @Phone=NULL, @Comment=@cm,
@InstrDepoID=NULL, @SubDepoCP=NULL, @IDDocTypeCP=NULL, @IDDocNumCP=NULL, @IDDocDateCP=NULL, @IDDocIssuerCP=NULL, @CPType=NULL, @Responsible=NULL, @Scale=NULL, @InstrFlags=NULL,
@DealDate=NULL, @PayDate=NULL, @CheckType=1, @DocComment=NULL, @InstrTypeID=@itid, @M2MDepoRecipientINN=@inn, @PassExpensesInfo=@pass, @ISIN=@isin, @QtyScale=@qs`
// quikExecNewInstr — регистрация поручения по ISIN (ввод или вывод M2M).
func quikExecNewInstr(ctx context.Context, s QuikMNPSettings, withdraw bool, extID, clientID, isin string, qty float64, inn string, passExpenses bool, comment string) (int64, error) {
if !s.WritesEnabled {
return 0, errQuikWritesDisabled
}
secIn, secOut, err := quikM2MInstruments(ctx, s)
if err != nil {
return 0, err
}
itype, itid, out := quikBaseTypeDeposit, quikTypeDepositM2M, secIn
if withdraw {
itype, itid, out = quikBaseTypeWithdraw, quikTypeWithdrawM2M, secOut
}
qs := 0
if qty != float64(int64(qty)) {
qs = 2
}
conn, err := quikOpen(s)
if err != nil {
return 0, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var aid sql.NullInt64
_, err = conn.ExecContext(cctx, quikNewInstrSQL,
sql.Named("aid", sql.Out{Dest: &aid}), sql.Named("ext", nullStr(extID)), sql.Named("cid", clientID), sql.Named("it", itype),
sql.Named("vol", qty), sql.Named("out", out), sql.Named("firm", quikFirmID), sql.Named("acc", quikAccount(s)),
sql.Named("cm", comment), sql.Named("itid", itid), sql.Named("inn", nullStr(inn)), sql.Named("pass", passExpenses), sql.Named("isin", isin), sql.Named("qs", qs))
if err != nil {
return 0, fmt.Errorf("quik: sp_NewInstrSec: %s", quikErrorDetail(err))
}
return aid.Int64, nil
}
// quikExecSign — подпись «Депозитарий» (единственная у «Вывод ЦБ (M2M)») → исполнение.
func quikExecSign(ctx context.Context, s QuikMNPSettings, queryNo float64, clientID string, qty float64, comment string) (int64, error) {
if !s.WritesEnabled {
return 0, errQuikWritesDisabled
}
_, secOut, err := quikM2MInstruments(ctx, s)
if err != nil {
return 0, err
}
conn, err := quikOpen(s)
if err != nil {
return 0, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var aid sql.NullInt64
_, err = conn.ExecContext(cctx, `exec dbo.sp_SignInstrSec @ActionID=@aid output, @QueryNo=@no, @ClientID=@cid, @Volume=@vol, @ValFee=NULL, @ValDepoPay=NULL, @ValCashPay=NULL,
@IntSecCode=@isec, @BlockS=NULL, @PutReason=NULL, @FirmID=@firm, @SignFlag=@sf, @DealDate=NULL, @PayDate=NULL, @CheckType=1, @DocComment=@cm,
@AccountIn=NULL, @ClassCodeAfterTrans=NULL, @SecCodeAfterTrans=NULL, @QtyAfterTrans=NULL, @FirmAfterTrans=NULL, @BrokerComment=NULL`,
sql.Named("aid", sql.Out{Dest: &aid}), sql.Named("no", queryNo), sql.Named("cid", clientID), sql.Named("vol", qty), sql.Named("isec", secOut),
sql.Named("firm", quikFirmID), sql.Named("sf", quikSignFlagDepo), sql.Named("cm", comment))
if err != nil {
return 0, fmt.Errorf("quik: sp_SignInstrSec: %s", quikErrorDetail(err))
}
return aid.Int64, nil
}
// quikExecDeny — снятие поручения с причиной (блокировка снимается, клиент видит причину).
func quikExecDeny(ctx context.Context, s QuikMNPSettings, queryNo float64, reason string) (int64, error) {
if !s.WritesEnabled {
return 0, errQuikWritesDisabled
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "Отказ по результату перевода M2M"
}
if len([]rune(reason)) > 250 {
reason = string([]rune(reason)[:250])
}
conn, err := quikOpen(s)
if err != nil {
return 0, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var aid sql.NullInt64
_, err = conn.ExecContext(cctx, `exec dbo.sp_DenyInstr @ActionID=@aid output, @QueryNo=@no, @CancelText=@txt, @CheckType=1, @TemplateCodeCommentDeny=NULL`,
sql.Named("aid", sql.Out{Dest: &aid}), sql.Named("no", queryNo), sql.Named("txt", reason))
if err != nil {
return 0, fmt.Errorf("quik: sp_DenyInstr: %s", quikErrorDetail(err))
}
return aid.Int64, nil
}
// ---- код клиента QUIK по клиенту DEPO ----
var quikPortRe = regexp.MustCompile(`^\s*(\S+)`)
// quikClientCodeFromPort: «БР-859 Иванов…» → «BR859»; исключения — из настроек.
func quikClientCodeFromPort(s QuikMNPSettings, portNum string) string {
m := quikPortRe.FindStringSubmatch(portNum)
if m == nil {
return ""
}
tok := m[1]
if s.ClientCodeMap != nil {
if v, ok := s.ClientCodeMap[tok]; ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
up := strings.ToUpper(tok)
if strings.HasPrefix(up, "БР-") {
return "BR" + strings.TrimPrefix(up, "БР-")
}
return tok
}
// quikResolveClient находит в DEPO клиента по паспорту и возвращает код QUIK и номер портфеля.
func quikResolveClient(ctx context.Context, rc *RuntimeConfig, ser, num string) (code, contract string, err error) {
st := rc.Snapshot()
db, err := fancyOpen(st.Fancy, st.Fancy.DepoDB())
if err != nil {
return "", "", fmt.Errorf("DEPO: %w", err)
}
defer db.Close()
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
numN := strings.TrimSpace(num)
// Правило депозитария: при нескольких договорах клиента зачисление идёт на
// САМЫЙ РАННИЙ договор — берём портфель с наименьшей датой (затем по дате
// открытия счёта и номеру).
rows, err := db.QueryContext(cctx, `SELECT TOP 5 p.PORT_NUM FROM OD_FACES f
JOIN OD_CLIENTS c ON c.FACE=f.ID JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID
WHERE REPLACE(f.DOC_SER,' ','')=@p1 AND f.DOC_NUM=@p2 AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0
ORDER BY ISNULL(p.PORT_DATE,'2099-01-01'), ISNULL(c.D_OPEN,'2099-01-01'), c.NUMBER`, serN, numN)
if err != nil {
return "", "", fmt.Errorf("DEPO: %w", err)
}
defer rows.Close()
for rows.Next() {
var port string
if rows.Scan(&port) == nil {
if c := quikClientCodeFromPort(st.Quik, port); c != "" {
return c, strings.Fields(port)[0], nil
}
}
}
return "", "", errors.New("клиент с таким паспортом и открытым торговым счётом в DEPO не найден")
}
// ---- высокоуровневые операции цикла ----
// quikActive — цикл M2M в МНП работает: записи разрешены или включена имитация.
func quikActive(s QuikMNPSettings) bool { return s.WritesEnabled || s.Emulate }
// quikEmulated — шаг цикла в режиме имитации: связка переходит в состояние state, в МНП ничего не
// пишется (нет пользователя QUIK для блокировок и лимитов); события в DEPO — как при реальной работе.
func quikEmulated(rc *RuntimeConfig, guid, isin, action, state, what string) *QuikLink {
l := globalQuikLinks.update(guid, isin, func(l *QuikLink) {
if l.InstrNo == "" {
l.InstrNo = "ИМИТ-" + shortGUID(guid)
}
l.State, l.Err = state, ""
l.Steps = append(l.Steps, QuikStep{At: time.Now(), Action: action, Reply: "имитация: " + what + " — в МНП не записано", OK: true})
})
log.Printf("quik: %s %s (имитация, в МНП не записано): %s × %v", shortGUID(guid), what, isin, l.Qty)
go depoOnQuik(rc, guid, isin, action, l.InstrNo)
return l
}
func quikStep(action string, aid int64, r QuikActionResult, ok bool) QuikStep {
return QuikStep{At: time.Now(), Action: action, ActionID: aid, Status: r.QueryStatus, Reply: r.ReplyText, OK: ok}
}
// QuikOutRegister — исходящий перевод: зарегистрировать «Вывод ЦБ (M2M)» (QUIK блокирует бумаги).
func QuikOutRegister(ctx context.Context, rc *RuntimeConfig, guid, clientID, contract, isin string, qty float64, recipientINN, recipientName string, passExpenses bool) (*QuikLink, error) {
s := rc.Snapshot().Quik
l := globalQuikLinks.update(guid, isin, func(l *QuikLink) {
l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "out", clientID, contract, isin, qty
})
if s.Emulate {
return quikEmulated(rc, guid, isin, "register", "registered", "вывод зарегистрирован, бумаги заблокированы"), nil
}
if err := quikEnsureRecipient(ctx, s, recipientINN, recipientName); err != nil {
return quikFail(rc, guid, isin, "register", err), err
}
aid, err := quikExecNewInstr(ctx, s, true, shortGUID(guid), clientID, isin, qty, recipientINN, passExpenses, "M2M bj: перевод "+shortGUID(guid))
if err != nil {
return quikFail(rc, guid, isin, "register", err), err
}
r := quikWaitAction(ctx, s, aid, quikActionWait)
if r.QueryStatus != 2 || r.InstrNo == "" || r.InstrNo == "0" {
err := fmt.Errorf("МНП отверг регистрацию: %s", r.ReplyText)
globalQuikLinks.update(guid, isin, func(l *QuikLink) { l.Steps = append(l.Steps, quikStep("register", aid, r, false)) })
return quikFail(rc, guid, isin, "register", err), err
}
l = globalQuikLinks.update(guid, isin, func(l *QuikLink) {
l.InstrNo, l.State, l.Err = r.InstrNo, "registered", ""
l.Steps = append(l.Steps, quikStep("register", aid, r, true))
})
log.Printf("quik: %s вывод зарегистрирован, поручение %s (%s, %s × %v)", shortGUID(guid), r.InstrNo, clientID, isin, qty)
go depoOnQuik(rc, guid, isin, "register", r.InstrNo)
return l, nil
}
// QuikOutSign — НРД подтвердил перевод: подписать поручение → исполнено, бумаги списаны.
func QuikOutSign(ctx context.Context, rc *RuntimeConfig, guid, isin, comment string) (*QuikLink, error) {
s := rc.Snapshot().Quik
l := globalQuikLinks.Get(guid, isin)
if l == nil || l.InstrNo == "" {
return l, errors.New("нет зарегистрированного поручения МНП по этому переводу")
}
if l.State == "signed" {
return l, nil
}
if s.Emulate {
return quikEmulated(rc, guid, isin, "sign", "signed", "поручение подписано, бумаги списаны"), nil
}
var no float64
fmt.Sscan(l.InstrNo, &no)
aid, err := quikExecSign(ctx, s, no, l.ClientID, l.Qty, comment)
if err != nil {
return quikFail(rc, guid, isin, "sign", err), err
}
r := quikWaitAction(ctx, s, aid, quikActionWait)
if r.QueryStatus != 2 {
err := fmt.Errorf("МНП отверг подпись: %s", r.ReplyText)
globalQuikLinks.update(guid, isin, func(l *QuikLink) { l.Steps = append(l.Steps, quikStep("sign", aid, r, false)) })
return quikFail(rc, guid, isin, "sign", err), err
}
l = globalQuikLinks.update(guid, isin, func(l *QuikLink) {
l.State, l.Err = "signed", ""
l.Steps = append(l.Steps, quikStep("sign", aid, r, true))
})
log.Printf("quik: %s поручение %s подписано и исполнено", shortGUID(guid), l.InstrNo)
go depoOnQuik(rc, guid, isin, "sign", l.InstrNo)
return l, nil
}
// QuikOutDeny — перевод отклонён (контрагентом, НРД или оператором): снять поручение с причиной.
func QuikOutDeny(ctx context.Context, rc *RuntimeConfig, guid, isin, reason string) (*QuikLink, error) {
s := rc.Snapshot().Quik
l := globalQuikLinks.Get(guid, isin)
if l == nil || l.InstrNo == "" {
return l, errors.New("нет зарегистрированного поручения МНП по этому переводу")
}
if l.State == "denied" || l.State == "signed" {
return l, nil
}
if s.Emulate {
return quikEmulated(rc, guid, isin, "deny", "denied", "поручение снято ("+reason+")"), nil
}
var no float64
fmt.Sscan(l.InstrNo, &no)
aid, err := quikExecDeny(ctx, s, no, reason)
if err != nil {
return quikFail(rc, guid, isin, "deny", err), err
}
r := quikWaitAction(ctx, s, aid, quikActionWait)
if r.QueryStatus != 2 {
err := fmt.Errorf("МНП отверг снятие: %s", r.ReplyText)
globalQuikLinks.update(guid, isin, func(l *QuikLink) { l.Steps = append(l.Steps, quikStep("deny", aid, r, false)) })
return quikFail(rc, guid, isin, "deny", err), err
}
l = globalQuikLinks.update(guid, isin, func(l *QuikLink) {
l.State, l.Err = "denied", ""
l.Steps = append(l.Steps, quikStep("deny", aid, r, true))
})
log.Printf("quik: %s поручение %s снято: %s", shortGUID(guid), l.InstrNo, reason)
go depoOnQuik(rc, guid, isin, "deny", l.InstrNo)
return l, nil
}
// QuikInDeposit — входящий перевод принят НРД: зарегистрировать «Ввод ЦБ (M2M)» (исполняется сразу).
func QuikInDeposit(ctx context.Context, rc *RuntimeConfig, guid, clientID, contract, isin string, qty float64, senderINN, senderName string) (*QuikLink, error) {
s := rc.Snapshot().Quik
if l := globalQuikLinks.Get(guid, isin); l != nil && l.State == "deposited" {
return l, nil
}
globalQuikLinks.update(guid, isin, func(l *QuikLink) {
l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "in", clientID, contract, isin, qty
})
if s.Emulate {
return quikEmulated(rc, guid, isin, "deposit", "deposited", "ввод зарегистрирован, бумаги зачислены"), nil
}
if strings.TrimSpace(senderINN) != "" {
_ = quikEnsureRecipient(ctx, s, senderINN, senderName)
}
aid, err := quikExecNewInstr(ctx, s, false, shortGUID(guid), clientID, isin, qty, senderINN, false, "M2M bj: зачисление "+shortGUID(guid))
if err != nil {
return quikFail(rc, guid, isin, "deposit", err), err
}
r := quikWaitAction(ctx, s, aid, quikActionWait)
if r.QueryStatus != 2 || r.InstrNo == "" || r.InstrNo == "0" {
err := fmt.Errorf("МНП отверг зачисление: %s", r.ReplyText)
globalQuikLinks.update(guid, isin, func(l *QuikLink) { l.Steps = append(l.Steps, quikStep("deposit", aid, r, false)) })
return quikFail(rc, guid, isin, "deposit", err), err
}
l := globalQuikLinks.update(guid, isin, func(l *QuikLink) {
l.InstrNo, l.State, l.Err = r.InstrNo, "deposited", ""
l.Steps = append(l.Steps, quikStep("deposit", aid, r, true))
})
log.Printf("quik: %s ввод зарегистрирован и исполнен, поручение %s (%s, %s × %v)", shortGUID(guid), r.InstrNo, clientID, isin, qty)
go depoOnQuik(rc, guid, isin, "deposit", r.InstrNo)
return l, nil
}
func quikFail(rc *RuntimeConfig, guid, isin, action string, err error) *QuikLink {
l := globalQuikLinks.update(guid, isin, func(l *QuikLink) {
l.State, l.Err = "error", err.Error()
})
log.Printf("quik: %s %s: %v", shortGUID(guid), action, err)
title := map[string]string{"register": "QUIK МНП: не удалось зарегистрировать вывод", "sign": "QUIK МНП: не удалось подписать (исполнить) поручение", "deny": "QUIK МНП: не удалось снять поручение", "deposit": "QUIK МНП: не удалось зачислить бумаги"}[action]
raiseSystemAlert(rc, title, "Перевод "+shortGUID(guid)+", бумага "+isin+": "+err.Error()+". Откройте карточку перевода и повторите операцию кнопкой или выполните её в QUIK вручную.")
return l
}
func shortGUID(g string) string {
g = strings.ToUpper(strings.TrimSpace(g))
if len(g) > 8 {
return g[:8]
}
return g
}
// IsTerminal — связка подхвачена из терминала (заявка в bj ещё не создана).
func (l *QuikLink) IsTerminal() bool { return l != nil && strings.HasPrefix(l.GUID, "terminal-") }
// LastStep — последний шаг журнала (для таблиц).
func (l *QuikLink) LastStep() *QuikStep {
if l == nil || len(l.Steps) == 0 {
return nil
}
return &l.Steps[len(l.Steps)-1]
}