Автоматический режим 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:
@@ -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 выключен — решения и поручения снова отправляет оператор.")
|
||||
}
|
||||
Reference in New Issue
Block a user