Projeto bot pontos

This commit is contained in:
2026-10-23 22:31:02 -03:00
parent 29614855ff
commit b35e1a7d69
116 changed files with 14523 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
package rules
import (
"fmt"
"time"
)
// FormatMinutes formata uma duração de jornada: 480 → "8h00", 390 → "6h30".
func FormatMinutes(minutes int) string {
return fmt.Sprintf("%dh%02d", minutes/60, minutes%60)
}
// FormatMinutesShort formata intervalos curtos: 15 → "15 min", 60 → "1h00".
func FormatMinutesShort(minutes int) string {
if minutes < 60 {
return fmt.Sprintf("%d min", minutes)
}
return FormatMinutes(minutes)
}
// FormatClock formata o horário no fuso informado: "13:02".
func FormatClock(t time.Time, loc *time.Location) string {
return t.In(loc).Format("15:04")
}

View File

@@ -0,0 +1,134 @@
package rules
import (
"time"
"botpontos/internal/domain"
)
// Tick alinha um instante ao relógio no intervalo informado (ex.: :00, :15,
// :30, :45 para 15 min). Fusos com deslocamento em horas inteiras ou em
// múltiplos de 15 min mantêm o alinhamento local.
func Tick(t time.Time, interval time.Duration) time.Time {
return t.Truncate(interval)
}
// NextTick é o próximo instante alinhado depois de t.
func NextTick(t time.Time, interval time.Duration) time.Time {
return Tick(t, interval).Add(interval)
}
// InWindow indica se t está dentro da janela de consulta do dia (inclusive nas pontas).
func InWindow(t time.Time, w domain.QueryWindow, day string, loc *time.Location) bool {
start := w.Start.On(day, loc)
end := w.End.On(day, loc)
return !t.Before(start) && !t.After(end)
}
// PlanInput reúne o que a reconciliação sabe sobre um usuário depois de uma
// consulta bem-sucedida à PontoMais.
type PlanInput struct {
Tick time.Time // instante alinhado do ciclo
Day string // AAAA-MM-DD no fuso do usuário
Location *time.Location
Window domain.QueryWindow
Workday domain.Workday
Resumed bool // o dia foi retomado manualmente (9.12)
Saida2At *time.Time // scheduled_at do lembrete de saida2, se agendado
Reconciliation time.Duration
Slow time.Duration // consultas lentas (60 min)
ExitConfirmFrom time.Duration // início da janela de confirmação antes do lembrete (20 min)
ExitConfirmTo time.Duration // fim da janela de confirmação antes do lembrete (5 min)
}
// Plan é a nova situação do dia e a próxima consulta (nil = nenhuma hoje).
type Plan struct {
Status domain.DayStatus
NextCheckAt *time.Time
}
// PlanNextCheck implementa a frequência adaptativa da seção 9.12:
// - 0 pontos antes da confirmação: próximo tick;
// - 0 pontos depois da confirmação: de hora em hora até o fim das reverificações;
// - 1 ou 2 pontos: próximo tick;
// - 3 pontos: de hora em hora + uma consulta de confirmação antes do lembrete de saida2;
// - 4 ou mais pontos: dia encerrado.
func PlanNextCheck(in PlanInput) Plan {
end := in.Window.End.On(in.Day, in.Location)
within := func(t time.Time) *time.Time {
if t.After(end) {
return nil
}
return &t
}
quick := in.Tick.Add(in.Reconciliation)
slow := in.Tick.Add(in.Slow)
w := in.Workday
switch {
case w.Overtime || w.Count() >= 4:
return Plan{Status: domain.DayFinished}
case w.Invalid:
// Dado inconsistente: continua consultando, mas devagar, para não poluir a auditoria.
status := domain.DayWorking
if w.Count() == 0 {
status = domain.DayAwaiting
}
return Plan{Status: status, NextCheckAt: within(slow)}
case w.Count() == 3:
if in.Saida2At == nil {
return Plan{Status: domain.DayWorking, NextCheckAt: within(slow)}
}
confirmStart := in.Saida2At.Add(-in.ExitConfirmFrom)
if !in.Tick.Before(confirmStart) {
// Esta já foi a consulta de confirmação (ou passou dela): nada mais a fazer hoje.
return Plan{Status: domain.DayWorking}
}
next := slow
if confirm := ConfirmTick(*in.Saida2At, in.Reconciliation, in.ExitConfirmTo); confirm.Before(next) {
next = confirm
}
return Plan{Status: domain.DayWorking, NextCheckAt: within(next)}
case w.Count() >= 1:
return Plan{Status: domain.DayWorking, NextCheckAt: within(quick)}
}
// 0 pontos.
if in.Resumed {
// Dia retomado manualmente: consulta no ritmo normal até o fim da janela.
return Plan{Status: domain.DayWorking, NextCheckAt: within(quick)}
}
deadline := in.Window.DayCheckDeadline.On(in.Day, in.Location)
if in.Tick.Before(deadline) {
return Plan{Status: domain.DayAwaiting, NextCheckAt: within(quick)}
}
recheckUntil := in.Window.NoPointsRecheckUntil.On(in.Day, in.Location)
if slow.After(recheckUntil) {
return Plan{Status: domain.DayNotWorking}
}
return Plan{Status: domain.DayNoPoints, NextCheckAt: within(slow)}
}
// ConfirmTick é o último tick antes de (lembrete − to). Com to = 5 min e
// tick de 15 min, ele cai sempre entre 20 e 5 minutos antes do lembrete.
func ConfirmTick(reminderAt time.Time, reconciliation, to time.Duration) time.Time {
return Tick(reminderAt.Add(-to).Add(-time.Nanosecond), reconciliation)
}
// ResumeNextCheck é a próxima consulta depois de um dia ser retomado: o próximo
// tick dentro da janela, ou o início da janela se ela ainda não começou.
func ResumeNextCheck(now time.Time, w domain.QueryWindow, day string, loc *time.Location, reconciliation time.Duration) *time.Time {
start := w.Start.On(day, loc)
end := w.End.On(day, loc)
next := NextTick(now, reconciliation)
if next.Before(start) {
next = start
}
if next.After(end) {
return nil
}
return &next
}

