Files
Bridge-and-Join-s/internal/lkgateway/quikcycle.go
T
fontvielle c476cfd7a3 ЛК-карточка и M2M: операторский путь заявки, баннеры «в НРД не отправлено», дубль-поручение, idempotency, фикс DEPO-алертов
- Карточка заявки: операторский «Путь заявки» (claimTimelineOperator) короткими
  понятными вехами + сворачиваемый технический журнал (buildClaimTimeline);
  QUIK показан пошагово (register/sign/deny/deposit) с верными временами, отказ
  регистрации больше не выглядит успехом (quikActionFailTitles).
- Баннеры: «в НРД не отправлено», когда пакет сформирован, но не ушёл (нет
  submitted_to_nsd и не Fail); заголовок карточки и бейдж учитывают это; красный
  баннер QUIK при отказе, строгий «нет бумаг» только при реальном шорт/недостатке.
- НРД-ответ «Поручение с исходящим номером … уже существует» теперь REG_DUP
  (предупреждение-дубль, не красный «Отказ в регистрации»); не влияет на od.Rejected.
- Idempotency: submitguard.go (inflightSubmit) + защита точек подачи от
  двойного срабатывания (manual + auto-loop, двойной клик): submitOutgoingFor,
  submitIncomingOrderFor, QuikOutRegister (ранний возврат при registered/signed).
- DEPO-алерты: водяной знак depoFailAlertedID засеивается текущим MAX(ID) на
  старте процесса (Hlth03) — после рестарта bj больше не поднимает повторно
  алерты про уже известные ошибки обработки событий BJ_*.
- Тесты: claimnotsent_render_test.go (хелперы + рендер карточки).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XG3zNMMf2LMoDpXo22mwMX
2026-10-15 17:47:28 +03:00

739 lines
31 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
})
// идемпотентность: уже зарегистрировано/подписано — повторно НЕ блокируем
// (как у QuikOutSign/Deny/Deposit). Иначе двойной клик/stale-страница создадут
// второе поручение «Вывод ЦБ (M2M)» и двойную блокировку.
if l != nil && l.InstrNo != "" && (l.State == "registered" || l.State == "signed") {
return l, nil
}
// защита от ПАРАЛЛЕЛЬНОЙ регистрации того же (guid,isin) до записи состояния
if !beginSubmit("quikreg:" + guid + ":" + isin) {
return l, fmt.Errorf("регистрация «Вывод ЦБ (M2M)» по этой бумаге уже выполняется (параллельный запрос)")
}
defer endSubmit("quikreg:" + guid + ":" + isin)
if cur := globalQuikLinks.Get(guid, isin); cur != nil && cur.InstrNo != "" && (cur.State == "registered" || cur.State == "signed") {
return cur, nil // успели зарегистрировать параллельно
}
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]
}