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 }