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

135 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}