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] }