Projeto bot pontos
This commit is contained in:
165
backend/internal/app/settings.go
Normal file
165
backend/internal/app/settings.go
Normal file
@@ -0,0 +1,165 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user