6c42e0378d
Третий режим МНП (между «внутренняя имитация» и «боевая запись»): в bj цикл идёт по обычному пользователю, а поручение в QUIK МНП уходит РЕАЛЬНО, но с кодом другого, реально существующего в QUIK клиента (обобщение ручного теста realtest «боевой ЛК → TEST3»). - QuikMNPSettings: RealWrite + RealClientCode (валидация формата кода). - Подмена кода — единая точка quikClientCodeFromPort (после ClientCodeMap); портфель не трогаем. quikActive: RealWrite только при devMode и непустом коде; Emulate (внутренняя имитация) в приоритете. - Шаги боевого пути помечаются «[на подставного клиента <код>]» — видно в «Поручениях QUIK». - Карточка МНП на /admin/emulators — три взаимоисключающих режима (выключено / имитация / реальная запись), поле кода, крупное предупреждение; отдельный красный баннер «Реальные поручения в МНП на клиента <код> (режим разработки)» — не смешиваем с баннером эмуляции НРД. - disableEmulatorsIfDevOff сбрасывает и RealWrite (код остаётся, инертен), новость явно фиксирует прекращение реальных поручений.
725 lines
30 KiB
Go
725 lines
30 KiB
Go
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]
|
||
}
|