Files
Bridge-and-Join-s/internal/lkgateway/workhours.go
T
zuevav bf7c7b53bd Рабочее время организации: запросы 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
2026-10-06 23:35:50 +03:00

211 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}