package lkgateway // Автоматический режим M2M (решение 16.09.2026): ручные шаги оператора выполняются без участия // человека, чтобы соблюдать сроки и убрать человеческий фактор. // // - Входящий перевод ждёт решения → свежая сверка с DEPO → решение по её рекомендации: каждая // бумага решается сама по себе (частичный приём), переводы на ИИС — отказ M2M06 по всем бумагам; // предупреждение со 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) { workHoursTick(ctx, rc, now) // вне рабочего времени — отказ M2M06 в любом режиме (workhours.go) 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{} strictSec := map[string]bool{} // бумаги для квал. инвесторов при неполной квалификации — отказ M2M08 именно по ним strict := "" for _, sr := range r.Secs { bySec[sr.ReferenceID] = sr for _, c := range sr.Checks { if c.Strict && c.Warn { strictSec[sr.ReferenceID] = true if 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 strictSec[sec.ReferenceID]: 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 = iisDecisions(it, 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 m := r.FormValue("mode"); m != "" { // карточка «Кто отвечает на переводы M2M»: выбор плиткой on = m == "auto" } ajax := r.Header.Get("X-BJ-Ajax") != "" reply := func(msg string) { if ajax { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte(msg)) return } setupFlash(w, r, "Автоматический режим M2M: "+msg) } if on == cur.Enabled { reply("без изменений") 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 { reply("ошибка сохранения: " + err.Error()) return } if on { log.Printf("m2m-auto: автоматический режим ВКЛЮЧЁН (%s)", s.ChangedBy) reply("включён — решения по входящим, поручения 16/1 и 16 bj отправляет сам (первый проход — в течение 30 секунд)") return } log.Printf("m2m-auto: автоматический режим выключен (%s)", s.ChangedBy) reply("выключен — решения и поручения отправляет оператор") }