Автоматический режим M2M и имитация QUIK МНП

Автоматический режим (Настройка → НРД, выключен по умолчанию): входящие переводы — решение по свежей
сверке с DEPO без оператора (пакет только целиком; бумага для квал. инвесторов при неполной или
непрочитанной квалификации — отказ M2M08), поручение 16/1 — как только пришёл черновик MF010,
поручение 16 по исходящим — по черновику. Нет сверки или ИШ — повтор каждые 3 минуты и оповещение,
без сверки ничего не принимается. Обрабатывается только появившееся не раньше суток до включения
(старые черновики в INBOX не подаются). Решение не отправляется повторно по уже отвеченному переводу.

Имитация QUIK МНП (Настройка → QUIK МНП → цикл): регистрация, подпись, снятие и зачисление проходят
как выполненные с пометкой «имитация», в МНП ничего не пишется; события в DEPO — как обычно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-16 14:59:23 +03:00
parent 4b62d00ac2
commit d47229c9a5
12 changed files with 486 additions and 30 deletions
+3 -2
View File
@@ -44,6 +44,7 @@ type depoCheck struct {
OK bool OK bool
Code string // код причины отказа (M2Mxx или QTY/PLACE) Code string // код причины отказа (M2Mxx или QTY/PLACE)
Warn bool // пройдено с предупреждением Warn bool // пройдено с предупреждением
Strict bool // предупреждение, которое автоматический режим считает отказом
} }
// depoSecResult — результат по одной бумаге. // depoSecResult — результат по одной бумаге.
@@ -282,8 +283,8 @@ func qualOnlyCheck(found, qualOnly bool, qdet string, p depoParty) depoCheck {
c.OK = false c.OK = false
c.Detail += " — инвестор НЕ квалифицированный: операция нарушит закон РФ" c.Detail += " — инвестор НЕ квалифицированный: операция нарушит закон РФ"
default: default:
c.Warn = true c.Warn, c.Strict = true, true
c.Detail += " — " + p.qualDet + "; вид бумаги сверяет оператор" c.Detail += " — " + p.qualDet + "; вид бумаги сверяет оператор (в автоматическом режиме — отказ M2M08)"
} }
return c return c
} }
+3
View File
@@ -36,6 +36,9 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
if !ok { if !ok {
return fmt.Errorf("входящий перевод %s не найден", id) return fmt.Errorf("входящий перевод %s не найден", id)
} }
if it.Status == "answered" {
return fmt.Errorf("по переводу уже отправлено решение (%s)", it.AnswerNote)
}
if deps.igwClient == nil || deps.igwChan == "" { if deps.igwClient == nil || deps.igwChan == "" {
return fmt.Errorf("ИШ не настроен — нельзя отправить решение") return fmt.Errorf("ИШ не настроен — нельзя отправить решение")
} }
+25 -16
View File
@@ -64,10 +64,27 @@ func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps,
redirect := func(msg string) { redirect := func(msg string) {
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther) http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
} }
if deps.igwClient == nil || deps.igwChan == "" { ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
redirect("ИШ не настроен — нельзя отправить поручение") defer cancel()
sent, firstErr, err := submitIncomingOrderFor(ctx, deps, it)
if err != nil {
redirect(err.Error())
return return
} }
note := fmt.Sprintf("Поручение 16/1 (тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+
"Ждём отчёт по поручению депо (XMLRP) = «MS010». Ответ появится ниже на странице.", strings.Join(sent, ", "))
if firstErr != "" {
note += " ⚠ часть файлов не ушла: " + firstErr
}
redirect(note)
}
// submitIncomingOrderFor — подать поручение 16/1 (ORDXM) по черновику MF010 из INBOX ИШ (кнопка в
// карточке и автоматический режим). firstErr — если часть файлов не ушла.
func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *IncomingTransfer) (sent []string, firstErr string, err error) {
if deps.igwClient == nil || deps.igwChan == "" {
return nil, "", fmt.Errorf("ИШ не настроен — нельзя отправить поручение")
}
// только .xml (без .SGN) — подпись НРД нам не нужна, ИШ подпишет своей ГОСТ. // только .xml (без .SGN) — подпись НРД нам не нужна, ИШ подпишет своей ГОСТ.
var xmls []string var xmls []string
for _, p := range findMF010Files(it.GUID) { for _, p := range findMF010Files(it.GUID) {
@@ -76,13 +93,11 @@ func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps,
} }
} }
if len(xmls) == 0 { if len(xmls) == 0 {
redirect("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден") return nil, "", fmt.Errorf("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден")
return
} }
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) id := it.ID
defer cancel() var sentXML []string
var sent, sentXML []string var contrDID string
var firstErr, contrDID string
for _, p := range xmls { for _, p := range xmls {
raw, e := os.ReadFile(p) // windows-1251, шлём как есть raw, e := os.ReadFile(p) // windows-1251, шлём как есть
if e != nil { if e != nil {
@@ -116,15 +131,9 @@ func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps,
if firstErr != "" { if firstErr != "" {
msg = firstErr msg = firstErr
} }
redirect("Поручение 16/1 (ORDXM): отправка в НРД не удалась: " + msg) return nil, "", fmt.Errorf("Поручение 16/1 (ORDXM): отправка в НРД не удалась: %s", msg)
return
} }
note := fmt.Sprintf("Поручение 16/1 (тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+ return sent, firstErr, nil
"Ждём отчёт по поручению депо (XMLRP) = «MS010». Ответ появится ниже на странице.", strings.Join(sent, ", "))
if firstErr != "" {
note += " ⚠ часть файлов не ушла: " + firstErr
}
redirect(note)
} }
// writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД // writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД
+301
View File
@@ -0,0 +1,301 @@
package lkgateway
// Автоматический режим M2M (решение 16.09.2026): ручные шаги оператора выполняются без участия
// человека, чтобы соблюдать сроки и убрать человеческий фактор.
//
// - Входящий перевод ждёт решения → свежая сверка с DEPO → решение по её рекомендации: пакет
// принимается только целиком; предупреждение со Strict (бумага для квалифицированных инвесторов
// при неполной квалификации клиента) — отказ M2M08. Без сверки ничего не принимается: если DEPO
// или ИШ недоступны, bj повторяет каждые m2mAutoRetry и шлёт оповещение.
// - После нашего согласия НРД присылает черновик 16/1 (MF010) → поручение подаётся автоматически.
// - По исходящему после согласия контрагента НРД присылает черновик 16 → подаётся автоматически.
//
// Обрабатывается только то, что появилось не раньше чем за сутки до включения режима (в INBOX ИШ
// лежат старые черновики тестов — их подавать нельзя). Шаги в QUIK МНП — по настройкам цикла
// (в том числе в режиме имитации).
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
)
// M2MAutoSettings — автоматический режим M2M.
type M2MAutoSettings struct {
Enabled bool `json:"enabled"`
ChangedAt time.Time `json:"changed_at,omitempty"`
ChangedBy string `json:"changed_by,omitempty"`
}
func (r *RuntimeConfig) UpdateM2MAuto(s M2MAutoSettings) error {
r.mu.Lock()
r.data.M2MAuto = s
r.data.UpdatedAt = time.Now().UTC()
r.mu.Unlock()
return r.save()
}
const (
m2mAutoTick = 30 * time.Second
m2mAutoRetry = 3 * time.Minute
m2mAutoAlertEvery = time.Hour
m2mAutoLookback = 24 * time.Hour // что обрабатывать при включении
m2mAutoDraftWait = 2 * time.Hour // черновик 16/1 не пришёл — оповещение
)
var (
globalIncomingDeps *incomingDeps
globalOutgoingDeps *outgoingDeps
)
// m2mAutoState — повторы и оповещения по ключу шага (answer:<id>, order:<id>, form16:<guid>).
type m2mAutoState struct {
mu sync.Mutex
next map[string]time.Time
alerted map[string]time.Time
}
var m2mAuto = &m2mAutoState{next: map[string]time.Time{}, alerted: map[string]time.Time{}}
func (st *m2mAutoState) due(key string, now time.Time) bool {
st.mu.Lock()
defer st.mu.Unlock()
return !now.Before(st.next[key])
}
func (st *m2mAutoState) retry(key string, now time.Time) {
st.mu.Lock()
st.next[key] = now.Add(m2mAutoRetry)
st.mu.Unlock()
}
func (st *m2mAutoState) done(key string) {
st.mu.Lock()
delete(st.next, key)
delete(st.alerted, key)
st.mu.Unlock()
}
// alert — оповещение по шагу не чаще раза в m2mAutoAlertEvery.
func (st *m2mAutoState) alert(rc *RuntimeConfig, key string, now time.Time, title, body string) {
st.mu.Lock()
last, ok := st.alerted[key]
send := !ok || now.Sub(last) >= m2mAutoAlertEvery
if send {
st.alerted[key] = now
}
st.mu.Unlock()
if send {
raiseSystemAlert(rc, title, body)
}
}
// m2mAutoLoop — фоновый исполнитель автоматического режима.
func (s *Server) m2mAutoLoop(ctx context.Context) {
ticker := time.NewTicker(m2mAutoTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
m2mAutoTickOnce(ctx, s.rc, time.Now())
healthBeat("m2m-auto")
}
}
func m2mAutoTickOnce(ctx context.Context, rc *RuntimeConfig, now time.Time) {
cfg := rc.Snapshot().M2MAuto
if !cfg.Enabled {
return
}
since := cfg.ChangedAt.Add(-m2mAutoLookback)
if d := globalIncomingDeps; d != nil {
for _, it := range d.store.List() {
switch {
case it.Status == "pending" && !it.ReceivedAt.Before(since):
m2mAutoAnswer(ctx, rc, *d, it, now)
case it.Status == "answered" && !it.OrderSubmitted && !it.AnsweredAt.Before(since) && incomingHasConfirmed(it):
m2mAutoOrder(ctx, rc, *d, it, now)
}
}
}
if d := globalOutgoingDeps; d != nil {
for _, od := range scanForm16Drafts(d.store) {
if !od.Submitted && len(od.Secs) > 0 && !od.DraftAt.Before(since) {
m2mAutoForm16(ctx, rc, *d, od, now)
}
}
}
}
func incomingHasConfirmed(it *IncomingTransfer) bool {
for _, s := range it.Securities {
if s.Decision == "confirmed" {
return true
}
}
return false
}
// autoDecisions — решение по результату сверки: рекомендация по каждой бумаге, Strict-предупреждение
// даёт отказ M2M08, пакет только целиком. why — пояснение для карточки.
func autoDecisions(it *IncomingTransfer, r depoResult) (decisions []SecurityDecision, why string) {
bySec := map[string]depoSecResult{}
strict := ""
for _, sr := range r.Secs {
bySec[sr.ReferenceID] = sr
for _, c := range sr.Checks {
if c.Strict && c.Warn && strict == "" {
strict = sr.Security
}
}
}
for _, sec := range it.Securities {
sr, ok := bySec[sec.ReferenceID]
d := SecurityDecision{ReferenceID: sec.ReferenceID}
switch {
case !ok:
d.RejectCode = "M2M05"
case strict != "":
d.RejectCode = "M2M08"
case sr.Recommend == "confirm":
d.Confirm = true
default:
d.RejectCode = sr.RejectCode
if d.RejectCode == "" {
d.RejectCode = "M2M05"
}
}
decisions = append(decisions, d)
}
decisions = packageDecisions(decisions)
why = r.Summary
if strict != "" {
why = "отказ M2M08: бумага " + strict + " для квалифицированных инвесторов, квалификация клиента неполная"
}
return decisions, why
}
func m2mAutoAnswer(ctx context.Context, rc *RuntimeConfig, d incomingDeps, it *IncomingTransfer, now time.Time) {
key := "answer:" + it.ID
if !m2mAuto.due(key, now) {
return
}
who := fmt.Sprintf("Перевод %s от %s (%s %s)", shortGUID(it.GUID), it.SenderCode, it.InvestorLast, it.InvestorFirst)
r := runDepoCheck(it)
if !r.Ran || r.Err != "" || len(r.Secs) == 0 {
m2mAuto.retry(key, now)
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: ответ отложен — нет сверки с DEPO",
fmt.Sprintf("%s: %s. bj повторит через %d мин и ответит, как только сверка пройдёт; без сверки ничего не принимается.", who, pickStr(r.Err, "сверка не выполнена"), int(m2mAutoRetry.Minutes())))
return
}
d.store.SetDepo(it.ID, r.Recommend, r.Summary)
if cur, ok := d.store.Get(it.ID); !ok || cur.Status != "pending" {
m2mAuto.done(key) // оператор успел ответить сам
return
}
decisions, why := autoDecisions(it, r)
cctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
if err := answerIncoming(cctx, d, it.ID, decisions); err != nil {
if cur, ok := d.store.Get(it.ID); ok && cur.Status == "answered" {
m2mAuto.done(key)
return
}
m2mAuto.retry(key, now)
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: решение не отправлено",
fmt.Sprintf("%s: %v. bj повторит через %d мин.", who, err, int(m2mAutoRetry.Minutes())))
return
}
if cur, ok := d.store.Get(it.ID); ok {
d.store.MarkAuto(it.ID, "Автоматически ("+why+"). "+cur.AnswerNote)
}
m2mAuto.done(key)
log.Printf("m2m-auto: %s — решение отправлено автоматически: %s", who, why)
}
func m2mAutoOrder(ctx context.Context, rc *RuntimeConfig, d incomingDeps, it *IncomingTransfer, now time.Time) {
key := "order:" + it.ID
if !m2mAuto.due(key, now) {
return
}
who := fmt.Sprintf("Перевод %s от %s (%s %s)", shortGUID(it.GUID), it.SenderCode, it.InvestorLast, it.InvestorFirst)
if len(findMF010Files(it.GUID)) == 0 {
if now.Sub(it.AnsweredAt) > m2mAutoDraftWait {
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: нет черновика поручения 16/1",
fmt.Sprintf("%s: согласие отправлено %s, но черновик 16/1 (MF010) из НРД так и не пришёл. bj подаст поручение, как только черновик появится в INBOX ИШ.", who, it.AnsweredAt.Local().Format("02.01.2006 15:04")))
}
return // черновик ещё не пришёл — проверим на следующем проходе
}
cctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
sent, partial, err := submitIncomingOrderFor(cctx, d, it)
if err != nil {
m2mAuto.retry(key, now)
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: поручение 16/1 не подано",
fmt.Sprintf("%s: %v. bj повторит через %d мин.", who, err, int(m2mAutoRetry.Minutes())))
return
}
m2mAuto.done(key)
log.Printf("m2m-auto: %s — поручение 16/1 подано автоматически, пакеты ИШ %s%s", who, strings.Join(sent, ", "), pickStr(partial, ""))
if partial != "" {
raiseSystemAlert(rc, "Автоматический режим M2M: поручение 16/1 подано не полностью", who+": часть файлов не ушла: "+partial)
}
}
func m2mAutoForm16(ctx context.Context, rc *RuntimeConfig, d outgoingDeps, od *OutgoingDeal, now time.Time) {
key := "form16:" + od.GUID
if !m2mAuto.due(key, now) {
return
}
who := fmt.Sprintf("Исходящий перевод %s (контрагент %s)", shortGUID(od.GUID), od.CounterpartyCode)
cctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
pkgs, _, _, err := submitOutgoingFor(cctx, d, od.GUID)
if len(pkgs) == 0 {
m2mAuto.retry(key, now)
m2mAuto.alert(rc, key, now, "Автоматический режим M2M: поручение 16 не подано",
fmt.Sprintf("%s: %v. bj повторит через %d мин.", who, err, int(m2mAutoRetry.Minutes())))
return
}
m2mAuto.done(key)
log.Printf("m2m-auto: %s — поручение 16 подано автоматически, пакеты ИШ %s", who, strings.Join(pkgs, ", "))
if err != nil {
raiseSystemAlert(rc, "Автоматический режим M2M: поручение 16 подано не полностью", who+": "+err.Error())
}
}
// saveM2MAuto — POST /admin/setup/m2m-auto.
func (h *setupHandlers) saveM2MAuto(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
cur := h.rc.Snapshot().M2MAuto
on := r.FormValue("enabled") != ""
if on == cur.Enabled {
setupFlash(w, r, "Автоматический режим M2M: без изменений")
return
}
s := M2MAutoSettings{Enabled: on, ChangedAt: time.Now()}
if u := currentUser(w); u != nil {
s.ChangedBy = u.Login
}
if err := h.rc.UpdateM2MAuto(s); err != nil {
setupFlash(w, r, "Автоматический режим M2M: ошибка сохранения: "+err.Error())
return
}
if on {
log.Printf("m2m-auto: автоматический режим ВКЛЮЧЁН (%s)", s.ChangedBy)
setupFlash(w, r, "Автоматический режим M2M включён: решения по входящим, поручения 16/1 и 16 bj отправляет сам (первый проход — в течение 30 секунд).")
return
}
log.Printf("m2m-auto: автоматический режим выключен (%s)", s.ChangedBy)
setupFlash(w, r, "Автоматический режим M2M выключен — решения и поручения снова отправляет оператор.")
}
+85
View File
@@ -0,0 +1,85 @@
package lkgateway
import (
"strings"
"testing"
"time"
)
func TestAutoDecisions(t *testing.T) {
it := &IncomingTransfer{Securities: []IncomingSecurity{{ReferenceID: "A"}, {ReferenceID: "B"}}}
// Всё прошло — согласие по всем.
r := depoResult{Summary: "принять", Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}, {ReferenceID: "B", Recommend: "confirm"}}}
dec, _ := autoDecisions(it, r)
if !dec[0].Confirm || !dec[1].Confirm {
t.Fatalf("согласие: %+v", dec)
}
// Отказ по одной — отказ всем пакетом с её кодом.
r = depoResult{Summary: "отказ пакетом M2M05", Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}, {ReferenceID: "B", Recommend: "reject", RejectCode: "M2M05"}}}
dec, _ = autoDecisions(it, r)
for _, d := range dec {
if d.Confirm || d.RejectCode != "M2M05" {
t.Fatalf("пакет целиком: %+v", dec)
}
}
// Бумага для квал. инвесторов при неполной квалификации — отказ M2M08 всем.
r = depoResult{Summary: "принять", Secs: []depoSecResult{
{ReferenceID: "A", Security: "RU000A10F5K6", Recommend: "confirm", Checks: []depoCheck{{Name: "Бумага только для квал. инвесторов", OK: true, Warn: true, Strict: true, Code: "M2M08"}}},
{ReferenceID: "B", Recommend: "confirm", Checks: []depoCheck{{Name: "Квал-инвестор (если бумага только для квал.)", OK: true, Warn: true, Code: "M2M08"}}},
}}
dec, why := autoDecisions(it, r)
for _, d := range dec {
if d.Confirm || d.RejectCode != "M2M08" {
t.Fatalf("строгое предупреждение: %+v", dec)
}
}
if !strings.Contains(why, "RU000A10F5K6") {
t.Fatalf("пояснение: %q", why)
}
// Обычное предупреждение (клиент не квал, бумага без ограничений) отказа не даёт.
r = depoResult{Secs: []depoSecResult{
{ReferenceID: "A", Recommend: "confirm", Checks: []depoCheck{{OK: true, Warn: true, Code: "M2M08"}}},
{ReferenceID: "B", Recommend: "confirm"},
}}
if dec, _ = autoDecisions(it, r); !dec[0].Confirm || !dec[1].Confirm {
t.Fatalf("информационное предупреждение не должно отказывать: %+v", dec)
}
// Бумага без результата сверки — не принимаем.
r = depoResult{Secs: []depoSecResult{{ReferenceID: "A", Recommend: "confirm"}}}
if dec, _ = autoDecisions(it, r); dec[0].Confirm || dec[1].Confirm {
t.Fatalf("без сверки по бумаге: %+v", dec)
}
}
func TestM2MAutoState(t *testing.T) {
st := &m2mAutoState{next: map[string]time.Time{}, alerted: map[string]time.Time{}}
now := time.Now()
if !st.due("k", now) {
t.Fatal("новый шаг должен выполняться сразу")
}
st.retry("k", now)
if st.due("k", now.Add(time.Minute)) || !st.due("k", now.Add(m2mAutoRetry)) {
t.Fatal("повтор через m2mAutoRetry")
}
st.done("k")
if !st.due("k", now) {
t.Fatal("после выполнения ключ сбрасывается")
}
}
// Выключенный режим ничего не делает (и не трогает сторы).
func TestM2MAutoDisabled(t *testing.T) {
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
if err != nil {
t.Fatal(err)
}
saved := globalIncomingDeps
globalIncomingDeps = nil
defer func() { globalIncomingDeps = saved }()
m2mAutoTickOnce(t.Context(), rc, time.Now())
}
+30
View File
@@ -466,6 +466,24 @@ func quikResolveClient(ctx context.Context, rc *RuntimeConfig, ser, num string)
// ---- высокоуровневые операции цикла ---- // ---- высокоуровневые операции цикла ----
// quikActive — цикл M2M в МНП работает: записи разрешены или включена имитация.
func quikActive(s QuikMNPSettings) bool { return s.WritesEnabled || s.Emulate }
// quikEmulated — шаг цикла в режиме имитации: связка переходит в состояние state, в МНП ничего не
// пишется (нет пользователя QUIK для блокировок и лимитов); события в DEPO — как при реальной работе.
func quikEmulated(rc *RuntimeConfig, guid, isin, action, state, what string) *QuikLink {
l := globalQuikLinks.update(guid, isin, func(l *QuikLink) {
if l.InstrNo == "" {
l.InstrNo = "ИМИТ-" + shortGUID(guid)
}
l.State, l.Err = state, ""
l.Steps = append(l.Steps, QuikStep{At: time.Now(), Action: action, Reply: "имитация: " + what + " — в МНП не записано", OK: true})
})
log.Printf("quik: %s %s (имитация, в МНП не записано): %s × %v", shortGUID(guid), what, isin, l.Qty)
go depoOnQuik(rc, guid, isin, action, l.InstrNo)
return l
}
func quikStep(action string, aid int64, r QuikActionResult, ok bool) QuikStep { 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} return QuikStep{At: time.Now(), Action: action, ActionID: aid, Status: r.QueryStatus, Reply: r.ReplyText, OK: ok}
} }
@@ -476,6 +494,9 @@ func QuikOutRegister(ctx context.Context, rc *RuntimeConfig, guid, clientID, con
l := globalQuikLinks.update(guid, isin, func(l *QuikLink) { l := globalQuikLinks.update(guid, isin, func(l *QuikLink) {
l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "out", clientID, contract, isin, qty l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "out", clientID, contract, isin, qty
}) })
if s.Emulate {
return quikEmulated(rc, guid, isin, "register", "registered", "вывод зарегистрирован, бумаги заблокированы"), nil
}
if err := quikEnsureRecipient(ctx, s, recipientINN, recipientName); err != nil { if err := quikEnsureRecipient(ctx, s, recipientINN, recipientName); err != nil {
return quikFail(rc, guid, isin, "register", err), err return quikFail(rc, guid, isin, "register", err), err
} }
@@ -508,6 +529,9 @@ func QuikOutSign(ctx context.Context, rc *RuntimeConfig, guid, isin, comment str
if l.State == "signed" { if l.State == "signed" {
return l, nil return l, nil
} }
if s.Emulate {
return quikEmulated(rc, guid, isin, "sign", "signed", "поручение подписано, бумаги списаны"), nil
}
var no float64 var no float64
fmt.Sscan(l.InstrNo, &no) fmt.Sscan(l.InstrNo, &no)
aid, err := quikExecSign(ctx, s, no, l.ClientID, l.Qty, comment) aid, err := quikExecSign(ctx, s, no, l.ClientID, l.Qty, comment)
@@ -539,6 +563,9 @@ func QuikOutDeny(ctx context.Context, rc *RuntimeConfig, guid, isin, reason stri
if l.State == "denied" || l.State == "signed" { if l.State == "denied" || l.State == "signed" {
return l, nil return l, nil
} }
if s.Emulate {
return quikEmulated(rc, guid, isin, "deny", "denied", "поручение снято ("+reason+")"), nil
}
var no float64 var no float64
fmt.Sscan(l.InstrNo, &no) fmt.Sscan(l.InstrNo, &no)
aid, err := quikExecDeny(ctx, s, no, reason) aid, err := quikExecDeny(ctx, s, no, reason)
@@ -569,6 +596,9 @@ func QuikInDeposit(ctx context.Context, rc *RuntimeConfig, guid, clientID, contr
globalQuikLinks.update(guid, isin, func(l *QuikLink) { globalQuikLinks.update(guid, isin, func(l *QuikLink) {
l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "in", clientID, contract, isin, qty l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "in", clientID, contract, isin, qty
}) })
if s.Emulate {
return quikEmulated(rc, guid, isin, "deposit", "deposited", "ввод зарегистрирован, бумаги зачислены"), nil
}
if strings.TrimSpace(senderINN) != "" { if strings.TrimSpace(senderINN) != "" {
_ = quikEnsureRecipient(ctx, s, senderINN, senderName) _ = quikEnsureRecipient(ctx, s, senderINN, senderName)
} }
+5 -2
View File
@@ -70,7 +70,7 @@ func (h *setupHandlers) quikFromForm(r *http.Request) QuikMNPSettings {
Trust: r.FormValue("trust") != "", Trust: r.FormValue("trust") != "",
Verified: cur.Verified, Verified: cur.Verified,
// поля автоматического цикла M2M правятся отдельной формой — не затираем // поля автоматического цикла M2M правятся отдельной формой — не затираем
WritesEnabled: cur.WritesEnabled, AutoOut: cur.AutoOut, AutoIn: cur.AutoIn, PollTerminal: cur.PollTerminal, WritesEnabled: cur.WritesEnabled, Emulate: cur.Emulate, AutoOut: cur.AutoOut, AutoIn: cur.AutoIn, PollTerminal: cur.PollTerminal,
Account: cur.Account, SecOut: cur.SecOut, SecIn: cur.SecIn, ClientCodeMap: cur.ClientCodeMap, Account: cur.Account, SecOut: cur.SecOut, SecIn: cur.SecIn, ClientCodeMap: cur.ClientCodeMap,
} }
} }
@@ -86,6 +86,7 @@ func (h *setupHandlers) saveQuikCycle(w http.ResponseWriter, r *http.Request) {
} }
s := h.rc.Snapshot().Quik s := h.rc.Snapshot().Quik
s.WritesEnabled = r.FormValue("writes_enabled") != "" s.WritesEnabled = r.FormValue("writes_enabled") != ""
s.Emulate = r.FormValue("emulate") != ""
s.AutoOut = r.FormValue("auto_out") != "" s.AutoOut = r.FormValue("auto_out") != ""
s.AutoIn = r.FormValue("auto_in") != "" s.AutoIn = r.FormValue("auto_in") != ""
s.PollTerminal = r.FormValue("poll_terminal") != "" s.PollTerminal = r.FormValue("poll_terminal") != ""
@@ -109,7 +110,9 @@ func (h *setupHandlers) saveQuikCycle(w http.ResponseWriter, r *http.Request) {
return return
} }
mode := "записи в МНП ВЫКЛЮЧЕНЫ" mode := "записи в МНП ВЫКЛЮЧЕНЫ"
if s.WritesEnabled { if s.Emulate {
mode = fmt.Sprintf("ИМИТАЦИЯ (в МНП ничего не пишется): исходящие %s, входящие %s, поручения из терминала %s", onOff(s.AutoOut), onOff(s.AutoIn), onOff(s.PollTerminal))
} else if s.WritesEnabled {
mode = fmt.Sprintf("записи включены: исходящие %s, входящие %s, поручения из терминала %s", onOff(s.AutoOut), onOff(s.AutoIn), onOff(s.PollTerminal)) mode = fmt.Sprintf("записи включены: исходящие %s, входящие %s, поручения из терминала %s", onOff(s.AutoOut), onOff(s.AutoIn), onOff(s.PollTerminal))
} }
setupFlash(w, r, "QUIK МНП: цикл M2M сохранён — "+mode) setupFlash(w, r, "QUIK МНП: цикл M2M сохранён — "+mode)
+5 -5
View File
@@ -45,7 +45,7 @@ func quikSecISIN(s m2m.RequestSecurity) string {
// ОТМЕНЯЕМ (нельзя переводить то, что не заблокировано). // ОТМЕНЯЕМ (нельзя переводить то, что не заблокировано).
func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest) error { func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest) error {
q := rc.Snapshot().Quik q := rc.Snapshot().Quik
if !q.WritesEnabled || !q.AutoOut || req == nil { if !quikActive(q) || !q.AutoOut || req == nil {
return nil return nil
} }
guid := string(req.Header.GUID) guid := string(req.Header.GUID)
@@ -89,7 +89,7 @@ func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2M
// quikOnDecision — ответ контрагента: по отказанным бумагам снимаем поручения. // quikOnDecision — ответ контрагента: по отказанным бумагам снимаем поручения.
func quikOnDecision(ctx context.Context, rc *RuntimeConfig, svc *Service, decision *m2m.M2MTransferDecision) { func quikOnDecision(ctx context.Context, rc *RuntimeConfig, svc *Service, decision *m2m.M2MTransferDecision) {
q := rc.Snapshot().Quik q := rc.Snapshot().Quik
if !q.WritesEnabled || !q.AutoOut || decision == nil { if !quikActive(q) || !q.AutoOut || decision == nil {
return return
} }
guid := string(decision.Header.GUID) guid := string(decision.Header.GUID)
@@ -120,7 +120,7 @@ func quikOnDecision(ctx context.Context, rc *RuntimeConfig, svc *Service, decisi
// quikOnServiceError — сервис МОСТ отверг перевод целиком: снимаем все поручения. // quikOnServiceError — сервис МОСТ отверг перевод целиком: снимаем все поручения.
func quikOnServiceError(ctx context.Context, rc *RuntimeConfig, guid, reason string) { func quikOnServiceError(ctx context.Context, rc *RuntimeConfig, guid, reason string) {
q := rc.Snapshot().Quik q := rc.Snapshot().Quik
if !q.WritesEnabled || !q.AutoOut { if !quikActive(q) || !q.AutoOut {
return return
} }
for _, l := range globalQuikLinks.ForGUID(guid) { for _, l := range globalQuikLinks.ForGUID(guid) {
@@ -148,7 +148,7 @@ func orderRespVerdict(resps []orderResponse) (done, rejected bool, text string)
// quikCycleTick — один проход опросчика. // quikCycleTick — один проход опросчика.
func quikCycleTick(ctx context.Context, rc *RuntimeConfig) { func quikCycleTick(ctx context.Context, rc *RuntimeConfig) {
q := rc.Snapshot().Quik q := rc.Snapshot().Quik
if !q.WritesEnabled || q.Server == "" { if !quikActive(q) || q.Server == "" {
return return
} }
// исходящие: наше поручение 16 принято НРД → подпись; отказ → снятие // исходящие: наше поручение 16 принято НРД → подпись; отказ → снятие
@@ -303,7 +303,7 @@ func registerQuikOps(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
} }
guid, isin, op := strings.TrimSpace(r.FormValue("guid")), strings.TrimSpace(r.FormValue("isin")), r.FormValue("op") guid, isin, op := strings.TrimSpace(r.FormValue("guid")), strings.TrimSpace(r.FormValue("isin")), r.FormValue("op")
q := rc.Snapshot().Quik q := rc.Snapshot().Quik
if !q.WritesEnabled { if !quikActive(q) {
flash("QUIK МНП: записи в МНП выключены (Настройка → QUIK МНП → Автоматический цикл)") flash("QUIK МНП: записи в МНП выключены (Настройка → QUIK МНП → Автоматический цикл)")
return return
} }
+2
View File
@@ -24,6 +24,7 @@ type Settings struct {
Postgres PostgresSettings `json:"postgres"` Postgres PostgresSettings `json:"postgres"`
Fancy FancyMSSQLSettings `json:"fancy_mssql"` Fancy FancyMSSQLSettings `json:"fancy_mssql"`
Quik QuikMNPSettings `json:"quik_mnp"` Quik QuikMNPSettings `json:"quik_mnp"`
M2MAuto M2MAutoSettings `json:"m2m_auto"`
Crypto CryptoSettings `json:"crypto"` Crypto CryptoSettings `json:"crypto"`
NSD NSDSettings `json:"nsd"` NSD NSDSettings `json:"nsd"`
LK LKSettings `json:"lk"` LK LKSettings `json:"lk"`
@@ -60,6 +61,7 @@ type QuikMNPSettings struct {
// Автоматический цикл M2M в МНП (пишущие операции к боевой БД). // Автоматический цикл M2M в МНП (пишущие операции к боевой БД).
WritesEnabled bool `json:"writes_enabled"` // главный предохранитель: без него ни одной записи в МНП WritesEnabled bool `json:"writes_enabled"` // главный предохранитель: без него ни одной записи в МНП
Emulate bool `json:"emulate"` // имитация: шаги цикла проходят без записи в МНП (нет пользователя QUIK для блокировок)
AutoOut bool `json:"auto_out"` // исходящие: регистрировать «Вывод ЦБ (M2M)» при отправке, подписывать/снимать по ответу AutoOut bool `json:"auto_out"` // исходящие: регистрировать «Вывод ЦБ (M2M)» при отправке, подписывать/снимать по ответу
AutoIn bool `json:"auto_in"` // входящие: регистрировать «Ввод ЦБ (M2M)» после приёма поручения НРД AutoIn bool `json:"auto_in"` // входящие: регистрировать «Ввод ЦБ (M2M)» после приёма поручения НРД
PollTerminal bool `json:"poll_terminal"` // подхватывать поручения, поданные клиентами из QUIK (Export) PollTerminal bool `json:"poll_terminal"` // подхватывать поручения, поданные клиентами из QUIK (Export)
+10 -4
View File
@@ -172,20 +172,24 @@ func NewServer(cfg ServerConfig) (*Server, error) {
registerM2MTest(mux, adminTpl, &setupHandlers{rc: rc, tpl: &adminTemplates{a: adminTpl}, svc: svc}) registerM2MTest(mux, adminTpl, &setupHandlers{rc: rc, tpl: &adminTemplates{a: adminTpl}, svc: svc})
incomingStore := NewIncomingStore() incomingStore := NewIncomingStore()
globalIncomingStore = incomingStore // для сводной выгрузки отчётов тест-плана globalIncomingStore = incomingStore // для сводной выгрузки отчётов тест-плана
registerIncoming(mux, adminTpl, incomingDeps{ inDeps := incomingDeps{
store: incomingStore, store: incomingStore,
igwClient: igwClient, igwClient: igwClient,
igwChan: igwChannel, igwChan: igwChannel,
rc: rc, rc: rc,
ourCode: string(cfg.DefaultSender), ourCode: string(cfg.DefaultSender),
}) }
globalIncomingDeps = &inDeps // автоматический режим M2M
registerIncoming(mux, adminTpl, inDeps)
globalOutgoingStore = NewOutgoingStore() globalOutgoingStore = NewOutgoingStore()
registerDepoSim(mux, adminTpl) // песочница сверки с DEPO (имитация ответа Fancy) registerDepoSim(mux, adminTpl) // песочница сверки с DEPO (имитация ответа Fancy)
registerOutgoing(mux, adminTpl, outgoingDeps{ outDeps := outgoingDeps{
store: globalOutgoingStore, store: globalOutgoingStore,
igwClient: igwClient, igwClient: igwClient,
igwChan: igwChannel, igwChan: igwChannel,
}) }
globalOutgoingDeps = &outDeps // автоматический режим M2M
registerOutgoing(mux, adminTpl, outDeps)
registerJournal(mux, adminTpl, svc, incomingStore) registerJournal(mux, adminTpl, svc, incomingStore)
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk)
registerHealth(mux) registerHealth(mux)
@@ -296,6 +300,8 @@ func (s *Server) Run(ctx context.Context) error {
go superviseLoop(ctx, "depo-events", s.rc, s.depoEventsLoop) go superviseLoop(ctx, "depo-events", s.rc, s.depoEventsLoop)
healthRegister("depo-job", 2*time.Minute) healthRegister("depo-job", 2*time.Minute)
go superviseLoop(ctx, "depo-job", s.rc, s.depoJobLoop) go superviseLoop(ctx, "depo-job", s.rc, s.depoJobLoop)
healthRegister("m2m-auto", 2*time.Minute)
go superviseLoop(ctx, "m2m-auto", s.rc, s.m2mAutoLoop)
// Перепроверка групп AD раз в 15 мин (если задана сервисная учётная запись). // Перепроверка групп AD раз в 15 мин (если задана сервисная учётная запись).
// Тик длинный, контроллер может отвечать медленно — только recover, без heartbeat. // Тик длинный, контроллер может отвечать медленно — только recover, без heartbeat.
+3
View File
@@ -106,6 +106,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
// Доверенные сертификаты НРД в справочнике ИШ (вшитый комплект + ручные). // Доверенные сертификаты НРД в справочнике ИШ (вшитый комплект + ручные).
registerQuikExportSetup(mux, h) registerQuikExportSetup(mux, h)
mux.HandleFunc("/admin/setup/versions/refresh", h.refreshVersionsHandler) mux.HandleFunc("/admin/setup/versions/refresh", h.refreshVersionsHandler)
mux.HandleFunc("/admin/setup/m2m-auto", h.saveM2MAuto)
registerUsersSetup(mux, h) registerUsersSetup(mux, h)
mux.HandleFunc("/admin/setup/npr", h.saveNPR) mux.HandleFunc("/admin/setup/npr", h.saveNPR)
mux.HandleFunc("/admin/setup/npr/test", h.testNPR) mux.HandleFunc("/admin/setup/npr/test", h.testNPR)
@@ -328,6 +329,8 @@ func HealthLabel(name string) string {
return "Сроки депо-поручений НРД (MS010 5 мин / 30 дней)" return "Сроки депо-поручений НРД (MS010 5 мин / 30 дней)"
case "depo-events": case "depo-events":
return "Обмен с DEPO (bj2depo): события и справочник" return "Обмен с DEPO (bj2depo): события и справочник"
case "m2m-auto":
return "Автоматический режим M2M (решения, 16/1, форма 16)"
case "depo-job": case "depo-job":
return "Обработка трансферных таблиц DEPO силами bj" return "Обработка трансферных таблиц DEPO силами bj"
} }
@@ -227,13 +227,16 @@
</div> </div>
<div class="card" id="sec-quik-cycle"> <div class="card" id="sec-quik-cycle">
<h2><span class="dot {{if not .Settings.Quik.WritesEnabled}}warn{{else if or .Settings.Quik.AutoOut .Settings.Quik.AutoIn}}ok{{else}}warn{{end}}"></span>Автоматический цикл M2M в МНП</h2> <h2><span class="dot {{if .Settings.Quik.Emulate}}warn{{else if not .Settings.Quik.WritesEnabled}}warn{{else if or .Settings.Quik.AutoOut .Settings.Quik.AutoIn}}ok{{else}}warn{{end}}"></span>Автоматический цикл M2M в МНП{{if .Settings.Quik.Emulate}} <span class="hero-status warn">имитация</span>{{end}}</h2>
<p class="muted">Что делает bj в Модуле неторговых поручений по ходу перевода: <strong>исходящий</strong> — при отправке в НРД регистрирует «Вывод ЦБ (M2M)» (QUIK блокирует бумаги), по подтверждению подписывает (списание), по отказу снимает с причиной; <strong>входящий</strong> — после приёма нашего поручения НРД регистрирует «Ввод ЦБ (M2M)» (зачисление сразу). Проверено на боевом МНП 09.09.2026. Каждый шаг виден в карточке перевода и на странице <a href="/admin/quik">QUIK МНП</a>.</p> <p class="muted">Что делает bj в Модуле неторговых поручений по ходу перевода: <strong>исходящий</strong> — при отправке в НРД регистрирует «Вывод ЦБ (M2M)» (QUIK блокирует бумаги), по подтверждению подписывает (списание), по отказу снимает с причиной; <strong>входящий</strong> — после приёма нашего поручения НРД регистрирует «Ввод ЦБ (M2M)» (зачисление сразу). Проверено на боевом МНП 09.09.2026. Каждый шаг виден в карточке перевода и на странице <a href="/admin/quik">QUIK МНП</a>.</p>
<form method="post" action="/admin/setup/quik/cycle" style="margin-top:12px;display:grid;gap:10px;max-width:640px"> <form method="post" action="/admin/setup/quik/cycle" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
<input type="hidden" name="section" value="sec-quik-cycle"> <input type="hidden" name="section" value="sec-quik-cycle">
<label style="display:flex;gap:8px;align-items:center;font-weight:600"> <label style="display:flex;gap:8px;align-items:center;font-weight:600">
<input type="checkbox" name="writes_enabled" value="1" {{if .Settings.Quik.WritesEnabled}}checked{{end}}> <input type="checkbox" name="writes_enabled" value="1" {{if .Settings.Quik.WritesEnabled}}checked{{end}}>
Разрешить записи в МНП (главный предохранитель — база боевая)</label> Разрешить записи в МНП (главный предохранитель — база боевая)</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:600">
<input type="checkbox" name="emulate" value="1" {{if .Settings.Quik.Emulate}}checked{{end}}>
Имитация: шаги цикла (блокировка, подпись, снятие, зачисление) проходят и отмечаются «имитация», в МНП ничего не пишется — пока нет пользователя QUIK для блокировок и лимитов. Имеет приоритет над записью.</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400;margin-left:22px"> <label style="display:flex;gap:8px;align-items:center;font-weight:400;margin-left:22px">
<input type="checkbox" name="auto_out" value="1" {{if .Settings.Quik.AutoOut}}checked{{end}}> <input type="checkbox" name="auto_out" value="1" {{if .Settings.Quik.AutoOut}}checked{{end}}>
Исходящие переводы: регистрировать при отправке, подписывать / снимать по ответу автоматически</label> Исходящие переводы: регистрировать при отправке, подписывать / снимать по ответу автоматически</label>
@@ -469,6 +472,16 @@
<section class="settings-section" id="sec-nsd"> <section class="settings-section" id="sec-nsd">
<h1>НРД</h1> <h1>НРД</h1>
{{template "verline" (.Versions.Get "ish")}} {{template "verline" (.Versions.Get "ish")}}
<div class="card" id="m2m-auto">
<h2><span class="dot {{if .Settings.M2MAuto.Enabled}}ok{{else}}warn{{end}}"></span>Автоматический режим M2M</h2>
<p class="muted">Без участия оператора, чтобы соблюдать сроки: <strong>ответ на входящие переводы</strong> по свежей сверке с DEPO (пакет принимается только целиком; бумага для квалифицированных инвесторов при неполной или непрочитанной квалификации клиента — отказ M2M08), <strong>подача поручения 16/1</strong>, как только НРД пришлёт черновик после нашего согласия, и <strong>подача поручения 16</strong> по исходящим после согласия контрагента. Если DEPO или ИШ недоступны — bj повторяет каждые 3 минуты и шлёт оповещение; без сверки ничего не принимается. Обрабатывается только то, что пришло не раньше чем за сутки до включения. Кнопки в карточках остаются для ручной работы.</p>
<form method="post" action="/admin/setup/m2m-auto" style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:10px">
<input type="hidden" name="section" value="m2m-auto">
<label style="display:flex;gap:8px;align-items:center;font-weight:600;margin:0"><input type="checkbox" name="enabled" value="1" {{if .Settings.M2MAuto.Enabled}}checked{{end}}> Автоматический режим включён</label>
<button type="submit" class="btn btn-ok">Сохранить</button>
</form>
{{if not .Settings.M2MAuto.ChangedAt.IsZero}}<p class="muted" style="font-size:12px;margin-top:8px">{{if .Settings.M2MAuto.Enabled}}Включён{{else}}Выключен{{end}} {{msk .Settings.M2MAuto.ChangedAt}}{{with .Settings.M2MAuto.ChangedBy}} · {{.}}{{end}}.{{if .Settings.Quik.Emulate}} Шаги QUIK МНП — в режиме имитации.{{end}}</p>{{end}}
</div>
<div class="card"> <div class="card">
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span>Интеграционный шлюз (ИШ)</h2> <h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span>Интеграционный шлюз (ИШ)</h2>
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}<code>mock-режим</code> — без реального ИШ.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}} Стенды и установка — <a href="/admin/help/systems">справка</a>.</p> <p class="muted">{{if not .Settings.NSD.IGWBaseURL}}<code>mock-режим</code> — без реального ИШ.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}} Стенды и установка — <a href="/admin/help/systems">справка</a>.</p>