Projeto bot pontos
This commit is contained in:
24
backend/internal/rules/format.go
Normal file
24
backend/internal/rules/format.go
Normal 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")
|
||||
}
|
||||
134
backend/internal/rules/plan.go
Normal file
134
backend/internal/rules/plan.go
Normal 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
|
||||
}
|
||||
229
backend/internal/rules/rules_test.go
Normal file
229
backend/internal/rules/rules_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
98
backend/internal/rules/validation.go
Normal file
98
backend/internal/rules/validation.go
Normal 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)
|
||||
}
|
||||
73
backend/internal/rules/workday.go
Normal file
73
backend/internal/rules/workday.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user