View File

@@ -0,0 +1,229 @@
package rules
import (
"testing"
"time"
"botpontos/internal/domain"
)
var saoPaulo = domain.LoadLocation("America/Sao_Paulo")
const testDay = "2026-10-14" // quarta-feira
func at(clock string) time.Time {
return domain.MustParseClock(clock).On(testDay, saoPaulo)
}
func workday(clocks ...string) domain.Workday {
points := make([]domain.Point, 0, len(clocks))
for _, c := range clocks {
points = append(points, domain.Point{OccurredAt: at(c)})
}
return CurrentWorkday(points, testDay, saoPaulo)
}
func TestCurrentWorkdayFiltersAndSorts(t *testing.T) {
yesterday := domain.MustParseClock("17:00").On("2026-10-13", saoPaulo)
points := []domain.Point{{OccurredAt: at("12:00")}, {OccurredAt: yesterday}, {OccurredAt: at("08:00")}}
w := CurrentWorkday(points, testDay, saoPaulo)
if w.Count() != 2 || !w.Entry1().Equal(at("08:00")) || !w.Exit1().Equal(at("12:00")) {
t.Fatalf("pontos inesperados: %+v", w.Points)
}
if w.Invalid || w.Overtime {
t.Fatalf("dia deveria ser válido: %+v", w)
}
}
func TestCurrentWorkdayInvalidAndOvertime(t *testing.T) {
if w := workday("08:00", "08:00"); !w.Invalid {
t.Error("pontos repetidos deveriam invalidar o dia")
}
if w := workday("08:00", "12:00", "13:00", "17:00", "18:00"); !w.Overtime {
t.Error("mais de quatro pontos deveria ser hora extra")
}
}
func TestEntrada2AndSaida2(t *testing.T) {
w := workday("08:00", "12:00", "13:00")
entrada2, err := Entrada2Time(w, 60)
if err != nil || !entrada2.Equal(at("13:00")) {
t.Fatalf("entrada2 = %v, %v", entrada2, err)
}
saida2, err := Saida2Time(w, 480)
if err != nil || !saida2.Equal(at("17:00")) {
t.Fatalf("saida2 = %v, %v", saida2, err)
}
if reminder := ReminderAt(saida2, 5); !reminder.Equal(at("16:55")) {
t.Fatalf("lembrete = %v", reminder)
}
if _, err := Saida2Time(workday("08:00", "17:00", "18:00"), 480); err != ErrNoRemainingWork {
t.Fatalf("esperado ErrNoRemainingWork, veio %v", err)
}
if _, err := Entrada2Time(workday("08:00"), 60); err != ErrNotEnoughPoints {
t.Fatalf("esperado ErrNotEnoughPoints, veio %v", err)
}
}
func TestFormat(t *testing.T) {
if got := FormatMinutes(480); got != "8h00" {
t.Errorf("FormatMinutes(480) = %s", got)
}
if got := FormatMinutes(390); got != "6h30" {
t.Errorf("FormatMinutes(390) = %s", got)
}
if got := FormatMinutesShort(15); got != "15 min" {
t.Errorf("FormatMinutesShort(15) = %s", got)
}
}
func TestValidateSchedule(t *testing.T) {
global := domain.DefaultQueryWindow()
ok := domain.DefaultSchedule(1)
if errs := ValidateSchedule(ok, global, 15*time.Minute); len(errs) != 0 {
t.Fatalf("jornada padrão deveria ser válida: %v", errs)
}
short := ok
short.LunchMinutes = 15 // 15 − 5 = 10 < 15
if errs := ValidateSchedule(short, global, 15*time.Minute); len(errs) == 0 {
t.Fatal("almoço de 15 min com antecedência de 5 deveria ser recusado")
}
twenty := ok
twenty.LunchMinutes = 20 // 20 − 5 = 15
if errs := ValidateSchedule(twenty, global, 15*time.Minute); len(errs) != 0 {
t.Fatalf("almoço de 20 min deveria passar: %v", errs)
}
}
func TestValidateWindow(t *testing.T) {
if errs := ValidateWindow(domain.DefaultQueryWindow(), 15*time.Minute, ""); len(errs) != 0 {
t.Fatalf("janela padrão deveria ser válida: %v", errs)
}
misaligned := domain.DefaultQueryWindow()
misaligned.Start = domain.MustParseClock("11:20")
if errs := ValidateWindow(misaligned, 15*time.Minute, ""); len(errs) == 0 {
t.Error("horário fora do alinhamento deveria ser recusado")
}
outOfOrder := domain.DefaultQueryWindow()
outOfOrder.DayCheckDeadline = domain.MustParseClock("16:00")
if errs := ValidateWindow(outOfOrder, 15*time.Minute, ""); len(errs) == 0 {
t.Error("confirmação depois do fim das reverificações deveria ser recusada")
}
tooShort := domain.QueryWindow{
Start: domain.MustParseClock("11:30"),
DayCheckDeadline: domain.MustParseClock("11:45"),
NoPointsRecheckUntil: domain.MustParseClock("13:00"),
End: domain.MustParseClock("15:00"),
}
if errs := ValidateWindow(tooShort, 15*time.Minute, ""); len(errs) == 0 {
t.Error("janela com menos de 4 horas deveria ser recusada")
}
}
func plan(tick string, w domain.Workday, saida2At *time.Time) Plan {
return PlanNextCheck(PlanInput{
Tick: at(tick),
Day: testDay,
Location: saoPaulo,
Window: domain.DefaultQueryWindow(),
Workday: w,
Saida2At: saida2At,
Reconciliation: 15 * time.Minute,
Slow: 60 * time.Minute,
ExitConfirmFrom: 20 * time.Minute,
ExitConfirmTo: 5 * time.Minute,
})
}
func assertPlan(t *testing.T, got Plan, status domain.DayStatus, next string) {
t.Helper()
if got.Status != status {
t.Errorf("status = %s, esperado %s", got.Status, status)
}
switch {
case next == "" && got.NextCheckAt != nil:
t.Errorf("próxima consulta = %s, esperado nenhuma", got.NextCheckAt.In(saoPaulo).Format("15:04"))
case next != "" && got.NextCheckAt == nil:
t.Errorf("próxima consulta = nenhuma, esperado %s", next)
case next != "" && !got.NextCheckAt.Equal(at(next)):
t.Errorf("próxima consulta = %s, esperado %s", got.NextCheckAt.In(saoPaulo).Format("15:04"), next)
}
}
func TestPlanNoPoints(t *testing.T) {
empty := workday()
assertPlan(t, plan("11:30", empty, nil), domain.DayAwaiting, "11:45")
assertPlan(t, plan("11:45", empty, nil), domain.DayNoPoints, "12:45")
assertPlan(t, plan("13:45", empty, nil), domain.DayNoPoints, "14:45")
assertPlan(t, plan("14:45", empty, nil), domain.DayNotWorking, "")
}
func TestPlanResumedWithoutPoints(t *testing.T) {
in := PlanInput{
Tick: at("12:30"), Day: testDay, Location: saoPaulo, Window: domain.DefaultQueryWindow(),
Workday: workday(), Resumed: true, Reconciliation: 15 * time.Minute, Slow: time.Hour,
ExitConfirmFrom: 20 * time.Minute, ExitConfirmTo: 5 * time.Minute,
}
assertPlan(t, PlanNextCheck(in), domain.DayWorking, "12:45")
}
func TestPlanBeforeAndDuringLunch(t *testing.T) {
assertPlan(t, plan("11:30", workday("08:00"), nil), domain.DayWorking, "11:45")
assertPlan(t, plan("12:15", workday("08:00", "12:00"), nil), domain.DayWorking, "12:30")
}
func TestPlanAfternoonWithConfirmation(t *testing.T) {
afternoon := workday("08:00", "12:00", "13:00")
reminder := at("16:55")
assertPlan(t, plan("13:00", afternoon, &reminder), domain.DayWorking, "14:00")
assertPlan(t, plan("16:00", afternoon, &reminder), domain.DayWorking, "16:45")
assertPlan(t, plan("16:45", afternoon, &reminder), domain.DayWorking, "")
}
func TestPlanFinishedAndWindowEnd(t *testing.T) {
assertPlan(t, plan("17:00", workday("08:00", "12:00", "13:00", "17:00"), nil), domain.DayFinished, "")
// 3 pontos às 18:30 sem lembrete agendado: a próxima consulta lenta (19:30) passa do fim da janela.
assertPlan(t, plan("18:30", workday("10:00", "14:00", "15:00"), nil), domain.DayWorking, "")
}
func TestConfirmTick(t *testing.T) {
cases := map[string]string{"16:55": "16:45", "17:00": "16:45", "16:50": "16:30"}
for reminder, expected := range cases {
got := ConfirmTick(at(reminder), 15*time.Minute, 5*time.Minute)
if !got.Equal(at(expected)) {
t.Errorf("ConfirmTick(%s) = %s, esperado %s", reminder, got.In(saoPaulo).Format("15:04"), expected)
}
}
}
func TestResumeNextCheck(t *testing.T) {
w := domain.DefaultQueryWindow()
if next := ResumeNextCheck(at("09:10"), w, testDay, saoPaulo, 15*time.Minute); next == nil || !next.Equal(at("11:30")) {
t.Errorf("antes da janela deveria começar às 11:30, veio %v", next)
}
if next := ResumeNextCheck(at("12:31"), w, testDay, saoPaulo, 15*time.Minute); next == nil || !next.Equal(at("12:45")) {
t.Errorf("dentro da janela deveria ser o próximo tick, veio %v", next)
}
if next := ResumeNextCheck(at("19:10"), w, testDay, saoPaulo, 15*time.Minute); next != nil {
t.Errorf("depois da janela não deveria haver consulta, veio %v", next)
}
}
func TestParseClock(t *testing.T) {
for _, invalid := range []string{"7:30", "24:00", "12:60", "ab:cd", "12-30", ""} {
if _, err := domain.ParseClock(invalid); err == nil {
t.Errorf("ParseClock(%q) deveria falhar", invalid)
}
}
if c, err := domain.ParseClock("11:45"); err != nil || c.String() != "11:45" {
t.Errorf("ParseClock(11:45) = %v, %v", c, err)
}
}

