142 lines
4.2 KiB
Go
142 lines
4.2 KiB
Go
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
|
|
}
|