Автоматический режим 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
+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 выключен — решения и поручения снова отправляет оператор.")
}