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:
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user