Files
Bridge-and-Join-s/internal/lkgateway/quikcycle.go
T
zuevav 6c42e0378d feat(admin): эмулятор МНП — режим реальной записи в МНП на подставного клиента
Третий режим МНП (между «внутренняя имитация» и «боевая запись»): в bj цикл
идёт по обычному пользователю, а поручение в QUIK МНП уходит РЕАЛЬНО, но с
кодом другого, реально существующего в QUIK клиента (обобщение ручного теста
realtest «боевой ЛК → TEST3»).

- QuikMNPSettings: RealWrite + RealClientCode (валидация формата кода).
- Подмена кода — единая точка quikClientCodeFromPort (после ClientCodeMap);
  портфель не трогаем. quikActive: RealWrite только при devMode и непустом
  коде; Emulate (внутренняя имитация) в приоритете.
- Шаги боевого пути помечаются «[на подставного клиента <код>]» — видно в
  «Поручениях QUIK».
- Карточка МНП на /admin/emulators — три взаимоисключающих режима
  (выключено / имитация / реальная запись), поле кода, крупное предупреждение;
  отдельный красный баннер «Реальные поручения в МНП на клиента <код> (режим
  разработки)» — не смешиваем с баннером эмуляции НРД.
- disableEmulatorsIfDevOff сбрасывает и RealWrite (код остаётся, инертен),
  новость явно фиксирует прекращение реальных поручений.
2026-10-08 00:45:06 +03:00

725 lines
30 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"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
"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, fsql.Qk01)
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»; исключения — из настроек.
// В режиме «реальная запись на подставного клиента» (RealWrite, страница «Эмуляторы»)
// код подменяется в самом конце — поручения в МНП уходят на RealClientCode.
func quikClientCodeFromPort(s QuikMNPSettings, portNum string) string {
m := quikPortRe.FindStringSubmatch(portNum)
if m == nil {
return ""
}
tok := m[1]
code := ""
if s.ClientCodeMap != nil {
if v, ok := s.ClientCodeMap[tok]; ok {
code = strings.TrimSpace(v)
}
}
if code == "" {
up := strings.ToUpper(tok)
if strings.HasPrefix(up, "БР-") {
code = "BR" + strings.TrimPrefix(up, "БР-")
} else {
code = tok
}
}
if s.RealWrite && strings.TrimSpace(s.RealClientCode) != "" {
return strings.TrimSpace(s.RealClientCode)
}
return code
}
// 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, accSQL(fsql.Qk02), 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 в МНП работает: записи разрешены, включена имитация или режим реальной
// записи на подставного клиента. Имитация и real-запись — только в режиме разработки; если он
// закончился при включённом режиме, цикл стоит (в МНП не пишем). Имитация имеет приоритет.
func quikActive(s QuikMNPSettings) bool {
if s.Emulate {
return devMode(globalRC)
}
if s.RealWrite {
return devMode(globalRC) && strings.TrimSpace(s.RealClientCode) != ""
}
return s.WritesEnabled
}
// quikEmulated — шаг цикла в режиме имитации: связка переходит в состояние state, в МНП ничего не
// пишется (нет пользователя QUIK для блокировок и лимитов); события в DEPO — как при реальной работе.
// Настройки MNPEmu (страница «Эмуляторы»): StepDelaySec — пауза перед шагом (повторяет ожидание ответа
// МНП), Fail<Шаг> — шаг завершается отказом, как боевой: состояние не продвигается, повтор на следующем проходе.
func quikEmulated(ctx context.Context, rc *RuntimeConfig, guid, isin, action, state, what string) (*QuikLink, error) {
emu := rc.Snapshot().MNPEmu
if emu.StepDelaySec > 0 {
d := time.Duration(emu.StepDelaySec) * time.Second
if d > 60*time.Second {
d = 60 * time.Second
}
select {
case <-ctx.Done():
return globalQuikLinks.Get(guid, isin), ctx.Err()
case <-time.After(d):
}
}
if emu.FailFor(action) {
err := fmt.Errorf("имитация отказа МНП: %s (настройки эмулятора, страница «Эмуляторы»)", what)
globalQuikLinks.update(guid, isin, func(l *QuikLink) {
l.Steps = append(l.Steps, QuikStep{At: time.Now(), Action: action, Reply: err.Error(), OK: false})
})
return quikFail(rc, guid, isin, action, err), err
}
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, nil
}
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}
}
// quikStepReal — шаг боевого пути с пометкой режима «реальная запись на подставного
// клиента»: в UI «Поручения QUIK» видно, что поручение ушло не на своего клиента.
func quikStepReal(s QuikMNPSettings, action string, aid int64, r QuikActionResult, ok bool) QuikStep {
st := quikStep(action, aid, r, ok)
if s.RealWrite && strings.TrimSpace(s.RealClientCode) != "" {
st.Reply = strings.TrimSpace(st.Reply + " [на подставного клиента " + strings.TrimSpace(s.RealClientCode) + "]")
}
return st
}
// Повтор регистрации, пока модуль МНП не подхватил нового получателя из M2MDepoRecipients.
var (
quikRecipientRetries = 3
quikRecipientWait = 30 * time.Second
)
// 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 := quikSettingsFor(rc, guid)
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(ctx, rc, guid, isin, "register", "registered", "вывод зарегистрирован, бумаги заблокированы")
}
if strings.TrimSpace(recipientName) == "" {
recipientName = m2mPartyName(recipientINN) // наименование из справочника НРД
}
if err := quikEnsureRecipient(ctx, s, recipientINN, recipientName); err != nil {
return quikFail(rc, guid, isin, "register", err), err
}
var aid int64
var r QuikActionResult
// Нового получателя (только что добавлен в M2MDepoRecipients) модуль МНП подхватывает не сразу —
// «Неизвестный депозитарий получателя»: ждём и повторяем, а не отклоняем заявку клиента.
for attempt := 1; ; attempt++ {
var err error
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 attempt > quikRecipientRetries || !strings.Contains(strings.ToLower(r.ReplyText), "неизвестный депозитарий получателя") {
break
}
log.Printf("quik: %s: МНП ещё не знает получателя ИНН %s — повтор через %s (попытка %d из %d)", shortGUID(guid), recipientINN, quikRecipientWait, attempt, quikRecipientRetries)
select {
case <-ctx.Done():
return quikFail(rc, guid, isin, "register", ctx.Err()), ctx.Err()
case <-time.After(quikRecipientWait):
}
}
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, quikStepReal(s, "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, quikStepReal(s, "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 := quikSettingsFor(rc, guid)
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(ctx, rc, guid, isin, "sign", "signed", "поручение подписано, бумаги списаны")
}
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, quikStepReal(s, "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, quikStepReal(s, "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 := quikSettingsFor(rc, guid)
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(ctx, rc, guid, isin, "deny", "denied", "поручение снято ("+reason+")")
}
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, quikStepReal(s, "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, quikStepReal(s, "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(ctx, rc, guid, isin, "deposit", "deposited", "ввод зарегистрирован, бумаги зачислены")
}
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, quikStepReal(s, "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, quikStepReal(s, "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]
}