Files
2026-10-23 22:31:02 -03:00

215 lines
6.4 KiB
Go

package app
import (
"context"
"errors"
"time"
"botpontos/internal/domain"
"botpontos/internal/rules"
)
// TodayView é o resultado de uma consulta ao vivo dos pontos do dia
// (/entrada2, /saida2, botão "testar API" e tela do usuário).
type TodayView struct {
User domain.User
Day string
Points []time.Time
Invalid bool
Overtime bool
Entrada2At *time.Time
Saida2At *time.Time
Saida2Error string
LunchMinutes int
DailyWorkMinutes int
DayState domain.UserDay
Window domain.QueryWindow
CustomWindow bool
Resumed bool // a consulta encontrou pontos e retomou um dia encerrado sem pontos
}
// Today consulta a PontoMais agora. source indica quem pediu, para retomar
// automaticamente um dia "sem pontos" quando a consulta manual encontra pontos (9.12).
func (s *Service) Today(ctx context.Context, actor Actor, userID int64, source *domain.ResumeSource) (TodayView, error) {
u, err := s.store.GetUser(ctx, userID)
if err != nil {
return TodayView{}, err
}
cred, err := s.requireValidatedCredential(ctx, userID)
if err != nil {
return TodayView{}, err
}
schedule, err := s.store.GetSchedule(ctx, userID)
if err != nil {
return TodayView{}, err
}
global, err := s.GlobalWindow(ctx)
if err != nil {
return TodayView{}, err
}
loc := u.Location()
points, err := s.points.History(ctx, cred, loc)
if err != nil {
return TodayView{}, err
}
now := s.now()
view := TodayView{
User: u,
Day: domain.LocalDay(now, loc),
LunchMinutes: schedule.LunchMinutes,
DailyWorkMinutes: schedule.DailyWorkMinutes,
}
view.Window, view.CustomWindow = rules.EffectiveWindow(global, schedule)
w := rules.CurrentWorkday(points, view.Day, loc)
view.Invalid, view.Overtime = w.Invalid, w.Overtime
for _, p := range w.Points {
view.Points = append(view.Points, p.OccurredAt)
}
if !w.Invalid && !w.Overtime {
if t, err := rules.Entrada2Time(w, schedule.LunchMinutes); err == nil {
view.Entrada2At = &t
}
if w.Count() >= 3 {
if t, err := rules.Saida2Time(w, schedule.DailyWorkMinutes); err == nil {
view.Saida2At = &t
} else {
view.Saida2Error = err.Error()
}
}
}
if view.DayState, _, err = s.store.GetUserDay(ctx, userID, view.Day); err != nil {
return view, err
}
if source != nil && w.Count() > 0 && view.DayState.IsClosedWithoutPoints() {
if d, err := s.resumeDayIfClosed(ctx, actor, u, *source); err == nil {
view.DayState, view.Resumed = d, true
}
}
return view, nil
}
// TestCredentials consulta a PontoMais com a credencial atual (botão do painel).
func (s *Service) TestCredentials(ctx context.Context, actor Actor, userID int64) (TodayView, error) {
view, err := s.Today(ctx, actor, userID, nil)
if err != nil {
if errors.Is(err, domain.ErrCredentialsRequired) {
// Credencial gravada mas nunca validada também pode ser testada.
if cred, cerr := s.store.GetCredential(ctx, userID); cerr == nil {
return s.testUnvalidated(ctx, actor, userID, cred)
}
}
s.audit(ctx, actor, &userID, domain.EventCredentialsTested, false, map[string]any{"error": shortError(err)})
return view, err
}
if err := s.store.MarkCredentialValidated(ctx, userID, s.now()); err != nil {
return view, err
}
s.audit(ctx, actor, &userID, domain.EventCredentialsTested, true, map[string]any{"points": len(view.Points)})
return view, nil
}
func (s *Service) testUnvalidated(ctx context.Context, actor Actor, userID int64, cred domain.Credential) (TodayView, error) {
u, err := s.store.GetUser(ctx, userID)
if err != nil {
return TodayView{}, err
}
if _, err := s.points.History(ctx, cred, u.Location()); err != nil {
s.audit(ctx, actor, &userID, domain.EventCredentialsTested, false, map[string]any{"error": shortError(err)})
return TodayView{}, err
}
if err := s.store.MarkCredentialValidated(ctx, userID, s.now()); err != nil {
return TodayView{}, err
}
return s.Today(ctx, actor, userID, nil)
}
// ResumeDay retoma um dia encerrado sem pontos (botão do painel, 9.12).
func (s *Service) ResumeDay(ctx context.Context, actor Actor, userID int64) (domain.UserDay, error) {
u, err := s.store.GetUser(ctx, userID)
if err != nil {
return domain.UserDay{}, err
}
d, err := s.resumeDayIfClosed(ctx, actor, u, domain.ResumeWeb)
if errors.Is(err, errNothingToResume) {
return d, domain.InvalidState("o dia não está encerrado por falta de pontos")
}
return d, err
}
var errNothingToResume = errors.New("nada a retomar")
func (s *Service) resumeDayIfClosed(ctx context.Context, actor Actor, u domain.User, source domain.ResumeSource) (domain.UserDay, error) {
loc := u.Location()
now := s.now()
day := domain.LocalDay(now, loc)
d, found, err := s.store.GetUserDay(ctx, u.ID, day)
if err != nil {
return d, err
}
if !found || !d.IsClosedWithoutPoints() {
return d, errNothingToResume
}
window, err := s.windowFor(ctx, u.ID)
if err != nil {
return d, err
}
d.Status = domain.DayWorking
d.ResumedBy = &source
d.NextCheckAt = rules.ResumeNextCheck(now, window, day, loc, s.cfg.Jobs.ReconciliationInterval)
if err := s.store.SaveUserDay(ctx, d); err != nil {
return d, err
}
s.audit(ctx, actor, &u.ID, domain.EventDayResumed, true, map[string]any{"source": string(source)})
return d, nil
}
// windowFor devolve a janela efetiva de um usuário.
func (s *Service) windowFor(ctx context.Context, userID int64) (domain.QueryWindow, error) {
global, err := s.GlobalWindow(ctx)
if err != nil {
return global, err
}
schedule, err := s.store.GetSchedule(ctx, userID)
if err != nil {
return global, err
}
w, _ := rules.EffectiveWindow(global, schedule)
return w, nil
}
// StatusInfo reúne o que o /status mostra.
type StatusInfo struct {
User domain.User
HasCredential bool
Validated bool
Failures int
Today domain.UserDay
Window domain.QueryWindow
CustomWindow bool
BusinessDay bool
}
func (s *Service) Status(ctx context.Context, userID int64) (StatusInfo, error) {
detail, err := s.UserDetail(ctx, userID)
if err != nil {
return StatusInfo{}, err
}
info := StatusInfo{
User: detail.User,
Today: detail.Today,
Window: detail.Window,
CustomWindow: detail.CustomWindow,
}
if detail.Credential != nil {
info.HasCredential = true
info.Validated = detail.Credential.IsValidated()
info.Failures = detail.Credential.ConsecutiveFailures
}
info.BusinessDay, _ = s.IsBusinessDay(ctx, s.now(), detail.User.Location())
return info, nil
}