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) } }