From d47229c9a50f8b01a0d6c884e1ed35539b83979d Mon Sep 17 00:00:00 2001 From: zuevav Date: Wed, 16 Sep 2026 14:59:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20=D1=80=D0=B5=D0=B6?= =?UTF-8?q?=D0=B8=D0=BC=20M2M=20=D0=B8=20=D0=B8=D0=BC=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20QUIK=20=D0=9C=D0=9D=D0=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Автоматический режим (Настройка → НРД, выключен по умолчанию): входящие переводы — решение по свежей сверке с DEPO без оператора (пакет только целиком; бумага для квал. инвесторов при неполной или непрочитанной квалификации — отказ M2M08), поручение 16/1 — как только пришёл черновик MF010, поручение 16 по исходящим — по черновику. Нет сверки или ИШ — повтор каждые 3 минуты и оповещение, без сверки ничего не принимается. Обрабатывается только появившееся не раньше суток до включения (старые черновики в INBOX не подаются). Решение не отправляется повторно по уже отвеченному переводу. Имитация QUIK МНП (Настройка → QUIK МНП → цикл): регистрация, подпись, снятие и зачисление проходят как выполненные с пометкой «имитация», в МНП ничего не пишется; события в DEPO — как обычно. Co-Authored-By: Claude Opus 5 (1M context) --- internal/lkgateway/depo_check.go | 5 +- internal/lkgateway/incoming_answer.go | 3 + internal/lkgateway/incoming_order.go | 41 ++- internal/lkgateway/m2mauto.go | 301 ++++++++++++++++++ internal/lkgateway/m2mauto_test.go | 85 +++++ internal/lkgateway/quikcycle.go | 30 ++ internal/lkgateway/quikdb_handlers.go | 7 +- internal/lkgateway/quikhooks.go | 10 +- internal/lkgateway/runtimeconfig.go | 2 + internal/lkgateway/server.go | 14 +- internal/lkgateway/setup.go | 3 + .../lkgateway/web/templates/admin_setup.html | 15 +- 12 files changed, 486 insertions(+), 30 deletions(-) create mode 100644 internal/lkgateway/m2mauto.go create mode 100644 internal/lkgateway/m2mauto_test.go diff --git a/internal/lkgateway/depo_check.go b/internal/lkgateway/depo_check.go index b8d9251..9f3e76c 100644 --- a/internal/lkgateway/depo_check.go +++ b/internal/lkgateway/depo_check.go @@ -44,6 +44,7 @@ type depoCheck struct { OK bool Code string // код причины отказа (M2Mxx или QTY/PLACE) Warn bool // пройдено с предупреждением + Strict bool // предупреждение, которое автоматический режим считает отказом } // depoSecResult — результат по одной бумаге. @@ -282,8 +283,8 @@ func qualOnlyCheck(found, qualOnly bool, qdet string, p depoParty) depoCheck { c.OK = false c.Detail += " — инвестор НЕ квалифицированный: операция нарушит закон РФ" default: - c.Warn = true - c.Detail += " — " + p.qualDet + "; вид бумаги сверяет оператор" + c.Warn, c.Strict = true, true + c.Detail += " — " + p.qualDet + "; вид бумаги сверяет оператор (в автоматическом режиме — отказ M2M08)" } return c } diff --git a/internal/lkgateway/incoming_answer.go b/internal/lkgateway/incoming_answer.go index fef1a9b..6dd330c 100644 --- a/internal/lkgateway/incoming_answer.go +++ b/internal/lkgateway/incoming_answer.go @@ -36,6 +36,9 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions if !ok { return fmt.Errorf("входящий перевод %s не найден", id) } + if it.Status == "answered" { + return fmt.Errorf("по переводу уже отправлено решение (%s)", it.AnswerNote) + } if deps.igwClient == nil || deps.igwChan == "" { return fmt.Errorf("ИШ не настроен — нельзя отправить решение") } diff --git a/internal/lkgateway/incoming_order.go b/internal/lkgateway/incoming_order.go index 00a8578..0fe07d2 100644 --- a/internal/lkgateway/incoming_order.go +++ b/internal/lkgateway/incoming_order.go @@ -64,10 +64,27 @@ func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps, redirect := func(msg string) { http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther) } - if deps.igwClient == nil || deps.igwChan == "" { - redirect("ИШ не настроен — нельзя отправить поручение") + ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) + defer cancel() + sent, firstErr, err := submitIncomingOrderFor(ctx, deps, it) + if err != nil { + redirect(err.Error()) 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) — подпись НРД нам не нужна, ИШ подпишет своей ГОСТ. var xmls []string for _, p := range findMF010Files(it.GUID) { @@ -76,13 +93,11 @@ func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps, } } if len(xmls) == 0 { - redirect("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден") - return + return nil, "", fmt.Errorf("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден") } - ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) - defer cancel() - var sent, sentXML []string - var firstErr, contrDID string + id := it.ID + var sentXML []string + var contrDID string for _, p := range xmls { raw, e := os.ReadFile(p) // windows-1251, шлём как есть if e != nil { @@ -116,15 +131,9 @@ func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps, if firstErr != "" { msg = firstErr } - redirect("Поручение 16/1 (ORDXM): отправка в НРД не удалась: " + msg) - return + return nil, "", fmt.Errorf("Поручение 16/1 (ORDXM): отправка в НРД не удалась: %s", msg) } - note := fmt.Sprintf("Поручение 16/1 (тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+ - "Ждём отчёт по поручению депо (XMLRP) = «MS010». Ответ появится ниже на странице.", strings.Join(sent, ", ")) - if firstErr != "" { - note += " ⚠ часть файлов не ушла: " + firstErr - } - redirect(note) + return sent, firstErr, nil } // writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД diff --git a/internal/lkgateway/m2mauto.go b/internal/lkgateway/m2mauto.go new file mode 100644 index 0000000..ddee18c --- /dev/null +++ b/internal/lkgateway/m2mauto.go @@ -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:, order:, form16:). +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 выключен — решения и поручения снова отправляет оператор.") +} diff --git a/internal/lkgateway/m2mauto_test.go b/internal/lkgateway/m2mauto_test.go new file mode 100644 index 0000000..07f3b21 --- /dev/null +++ b/internal/lkgateway/m2mauto_test.go @@ -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()) +} diff --git a/internal/lkgateway/quikcycle.go b/internal/lkgateway/quikcycle.go index 325bef7..21c5b0d 100644 --- a/internal/lkgateway/quikcycle.go +++ b/internal/lkgateway/quikcycle.go @@ -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 { 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.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 { 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" { return l, nil } + if s.Emulate { + return quikEmulated(rc, guid, isin, "sign", "signed", "поручение подписано, бумаги списаны"), nil + } var no float64 fmt.Sscan(l.InstrNo, &no) 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" { return l, nil } + if s.Emulate { + return quikEmulated(rc, guid, isin, "deny", "denied", "поручение снято ("+reason+")"), nil + } var no float64 fmt.Sscan(l.InstrNo, &no) 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) { 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) != "" { _ = quikEnsureRecipient(ctx, s, senderINN, senderName) } diff --git a/internal/lkgateway/quikdb_handlers.go b/internal/lkgateway/quikdb_handlers.go index 3d83129..3cf4f4e 100644 --- a/internal/lkgateway/quikdb_handlers.go +++ b/internal/lkgateway/quikdb_handlers.go @@ -70,7 +70,7 @@ func (h *setupHandlers) quikFromForm(r *http.Request) QuikMNPSettings { Trust: r.FormValue("trust") != "", Verified: cur.Verified, // поля автоматического цикла 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, } } @@ -86,6 +86,7 @@ func (h *setupHandlers) saveQuikCycle(w http.ResponseWriter, r *http.Request) { } s := h.rc.Snapshot().Quik s.WritesEnabled = r.FormValue("writes_enabled") != "" + s.Emulate = r.FormValue("emulate") != "" s.AutoOut = r.FormValue("auto_out") != "" s.AutoIn = r.FormValue("auto_in") != "" s.PollTerminal = r.FormValue("poll_terminal") != "" @@ -109,7 +110,9 @@ func (h *setupHandlers) saveQuikCycle(w http.ResponseWriter, r *http.Request) { return } 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)) } setupFlash(w, r, "QUIK МНП: цикл M2M сохранён — "+mode) diff --git a/internal/lkgateway/quikhooks.go b/internal/lkgateway/quikhooks.go index 292222b..3760eac 100644 --- a/internal/lkgateway/quikhooks.go +++ b/internal/lkgateway/quikhooks.go @@ -45,7 +45,7 @@ func quikSecISIN(s m2m.RequestSecurity) string { // ОТМЕНЯЕМ (нельзя переводить то, что не заблокировано). func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest) error { q := rc.Snapshot().Quik - if !q.WritesEnabled || !q.AutoOut || req == nil { + if !quikActive(q) || !q.AutoOut || req == nil { return nil } guid := string(req.Header.GUID) @@ -89,7 +89,7 @@ func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2M // quikOnDecision — ответ контрагента: по отказанным бумагам снимаем поручения. func quikOnDecision(ctx context.Context, rc *RuntimeConfig, svc *Service, decision *m2m.M2MTransferDecision) { q := rc.Snapshot().Quik - if !q.WritesEnabled || !q.AutoOut || decision == nil { + if !quikActive(q) || !q.AutoOut || decision == nil { return } guid := string(decision.Header.GUID) @@ -120,7 +120,7 @@ func quikOnDecision(ctx context.Context, rc *RuntimeConfig, svc *Service, decisi // quikOnServiceError — сервис МОСТ отверг перевод целиком: снимаем все поручения. func quikOnServiceError(ctx context.Context, rc *RuntimeConfig, guid, reason string) { q := rc.Snapshot().Quik - if !q.WritesEnabled || !q.AutoOut { + if !quikActive(q) || !q.AutoOut { return } for _, l := range globalQuikLinks.ForGUID(guid) { @@ -148,7 +148,7 @@ func orderRespVerdict(resps []orderResponse) (done, rejected bool, text string) // quikCycleTick — один проход опросчика. func quikCycleTick(ctx context.Context, rc *RuntimeConfig) { q := rc.Snapshot().Quik - if !q.WritesEnabled || q.Server == "" { + if !quikActive(q) || q.Server == "" { return } // исходящие: наше поручение 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") q := rc.Snapshot().Quik - if !q.WritesEnabled { + if !quikActive(q) { flash("QUIK МНП: записи в МНП выключены (Настройка → QUIK МНП → Автоматический цикл)") return } diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 087de99..242f023 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -24,6 +24,7 @@ type Settings struct { Postgres PostgresSettings `json:"postgres"` Fancy FancyMSSQLSettings `json:"fancy_mssql"` Quik QuikMNPSettings `json:"quik_mnp"` + M2MAuto M2MAutoSettings `json:"m2m_auto"` Crypto CryptoSettings `json:"crypto"` NSD NSDSettings `json:"nsd"` LK LKSettings `json:"lk"` @@ -60,6 +61,7 @@ type QuikMNPSettings struct { // Автоматический цикл M2M в МНП (пишущие операции к боевой БД). WritesEnabled bool `json:"writes_enabled"` // главный предохранитель: без него ни одной записи в МНП + Emulate bool `json:"emulate"` // имитация: шаги цикла проходят без записи в МНП (нет пользователя QUIK для блокировок) AutoOut bool `json:"auto_out"` // исходящие: регистрировать «Вывод ЦБ (M2M)» при отправке, подписывать/снимать по ответу AutoIn bool `json:"auto_in"` // входящие: регистрировать «Ввод ЦБ (M2M)» после приёма поручения НРД PollTerminal bool `json:"poll_terminal"` // подхватывать поручения, поданные клиентами из QUIK (Export) diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 5a2f411..efd8f43 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -172,20 +172,24 @@ func NewServer(cfg ServerConfig) (*Server, error) { registerM2MTest(mux, adminTpl, &setupHandlers{rc: rc, tpl: &adminTemplates{a: adminTpl}, svc: svc}) incomingStore := NewIncomingStore() globalIncomingStore = incomingStore // для сводной выгрузки отчётов тест-плана - registerIncoming(mux, adminTpl, incomingDeps{ + inDeps := incomingDeps{ store: incomingStore, igwClient: igwClient, igwChan: igwChannel, rc: rc, ourCode: string(cfg.DefaultSender), - }) + } + globalIncomingDeps = &inDeps // автоматический режим M2M + registerIncoming(mux, adminTpl, inDeps) globalOutgoingStore = NewOutgoingStore() registerDepoSim(mux, adminTpl) // песочница сверки с DEPO (имитация ответа Fancy) - registerOutgoing(mux, adminTpl, outgoingDeps{ + outDeps := outgoingDeps{ store: globalOutgoingStore, igwClient: igwClient, igwChan: igwChannel, - }) + } + globalOutgoingDeps = &outDeps // автоматический режим M2M + registerOutgoing(mux, adminTpl, outDeps) registerJournal(mux, adminTpl, svc, incomingStore) registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) registerHealth(mux) @@ -296,6 +300,8 @@ func (s *Server) Run(ctx context.Context) error { go superviseLoop(ctx, "depo-events", s.rc, s.depoEventsLoop) healthRegister("depo-job", 2*time.Minute) 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 мин (если задана сервисная учётная запись). // Тик длинный, контроллер может отвечать медленно — только recover, без heartbeat. diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 7cfa8bc..f12aee3 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -106,6 +106,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service // Доверенные сертификаты НРД в справочнике ИШ (вшитый комплект + ручные). registerQuikExportSetup(mux, h) mux.HandleFunc("/admin/setup/versions/refresh", h.refreshVersionsHandler) + mux.HandleFunc("/admin/setup/m2m-auto", h.saveM2MAuto) registerUsersSetup(mux, h) mux.HandleFunc("/admin/setup/npr", h.saveNPR) mux.HandleFunc("/admin/setup/npr/test", h.testNPR) @@ -328,6 +329,8 @@ func HealthLabel(name string) string { return "Сроки депо-поручений НРД (MS010 5 мин / 30 дней)" case "depo-events": return "Обмен с DEPO (bj2depo): события и справочник" + case "m2m-auto": + return "Автоматический режим M2M (решения, 16/1, форма 16)" case "depo-job": return "Обработка трансферных таблиц DEPO силами bj" } diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index aef7145..05e08e8 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -227,13 +227,16 @@
-

Автоматический цикл M2M в МНП

+

Автоматический цикл M2M в МНП{{if .Settings.Quik.Emulate}} имитация{{end}}

Что делает bj в Модуле неторговых поручений по ходу перевода: исходящий — при отправке в НРД регистрирует «Вывод ЦБ (M2M)» (QUIK блокирует бумаги), по подтверждению подписывает (списание), по отказу снимает с причиной; входящий — после приёма нашего поручения НРД регистрирует «Ввод ЦБ (M2M)» (зачисление сразу). Проверено на боевом МНП 09.09.2026. Каждый шаг виден в карточке перевода и на странице QUIK МНП.

+ @@ -469,6 +472,16 @@

НРД

{{template "verline" (.Versions.Get "ish")}} +
+

Автоматический режим M2M

+

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

+ + + + + + {{if not .Settings.M2MAuto.ChangedAt.IsZero}}

{{if .Settings.M2MAuto.Enabled}}Включён{{else}}Выключен{{end}} {{msk .Settings.M2MAuto.ChangedAt}}{{with .Settings.M2MAuto.ChangedBy}} · {{.}}{{end}}.{{if .Settings.Quik.Emulate}} Шаги QUIK МНП — в режиме имитации.{{end}}

{{end}} +

Интеграционный шлюз (ИШ)

{{if not .Settings.NSD.IGWBaseURL}}mock-режим — без реального ИШ.{{else}}Профиль {{.Settings.NSD.Profile}}, ИШ {{.Settings.NSD.IGWBaseURL}}.{{end}} Стенды и установка — справка.