Рабочее время организации: запросы M2M вне него — отказ M2M06 (в любом режиме)

Настройка в шаге «Время и сеть» и Настройка → Сервер: дни недели, часы (по поясу сервера), включение
правила. Запрос, полученный вне рабочего времени (и после включения правила), bj сразу отклоняет по всем
бумагам кодом M2M06 «Приостановление операций по счёту депо зачисления» — кода «вне рабочего времени» в
перечне НРД (инструкция по обмену M2M, раздел 7.4) нет, решение депозитария. Строка «Рабочее время» в
проверке готовности; сквозной тест отказа через ИШ. Файл входящих — переменная (тесты не трогают сервер).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-06 23:35:50 +03:00
parent dfe5796c02
commit bf7c7b53bd
11 changed files with 376 additions and 1 deletions
+210
View File
@@ -0,0 +1,210 @@
package lkgateway
// Рабочее время организации (06.10.2026): запросы на перевод M2M принимаются только в рабочее время. Запрос,
// полученный вне его, bj сразу отклоняет по всем бумагам кодом M2M06 «Приостановление операций по счёту депо
// зачисления» — и в автоматическом режиме, и в ручном (оператор такой запрос уже не решает). Так решил
// депозитарий: отдельного кода «вне рабочего времени» в перечне НРД нет (инструкция НРД по обмену M2M,
// раздел 7.4: у зачисляющего только M2M01–M2M09). Время — по часовому поясу сервера (шаг «Время и сеть»).
import (
"context"
"fmt"
"log"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// workHoursRejectCode — код отказа по запросу, полученному вне рабочего времени.
const workHoursRejectCode = "M2M06"
// WorkHoursSettings — рабочее время организации.
type WorkHoursSettings struct {
Enabled bool `json:"enabled"` // запросы M2M — только в рабочее время
Days []int `json:"days,omitempty"` // 1 — понедельник … 7 — воскресенье; пусто — пн–пт
From string `json:"from,omitempty"` // «09:00»
To string `json:"to,omitempty"` // «18:00»
SavedAt time.Time `json:"saved_at,omitempty"`
}
func (w WorkHoursSettings) days() []int {
if len(w.Days) == 0 {
return []int{1, 2, 3, 4, 5}
}
return w.Days
}
// FromText, ToText — начало и конец рабочего дня (для формы).
func (w WorkHoursSettings) FromText() string { return pickStr(w.From, "09:00") }
func (w WorkHoursSettings) ToText() string { return pickStr(w.To, "18:00") }
// HasDay — день недели (1 — пн … 7 — вс) рабочий.
func (w WorkHoursSettings) HasDay(d int) bool {
for _, x := range w.days() {
if x == d {
return true
}
}
return false
}
// hhmm — «09:30» → минуты от полуночи.
func hhmm(s string) (int, bool) {
h, m, ok := strings.Cut(strings.TrimSpace(s), ":")
hi, e1 := strconv.Atoi(h)
mi, e2 := strconv.Atoi(m)
if !ok || e1 != nil || e2 != nil || hi < 0 || hi > 24 || mi < 0 || mi > 59 || (hi == 24 && mi != 0) {
return 0, false
}
return hi*60 + mi, true
}
// Open — момент t в рабочем времени (по часовому поясу сервера). Правило выключено — всегда да.
func (w WorkHoursSettings) Open(t time.Time) bool {
if !w.Enabled {
return true
}
t = t.In(time.Local)
wd := int(t.Weekday())
if wd == 0 {
wd = 7
}
from, _ := hhmm(w.FromText())
to, _ := hhmm(w.ToText())
m := t.Hour()*60 + t.Minute()
if from < to { // обычный день
return w.HasDay(wd) && m >= from && m < to
}
if from == to { // круглые сутки в рабочие дни
return w.HasDay(wd)
}
// ночная смена (например 20:00–08:00): начало — в рабочий день, хвост — утром следующего
prev := wd - 1
if prev == 0 {
prev = 7
}
return (w.HasDay(wd) && m >= from) || (w.HasDay(prev) && m < to)
}
var weekdayShort = []string{"", "пн", "вт", "ср", "чт", "пт", "сб", "вс"}
// Text — «пн–пт 09:00–18:00».
func (w WorkHoursSettings) Text() string {
d := append([]int(nil), w.days()...)
sort.Ints(d)
var parts []string
for i := 0; i < len(d); {
j := i
for j+1 < len(d) && d[j+1] == d[j]+1 {
j++
}
switch {
case j == i:
parts = append(parts, weekdayShort[d[i]])
case j == i+1:
parts = append(parts, weekdayShort[d[i]], weekdayShort[d[j]])
default:
parts = append(parts, weekdayShort[d[i]]+"–"+weekdayShort[d[j]])
}
i = j + 1
}
return strings.Join(parts, ", ") + " " + w.FromText() + "–" + w.ToText()
}
func (r *RuntimeConfig) UpdateWorkHours(w WorkHoursSettings) error {
r.mu.Lock()
r.data.WorkHours = w
r.data.UpdatedAt = time.Now().UTC()
r.mu.Unlock()
return r.save()
}
// saveWorkHours — POST /admin/setup/workhours (шаг «Время и сеть», Настройка → Сервер).
func (h *setupHandlers) saveWorkHours(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
_ = r.ParseForm()
wh := WorkHoursSettings{Enabled: r.FormValue("enabled") != "", SavedAt: time.Now()}
for _, v := range r.Form["day"] {
if d, err := strconv.Atoi(v); err == nil && d >= 1 && d <= 7 && !wh.HasDayExplicit(d) {
wh.Days = append(wh.Days, d)
}
}
sort.Ints(wh.Days)
if len(wh.Days) == 0 {
setupFlash(w, r, "Рабочее время: отметьте хотя бы один рабочий день")
return
}
from, ok1 := hhmm(r.FormValue("from"))
to, ok2 := hhmm(r.FormValue("to"))
if !ok1 || !ok2 {
setupFlash(w, r, "Рабочее время: время — в виде ЧЧ:ММ, например 09:00 и 18:00")
return
}
wh.From, wh.To = fmt.Sprintf("%02d:%02d", from/60, from%60), fmt.Sprintf("%02d:%02d", to/60, to%60)
if err := h.rc.UpdateWorkHours(wh); err != nil {
setupFlash(w, r, "Рабочее время: "+err.Error())
return
}
msg := "Рабочее время: " + wh.Text() + " — запросы M2M вне него отклоняются кодом " + workHoursRejectCode
if !wh.Enabled {
msg = "Рабочее время: " + wh.Text() + " — сохранено; запросы M2M принимаются круглосуточно"
}
setupFlash(w, r, msg)
}
// HasDayExplicit — день есть в явно заданном списке (без подстановки пн–пт по умолчанию).
func (w WorkHoursSettings) HasDayExplicit(d int) bool {
for _, x := range w.Days {
if x == d {
return true
}
}
return false
}
// workHoursTick — отклонить кодом M2M06 входящие запросы, полученные вне рабочего времени (и ещё без ответа).
// Запросы, пришедшие до включения правила, не трогаются: решение по ним — как раньше.
func workHoursTick(ctx context.Context, rc *RuntimeConfig, now time.Time) {
wh := rc.Snapshot().WorkHours
d := globalIncomingDeps
if !wh.Enabled || d == nil {
return
}
for _, it := range d.store.List() {
if it.Status != "pending" || it.ReceivedAt.Before(wh.SavedAt) || wh.Open(it.ReceivedAt) {
continue
}
key := "workhours:" + it.ID
if !m2mAuto.due(key, now) {
continue
}
var decisions []SecurityDecision
for _, s := range it.Securities {
decisions = append(decisions, SecurityDecision{ReferenceID: s.ReferenceID, RejectCode: workHoursRejectCode})
}
who := fmt.Sprintf("Перевод %s от %s (%s %s)", shortGUID(it.GUID), it.SenderCode, it.InvestorLast, it.InvestorFirst)
cctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
err := answerIncoming(cctx, *d, it.ID, decisions)
cancel()
if err != nil {
if cur, ok := d.store.Get(it.ID); ok && cur.Status == "answered" {
m2mAuto.done(key)
continue
}
m2mAuto.retry(key, now)
m2mAuto.alert(rc, key, now, "Рабочее время: отказ по запросу M2M не отправлен",
fmt.Sprintf("%s получен вне рабочего времени (%s), отказ %s не отправлен: %v. bj повторит через %d мин.", who, wh.Text(), workHoursRejectCode, err, int(m2mAutoRetry.Minutes())))
continue
}
if cur, ok := d.store.Get(it.ID); ok {
d.store.MarkAuto(it.ID, "Получен вне рабочего времени организации ("+wh.Text()+", получен "+it.ReceivedAt.In(time.Local).Format("02.01.2006 15:04")+") — отклонён кодом "+workHoursRejectCode+" автоматически. "+cur.AnswerNote)
}
m2mAuto.done(key)
log.Printf("work-hours: %s получен %s вне рабочего времени (%s) — отказ %s отправлен", who, it.ReceivedAt.Format("02.01 15:04"), wh.Text(), workHoursRejectCode)
}
}