diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 31d39da..7cd20c3 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -60,6 +60,8 @@ var templateFuncs = template.FuncMap{ "backupStatus": backupStatus, "backupWhen": backupWhen, "smbWin": smbWin, + "whForm": func(w WorkHoursSettings, ret string) map[string]any { return map[string]any{"W": w, "Ret": ret} }, + "weekdayNames": func() []string { return weekdayShort }, "depoAccPrefix": depoAccPrefix, "certDaysLeft": certDaysLeft, "currentContour": func() string { return contourOf(currentNSDProfile()) }, diff --git a/internal/lkgateway/incoming.go b/internal/lkgateway/incoming.go index 68cddad..00a1c5e 100644 --- a/internal/lkgateway/incoming.go +++ b/internal/lkgateway/incoming.go @@ -18,7 +18,7 @@ import ( // ответить M2MTransferDecision — по каждой бумаге подтвердить приём или // отказать. Здесь — хранилище входящих и их состояние ответа. -const incomingStateFile = "/var/lib/bj/.bj/incoming-transfers.json" +var incomingStateFile = "/var/lib/bj/.bj/incoming-transfers.json" // var — тесты подменяют путь // IncomingSecurity — бумага во входящем переводе (что нам предлагают принять). type IncomingSecurity struct { diff --git a/internal/lkgateway/m2mauto.go b/internal/lkgateway/m2mauto.go index 186e2b2..76a67a5 100644 --- a/internal/lkgateway/m2mauto.go +++ b/internal/lkgateway/m2mauto.go @@ -111,6 +111,7 @@ func (s *Server) m2mAutoLoop(ctx context.Context) { } 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 diff --git a/internal/lkgateway/readiness.go b/internal/lkgateway/readiness.go index 8b2a58f..729dbf0 100644 --- a/internal/lkgateway/readiness.go +++ b/internal/lkgateway/readiness.go @@ -265,6 +265,22 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck { } return c }) + run(func() ReadyCheck { + c := ReadyCheck{Order: 11, Title: "Рабочее время", Link: "/admin/wizard?step=12"} + w := s.WorkHours + switch { + case w.SavedAt.IsZero(): + c.State, c.Detail = "warn", "рабочее время организации не задано — запросы на перевод M2M принимаются круглосуточно" + case !w.Enabled: + c.State, c.Detail = "ok", w.Text()+"; ограничение выключено — запросы M2M принимаются круглосуточно" + default: + c.State, c.Detail = "ok", w.Text()+" ("+time.Local.String()+"): запрос на перевод M2M, пришедший вне этого времени, отклоняется кодом "+workHoursRejectCode+" «"+m2mCodeText(workHoursRejectCode)+"»" + if !w.Open(time.Now()) { + c.Detail += "; сейчас нерабочее время" + } + } + return c + }) run(func() ReadyCheck { c := ReadyCheck{Order: 11, Title: "Время сервера", Link: "/admin/setup#server"} cctx, cancel := context.WithTimeout(ctx, 3*time.Second) diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index fb07495..458b5fd 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -42,6 +42,7 @@ type Settings struct { Backup BackupSettings `json:"backup,omitempty"` HTTPS HTTPSSettings `json:"https,omitempty"` Time TimeSettings `json:"time,omitempty"` + WorkHours WorkHoursSettings `json:"work_hours,omitempty"` // рабочее время: запросы M2M вне него — отказ M2M06 LastTest *TestRunResult `json:"last_test,omitempty"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 73723b1..2daf669 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -96,6 +96,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn) mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate) mux.HandleFunc("/admin/setup/lk-pull", h.lkPull) + mux.HandleFunc("/admin/setup/workhours", h.saveWorkHours) mux.HandleFunc("/admin/setup/alerts-smtp", h.saveAlertsSMTP) mux.HandleFunc("/admin/setup/alerts-smtp-test", h.testAlertsSMTP) mux.HandleFunc("/admin/setup/alerts-zabbix", h.saveAlertsZabbix) @@ -274,6 +275,7 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { d.Done.Quik = !d.DepoLicensed || doneQuik(s) d.Modules = wizModuleViews(h.rc, s) d.Done.Server, _ = timeStatus(r.Context()) + d.Done.Server = d.Done.Server && !s.WorkHours.SavedAt.IsZero() // и рабочее время организации задано d.Done.Modules = true for _, m := range d.Modules { if !m.Ready { diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index df2cf40..0711554 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -958,6 +958,12 @@
+
+

Рабочее время организации

+

Когда депозитарий принимает запросы на перевод M2M.

+ {{template "workHoursForm" (whForm .Settings.WorkHours "")}} +
+

Время сервера

Точное время нужно для подписей и обмена с НРД. В закрытом контуре внешние серверы времени недоступны — укажите сервер времени вашей сети (обычно контроллер домена или маршрутизатор).

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 @@ +
+
Рабочее время{{if and .Settings.WorkHours.Enabled (not .Settings.WorkHours.SavedAt.IsZero)}}✓ {{.Settings.WorkHours.Text}}{{else if .Settings.WorkHours.SavedAt.IsZero}}⚠ не задано{{else}}без ограничения{{end}}
+ {{template "workHoursForm" (whForm .Settings.WorkHours "/admin/wizard?step=12")}} +
+
Адрес 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}} +
+ {{if .Ret}}{{end}} + +
{{range $d := intList 1 2 3 4 5 6 7}}{{end}}
+
с до по часовому поясу сервера
+ +
{{if not $w.SavedAt.IsZero}} {{if $w.Enabled}}сейчас: {{$w.Text}}, вне его — отказ M2M06{{else}}сохранено без ограничения — запросы принимаются круглосуточно{{end}}{{end}}
+
+{{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) + } + } +}