56068af897
- QUIK: в карточке режима — одна галочка «Клиенты подают поручения из QUIK (терминал и мобильное приложение)», сохраняется сразу; предупреждение, если режим изменён в расширенных; «Расширенные настройки» (отдельно исходящие/входящие, инструменты МНП, коды клиентов) свёрнуты, без дубля счёта и галочки терминала; формы цикла не сбрасывают поля, которых не прислали. - НРД: организация — код, ИНН, наименование в ряд; реквизиты в НРД — общий блок с мастером (nsdRequisites), «Определить из DEPO» видна (была белым по белому); блок шлюза из мастера (контуры, доступ к папкам по сети) — и в настройках; ручная настройка шлюза — под «Расширенными настройками». - Автоматический режим M2M: вместо галочки с «Сохранить» — выбор плиткой «bj сам — автоматически / Оператор», сохраняется сразу (m2mAutoCard); тот же выбор — в мастере на шаге «Проверка»; подробности — под раскрытием. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
320 lines
13 KiB
Go
320 lines
13 KiB
Go
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:<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 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("выключен — решения и поручения отправляет оператор")
|
|
}
|