Projeto bot pontos
This commit is contained in:
141
backend/internal/app/calendar.go
Normal file
141
backend/internal/app/calendar.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"botpontos/internal/domain"
|
||||
"botpontos/internal/rules"
|
||||
)
|
||||
|
||||
const stateHolidayFallbackNotified = "holiday_fallback_notified"
|
||||
|
||||
// IsBusinessDay aplica a ordem da seção 9.9: fim de semana → tabela holidays
|
||||
// (com sincronização anual da API) → fallback de segunda a sexta com aviso.
|
||||
func (s *Service) IsBusinessDay(ctx context.Context, t time.Time, loc *time.Location) (bool, error) {
|
||||
if rules.IsWeekend(t, loc) {
|
||||
return false, nil
|
||||
}
|
||||
local := t.In(loc)
|
||||
s.ensureHolidays(ctx, local.Year())
|
||||
|
||||
holiday, err := s.store.IsHoliday(ctx, local.Format(domain.DateLayout))
|
||||
if err != nil {
|
||||
// Sem banco não há como saber; mantém a automação funcionando.
|
||||
return true, err
|
||||
}
|
||||
return !holiday, nil
|
||||
}
|
||||
|
||||
// ensureHolidays sincroniza o ano com a API na primeira vez e, enquanto não
|
||||
// der certo, tenta no máximo uma vez por dia (9.9).
|
||||
func (s *Service) ensureHolidays(ctx context.Context, year int) {
|
||||
today := s.TodayDate()
|
||||
|
||||
s.mu.Lock()
|
||||
if s.syncedYears[year] || s.syncTried[year] == today {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.syncTried[year] = today
|
||||
s.mu.Unlock()
|
||||
|
||||
synced, err := s.store.HasSyncedHolidays(ctx, year)
|
||||
if err == nil && synced {
|
||||
s.markSynced(year)
|
||||
return
|
||||
}
|
||||
if _, err := s.syncHolidays(ctx, WorkerActor, year); err != nil {
|
||||
s.holidayFallback(ctx, year, err)
|
||||
return
|
||||
}
|
||||
s.markSynced(year)
|
||||
}
|
||||
|
||||
func (s *Service) markSynced(year int) {
|
||||
s.mu.Lock()
|
||||
s.syncedYears[year] = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// holidayFallback avisa os admins no máximo uma vez por dia (9.9 e 9.11).
|
||||
func (s *Service) holidayFallback(ctx context.Context, year int, cause error) {
|
||||
today := s.TodayDate()
|
||||
var state struct {
|
||||
Day string `json:"day"`
|
||||
}
|
||||
if found, _ := s.store.GetState(ctx, stateHolidayFallbackNotified, &state); found && state.Day == today {
|
||||
return
|
||||
}
|
||||
state.Day = today
|
||||
if err := s.store.SetState(ctx, stateHolidayFallbackNotified, state); err != nil {
|
||||
s.log.Warn("falha ao registrar aviso de feriados", "error", err)
|
||||
}
|
||||
s.audit(ctx, WorkerActor, nil, domain.EventHolidaySyncFailed, false, map[string]any{"year": year, "error": shortError(cause)})
|
||||
s.notifyAdmins(ctx, msgHolidayFallback(year, cause))
|
||||
}
|
||||
|
||||
// SyncHolidays baixa os feriados do ano (worker ou botão do painel).
|
||||
func (s *Service) SyncHolidays(ctx context.Context, actor Actor, year int) (int64, error) {
|
||||
inserted, err := s.syncHolidays(ctx, actor, year)
|
||||
if err == nil {
|
||||
s.markSynced(year)
|
||||
}
|
||||
return inserted, err
|
||||
}
|
||||
|
||||
func (s *Service) syncHolidays(ctx context.Context, actor Actor, year int) (int64, error) {
|
||||
if s.holidays == nil {
|
||||
return 0, domain.ErrNotConfigured
|
||||
}
|
||||
holidays, err := s.holidays.FetchYear(ctx, year)
|
||||
if err != nil {
|
||||
if actor.Source != domain.SourceWorker {
|
||||
s.audit(ctx, actor, nil, domain.EventHolidaySyncFailed, false, map[string]any{"year": year, "error": shortError(err)})
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
inserted, err := s.store.InsertHolidays(ctx, holidays)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.audit(ctx, actor, nil, domain.EventHolidaySynced, true, map[string]any{"year": year, "received": len(holidays), "inserted": inserted})
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListHolidays(ctx context.Context, year int) ([]domain.Holiday, error) {
|
||||
return s.store.ListHolidays(ctx, year)
|
||||
}
|
||||
|
||||
func (s *Service) AddHoliday(ctx context.Context, actor Actor, day, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
var errs domain.ValidationErrors
|
||||
if _, err := time.Parse(domain.DateLayout, day); err != nil {
|
||||
errs.Add("day", "data inválida (use AAAA-MM-DD)")
|
||||
}
|
||||
if name == "" || len(name) > 120 {
|
||||
errs.Add("name", "obrigatório, até 120 caracteres")
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
err := s.store.AddHoliday(ctx, domain.Holiday{Day: day, Name: name, Source: domain.HolidaySourceManual})
|
||||
if errors.Is(err, domain.ErrConflict) {
|
||||
return domain.ValidationErrors{{Field: "day", Message: "já existe um feriado nessa data"}}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(ctx, actor, nil, domain.EventHolidayAdded, true, map[string]any{"day": day, "name": name})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteHoliday(ctx context.Context, actor Actor, day string) error {
|
||||
if err := s.store.DeleteManualHoliday(ctx, day); err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(ctx, actor, nil, domain.EventHolidayRemoved, true, map[string]any{"day": day})
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user