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 }