View File

@@ -0,0 +1,98 @@
package rules
import (
"time"
"botpontos/internal/domain"
)
// EffectiveWindow devolve a janela do usuário (exceção) ou a global (9.14).
func EffectiveWindow(global domain.QueryWindow, schedule domain.WorkSchedule) (window domain.QueryWindow, custom bool) {
if schedule.Window != nil {
return *schedule.Window, true
}
return global, false
}
// ValidateWindow aplica as regras da seção 9.13. prefix identifica o campo
// na resposta (ex.: "window." no formulário do usuário).
func ValidateWindow(w domain.QueryWindow, reconciliation time.Duration, prefix string) domain.ValidationErrors {
var errs domain.ValidationErrors
step := int(reconciliation / time.Minute)
fields := []struct {
name string
value domain.ClockTime
}{
{"query_window_start", w.Start},
{"day_check_deadline", w.DayCheckDeadline},
{"no_points_recheck_until", w.NoPointsRecheckUntil},
{"query_window_end", w.End},
}
if step > 0 {
for _, f := range fields {
if int(f.value)%step != 0 {
errs.Add(prefix+f.name, "precisa ser múltiplo de "+formatStep(step)+" (ex.: :00, :15, :30, :45)")
}
}
}
if !(w.Start < w.DayCheckDeadline && w.DayCheckDeadline < w.NoPointsRecheckUntil && w.NoPointsRecheckUntil < w.End) {
errs.Add(prefix+"query_window", "a ordem precisa ser início < confirmação < fim das reverificações < fim")
}
if step > 0 && int(w.DayCheckDeadline-w.Start) < step {
errs.Add(prefix+"day_check_deadline", "precisa ser pelo menos "+formatStep(step)+" depois do início, para haver duas verificações")
}
if int(w.End-w.Start) < 4*60 {
errs.Add(prefix+"query_window_end", "a janela precisa ter pelo menos 4 horas")
}
return errs
}
// ValidateSchedule aplica as regras da seção 9.8 e, se houver exceção de
// janela, as da 9.13. global é a janela usada quando o usuário não tem exceção.
func ValidateSchedule(s domain.WorkSchedule, global domain.QueryWindow, reconciliation time.Duration) domain.ValidationErrors {
var errs domain.ValidationErrors
if s.LunchMinutes < 15 || s.LunchMinutes > 180 {
errs.Add("lunch_minutes", "precisa estar entre 15 e 180")
}
if s.DailyWorkMinutes < 60 || s.DailyWorkMinutes > 720 {
errs.Add("daily_work_minutes", "precisa estar entre 60 e 720")
}
if s.Entrada2LeadMinutes < 0 || s.Entrada2LeadMinutes > 60 {
errs.Add("entrada2_lead_minutes", "precisa estar entre 0 e 60")
}
if s.Saida2LeadMinutes < 0 || s.Saida2LeadMinutes > 60 {
errs.Add("saida2_lead_minutes", "precisa estar entre 0 e 60")
}
step := int(reconciliation / time.Minute)
if step > 0 && s.LunchMinutes-s.Entrada2LeadMinutes < step {
errs.Add("lunch_minutes", "almoço − antecedência de entrada2 precisa ser pelo menos "+formatStep(step)+
", para a saída do almoço ser detectada antes do lembrete")
}
if s.Window != nil {
errs = append(errs, ValidateWindow(*s.Window, reconciliation, "window.")...)
}
return errs
}
// ScheduleFitsInterval indica se a jornada continua válida para o intervalo de
// reconciliação atual (verificado na inicialização do worker, 9.8).
func ScheduleFitsInterval(s domain.WorkSchedule, reconciliation time.Duration) bool {
step := int(reconciliation / time.Minute)
return s.LunchMinutes-s.Entrada2LeadMinutes >= step
}
func ValidateTimezone(name string) error {
if name == "" {
return domain.ValidationErrors{{Field: "timezone", Message: "obrigatório"}}
}
if _, err := time.LoadLocation(name); err != nil {
return domain.ValidationErrors{{Field: "timezone", Message: "fuso inválido (use um nome IANA, ex.: America/Sao_Paulo)"}}
}
return nil
}
func formatStep(minutes int) string {
return FormatMinutesShort(minutes)
}

