166 lines
5.2 KiB
Go
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
|
|
}
|