bf7c7b53bd
Настройка в шаге «Время и сеть» и Настройка → Сервер: дни недели, часы (по поясу сервера), включение правила. Запрос, полученный вне рабочего времени (и после включения правила), bj сразу отклоняет по всем бумагам кодом M2M06 «Приостановление операций по счёту депо зачисления» — кода «вне рабочего времени» в перечне НРД (инструкция по обмену M2M, раздел 7.4) нет, решение депозитария. Строка «Рабочее время» в проверке готовности; сквозной тест отказа через ИШ. Файл входящих — переменная (тесты не трогают сервер). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
308 lines
13 KiB
Go
308 lines
13 KiB
Go
package lkgateway
|
|
|
|
// Автоматический режим M2M (решение 16.09.2026): ручные шаги оператора выполняются без участия
|
|
// человека, чтобы соблюдать сроки и убрать человеческий фактор.
|
|
//
|
|
// - Входящий перевод ждёт решения → свежая сверка с DEPO → решение по её рекомендации: каждая
|
|
// бумага решается сама по себе (частичный приём), переводы на ИИС — отказ M2M07 по всем бумагам;
|
|
// предупреждение со 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) {
|
|
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 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 выключен — решения и поручения снова отправляет оператор.")
|
|
}
|