View File

@@ -0,0 +1,73 @@
// Package rules contém os cálculos determinísticos da jornada. Não conhece
// banco, HTTP nem Telegram, e por isso é testado sem nenhuma dependência.
package rules
import (
"errors"
"sort"
"time"
"botpontos/internal/domain"
)
var (
ErrNotEnoughPoints = errors.New("pontos insuficientes para o cálculo")
ErrNoRemainingWork = errors.New("tempo restante da jornada igual ou menor que zero")
)
// CurrentWorkday filtra os pontos da data (AAAA-MM-DD, no fuso loc), ordena e
// classifica: mais de quatro pontos é hora extra; horários repetidos ou fora
// de ordem tornam o dia inválido.
func CurrentWorkday(points []domain.Point, day string, loc *time.Location) domain.Workday {
filtered := make([]domain.Point, 0, len(points))
for _, p := range points {
if domain.LocalDay(p.OccurredAt, loc) == day {
filtered = append(filtered, p)
}
}
sort.Slice(filtered, func(i, j int) bool { return filtered[i].OccurredAt.Before(filtered[j].OccurredAt) })
workday := domain.Workday{Points: filtered}
if len(filtered) > 4 {
workday.Overtime = true
return workday
}
for i := 1; i < len(filtered); i++ {
if !filtered[i].OccurredAt.After(filtered[i-1].OccurredAt) {
workday.Invalid = true
}
}
return workday
}
// Entrada2Time: retorno previsto do almoço = saida1 + almoço.
func Entrada2Time(w domain.Workday, lunchMinutes int) (time.Time, error) {
if w.Count() < 2 {
return time.Time{}, ErrNotEnoughPoints
}
return w.Exit1().Add(time.Duration(lunchMinutes) * time.Minute), nil
}
// Saida2Time: saída prevista = entrada2 + (jornada − (saida1 − entrada1)).
func Saida2Time(w domain.Workday, dailyWorkMinutes int) (time.Time, error) {
if w.Count() < 3 {
return time.Time{}, ErrNotEnoughPoints
}
workedBeforeLunch := w.Exit1().Sub(w.Entry1())
remaining := time.Duration(dailyWorkMinutes)*time.Minute - workedBeforeLunch
if remaining <= 0 {
return time.Time{}, ErrNoRemainingWork
}
return w.Entry2().Add(remaining), nil
}
// ReminderAt é o horário do aviso: o horário previsto menos a antecedência.
func ReminderAt(predicted time.Time, leadMinutes int) time.Time {
return predicted.Add(-time.Duration(leadMinutes) * time.Minute)
}
// IsWeekend indica sábado ou domingo no fuso informado.
func IsWeekend(t time.Time, loc *time.Location) bool {
wd := t.In(loc).Weekday()
return wd == time.Saturday || wd == time.Sunday
}