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 }