Files
bot-pontos/backend/internal/app/settings.go
2026-10-23 22:31:02 -03:00

166 lines
5.2 KiB
Go

package app
import (
"context"
"time"
"botpontos/internal/domain"
"botpontos/internal/rules"
)
const (
keyWindowStart = "query_window_start"
keyWindowEnd = "query_window_end"
keyCheckDeadline = "day_check_deadline"
keyRecheckUntil = "no_points_recheck_until"
)
func windowToSettings(w domain.QueryWindow) map[string]string {
return map[string]string{
keyWindowStart: w.Start.String(),
keyWindowEnd: w.End.String(),
keyCheckDeadline: w.DayCheckDeadline.String(),
keyRecheckUntil: w.NoPointsRecheckUntil.String(),
}
}
// SeedSettings grava os padrões da janela na primeira subida (9.13).
func (s *Service) SeedSettings(ctx context.Context) error {
return s.store.SeedSettings(ctx, windowToSettings(s.cfg.DefaultWindow))
}
// GlobalWindow lê a janela global do banco; valores ausentes ou inválidos usam o padrão.
func (s *Service) GlobalWindow(ctx context.Context) (domain.QueryWindow, error) {
values, err := s.store.GetSettings(ctx)
if err != nil {
return s.cfg.DefaultWindow, err
}
w := s.cfg.DefaultWindow
read := func(key string, target *domain.ClockTime) {
if v, ok := values[key]; ok {
if c, err := domain.ParseClock(v); err == nil {
*target = c
}
}
}
read(keyWindowStart, &w.Start)
read(keyWindowEnd, &w.End)
read(keyCheckDeadline, &w.DayCheckDeadline)
read(keyRecheckUntil, &w.NoPointsRecheckUntil)
return w, nil
}
// UpdateGlobalWindow valida e grava a janela global; vale a partir do próximo tick.
func (s *Service) UpdateGlobalWindow(ctx context.Context, actor Actor, w domain.QueryWindow) error {
if errs := rules.ValidateWindow(w, s.cfg.Jobs.ReconciliationInterval, ""); len(errs) > 0 {
return errs
}
previous, err := s.GlobalWindow(ctx)
if err != nil {
return err
}
if err := s.store.SaveSettings(ctx, windowToSettings(w), actor.AccountID); err != nil {
return err
}
if err := s.store.ResetNextChecks(ctx, s.TodayDate()); err != nil {
s.log.Warn("falha ao recalcular as consultas do dia", "error", err)
}
s.audit(ctx, actor, nil, domain.EventSettingsChanged, true, map[string]any{
"before": windowToSettings(previous),
"after": windowToSettings(w),
})
return nil
}
// WindowPreview é o efeito estimado de uma nova janela global (9.13).
type WindowPreview struct {
Errors domain.ValidationErrors `json:"errors"`
MaxQueriesPerUser int `json:"max_queries_per_user"`
TypicalQueriesPerDay int `json:"typical_queries_per_user"`
DayOffQueries int `json:"day_off_queries_per_user"`
AffectedUsers int `json:"affected_users"`
AffectedDays int `json:"affected_days"`
CustomWindowUsers int `json:"custom_window_users"`
}
func (s *Service) PreviewWindow(ctx context.Context, w domain.QueryWindow) (WindowPreview, error) {
recon := s.cfg.Jobs.ReconciliationInterval
p := WindowPreview{Errors: rules.ValidateWindow(w, recon, "")}
if len(p.Errors) > 0 {
return p, nil
}
p.MaxQueriesPerUser = int(time.Duration(w.End-w.Start)*time.Minute/recon) + 1
p.TypicalQueriesPerDay = s.simulateDay(w, []string{"08:00", "12:00", "13:00", "17:00"})
p.DayOffQueries = s.simulateDay(w, nil)
since := domain.LocalDay(s.now().AddDate(0, 0, -30), s.location)
samples, err := s.store.Entrada2Samples(ctx, since)
if err != nil {
return p, err
}
users := map[int64]bool{}
for _, x := range samples {
if x.CustomWindow {
continue
}
loc := domain.LoadLocation(x.Timezone)
if x.ScheduledAt.Before(w.Start.On(x.Day, loc)) {
users[x.UserID] = true
p.AffectedDays++
}
}
p.AffectedUsers = len(users)
if p.CustomWindowUsers, err = s.store.CountCustomWindows(ctx); err != nil {
return p, err
}
return p, nil
}
// simulateDay conta quantas consultas a frequência adaptativa faria num dia
// em que os pontos são batidos nos horários informados (jornada padrão).
func (s *Service) simulateDay(w domain.QueryWindow, clocks []string) int {
const day = "2026-01-07" // uma quarta-feira qualquer; só os horários importam
loc := s.location
schedule := domain.DefaultSchedule(0)
recon := s.cfg.Jobs.ReconciliationInterval
var punches []domain.Point
for _, c := range clocks {
punches = append(punches, domain.Point{OccurredAt: domain.MustParseClock(c).On(day, loc)})
}
queries := 0
next := w.Start.On(day, loc)
end := w.End.On(day, loc)
for tick := next; !tick.After(end); tick = tick.Add(recon) {
if tick.Before(next) {
continue
}
queries++
var visible []domain.Point
for _, p := range punches {
if !p.OccurredAt.After(tick) {
visible = append(visible, p)
}
}
wd := rules.CurrentWorkday(visible, day, loc)
var saida2At *time.Time
if wd.Count() == 3 {
if t, err := rules.Saida2Time(wd, schedule.DailyWorkMinutes); err == nil {
r := rules.ReminderAt(t, schedule.Saida2LeadMinutes)
saida2At = &r
}
}
plan := rules.PlanNextCheck(rules.PlanInput{
Tick: tick, Day: day, Location: loc, Window: w, Workday: wd, Saida2At: saida2At,
Reconciliation: recon, Slow: s.cfg.Jobs.SlowCheckInterval,
ExitConfirmFrom: s.cfg.Jobs.ExitConfirmFrom, ExitConfirmTo: s.cfg.Jobs.ExitConfirmTo,
})
if plan.NextCheckAt == nil {
break
}
next = *plan.NextCheckAt
}
return queries
}