215 lines
6.4 KiB
Go
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
|
|
}
|