feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР

- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+621
View File
@@ -0,0 +1,621 @@
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 не найден")
}
// ---- высокоуровневые операции цикла ----
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 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
}
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
}
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 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]
}