Точное время нужно для подписей и обмена с НРД. В закрытом контуре внешние серверы времени недоступны — укажите сервер времени вашей сети (обычно контроллер домена или маршрутизатор).
diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html
index 0341daa..64141a5 100644
--- a/internal/lkgateway/web/templates/admin_wizard.html
+++ b/internal/lkgateway/web/templates/admin_wizard.html
@@ -624,6 +624,11 @@
+
Адрес bj
https://{{serverIP}}:8443/
diff --git a/internal/lkgateway/web/templates/part_workhours.html b/internal/lkgateway/web/templates/part_workhours.html
new file mode 100644
index 0000000..c3aa139
--- /dev/null
+++ b/internal/lkgateway/web/templates/part_workhours.html
@@ -0,0 +1,20 @@
+{{/* Рабочее время организации (workhours.go): шаг «Время и сеть» мастера и Настройка → Сервер. Данные — whForm. */}}
+{{define "workHoursForm"}}{{$w := .W}}
+
+{{end}}
diff --git a/internal/lkgateway/workhours.go b/internal/lkgateway/workhours.go
new file mode 100644
index 0000000..662c76a
--- /dev/null
+++ b/internal/lkgateway/workhours.go
@@ -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)
+ }
+}
diff --git a/internal/lkgateway/workhours_test.go b/internal/lkgateway/workhours_test.go
new file mode 100644
index 0000000..9071e2e
--- /dev/null
+++ b/internal/lkgateway/workhours_test.go
@@ -0,0 +1,112 @@
+package lkgateway
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
+)
+
+func TestWorkHoursOpen(t *testing.T) {
+ at := func(s string) time.Time { // 06.10.2026 — вторник
+ tm, err := time.ParseInLocation("02.01.2006 15:04", s, time.Local)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return tm
+ }
+ w := WorkHoursSettings{Enabled: true, Days: []int{1, 2, 3, 4, 5}, From: "09:00", To: "18:00"}
+ cases := map[string]bool{
+ "06.10.2026 08:59": false, "06.10.2026 09:00": true, "06.10.2026 17:59": true, "06.10.2026 18:00": false,
+ "10.10.2026 12:00": false, // суббота
+ "12.10.2026 10:00": true, // понедельник
+ }
+ for s, want := range cases {
+ if got := w.Open(at(s)); got != want {
+ t.Errorf("%s: %v, ожидалось %v", s, got, want)
+ }
+ }
+ night := WorkHoursSettings{Enabled: true, Days: []int{5}, From: "20:00", To: "08:00"} // пятница, ночная смена
+ if !night.Open(at("09.10.2026 21:00")) || !night.Open(at("10.10.2026 07:00")) || night.Open(at("10.10.2026 21:00")) {
+ t.Error("ночная смена")
+ }
+ if !(WorkHoursSettings{}).Open(at("10.10.2026 03:00")) {
+ t.Error("правило выключено — всегда открыто")
+ }
+ if got := w.Text(); got != "пн–пт 09:00–18:00" {
+ t.Errorf("текст: %q", got)
+ }
+ if got := (WorkHoursSettings{Days: []int{1, 3, 4}}).Text(); got != "пн, ср, чт 09:00–18:00" {
+ t.Errorf("текст: %q", got)
+ }
+}
+
+// Запрос, полученный вне рабочего времени, получает отказ M2M06 по всем бумагам; полученный в рабочее время —
+// ждёт решения как обычно; полученный до включения правила — не трогается.
+func TestWorkHoursRejectsOffHours(t *testing.T) {
+ old := incomingStateFile
+ incomingStateFile = filepath.Join(t.TempDir(), "incoming.json")
+ defer func() { incomingStateFile = old }()
+ var sent []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ sent = append(sent, r.Method+" "+r.URL.Path+" "+string(b))
+ _, _ = io.WriteString(w, `{"id":42}`)
+ }))
+ defer srv.Close()
+ rc, _ := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
+ _ = rc.UpdateNSD(NSDSettings{DeponentCode: "MC0413600000", OrgINN: "7703807489"})
+ enabledAt := time.Date(2026, 10, 1, 0, 0, 0, 0, time.Local)
+ _ = rc.UpdateWorkHours(WorkHoursSettings{Enabled: true, Days: []int{1, 2, 3, 4, 5}, From: "09:00", To: "18:00", SavedAt: enabledAt})
+ store := &IncomingStore{items: map[string]*IncomingTransfer{}}
+ mk := func(id string, at time.Time) {
+ store.items[id] = &IncomingTransfer{ID: id, GUID: "6f1c2a7e-0d4b-4c8a-9e2f-3b5d7a9c1e0" + id, ReceivedAt: at, Status: "pending",
+ SenderCode: "MC0012500000", ReceiverCode: "MC0413600000", InvestorLast: "Бубликов",
+ Securities: []IncomingSecurity{{ReferenceID: "M2MREF0000000001", SecurityCode: "RU0009029540", Quantity: "1"}, {ReferenceID: "M2MREF0000000002", SecurityCode: "RU000A0JUR61", Quantity: "2"}}}
+ }
+ mk("1", time.Date(2026, 10, 10, 20, 0, 0, 0, time.Local)) // суббота вечер
+ mk("2", time.Date(2026, 10, 6, 11, 0, 0, 0, time.Local)) // вторник, рабочее время
+ mk("3", time.Date(2026, 9, 27, 20, 0, 0, 0, time.Local)) // до включения правила
+ saved := globalIncomingDeps
+ globalIncomingDeps = &incomingDeps{store: store, igwClient: igw.NewClient(srv.URL), igwChan: "TEST3MC0413600000", rc: rc, ourCode: "MC0413600000"}
+ defer func() { globalIncomingDeps = saved }()
+ m2mAuto.done("workhours:1")
+
+ workHoursTick(context.Background(), rc, time.Date(2026, 10, 10, 20, 1, 0, 0, time.Local))
+
+ posts := 0
+ for _, x := range sent { // POST пакета; GET — клиент ИШ сам спрашивает статус доставки
+ if strings.HasPrefix(x, "POST /api/package/TEST3MC0413600000/file") {
+ posts++
+ }
+ }
+ if posts != 1 {
+ for _, n := range rc.Snapshot().News.Items {
+ t.Log(n.Title, " — ", n.Body)
+ }
+ t.Fatalf("в шлюз должен уйти один пакет решения, ушло %d", posts)
+ }
+ it, _ := store.Get("1")
+ if it.Status != "answered" || !strings.Contains(it.AnswerNote, "вне рабочего времени") {
+ t.Fatalf("запрос вне рабочего времени: %s / %s", it.Status, it.AnswerNote)
+ }
+ for _, s := range it.Securities {
+ if s.Decision != "rejected" || s.RejectCode != workHoursRejectCode {
+ t.Errorf("бумага %s: %s %s, ожидался отказ %s", s.ReferenceID, s.Decision, s.RejectCode, workHoursRejectCode)
+ }
+ }
+ if !strings.Contains(it.DecisionXML, workHoursRejectCode) {
+ t.Error("в решении нет кода M2M06")
+ }
+ for _, id := range []string{"2", "3"} {
+ if it, _ := store.Get(id); it.Status != "pending" {
+ t.Errorf("запрос %s не должен был получить ответ: %s", id, it.Status)
+ }
+ }
+}