feat: add deadline CRUD, calculator, and holiday services (Phase 1C)
- Holiday service with German federal holidays, Easter calculation, DB loading - Deadline calculator adapted from youpc.org (duration calc + non-working day adjustment) - Deadline CRUD service (tenant-scoped: list, create, update, complete, delete) - Deadline rule service (list, filter by proceeding type, hierarchical rule trees) - HTTP handlers for all endpoints with tenant resolution via X-Tenant-ID header - Router wired with all new endpoints under /api/ - Tests for holiday and calculator services (8 passing)
This commit is contained in:
193
backend/internal/services/holidays.go
Normal file
193
backend/internal/services/holidays.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// Holiday represents a non-working day
|
||||
type Holiday struct {
|
||||
Date time.Time
|
||||
Name string
|
||||
IsVacation bool // Part of court vacation period
|
||||
IsClosure bool // Single-day closure (public holiday)
|
||||
}
|
||||
|
||||
// HolidayService manages holiday data and non-working day checks
|
||||
type HolidayService struct {
|
||||
db *sqlx.DB
|
||||
// Cached holidays by year
|
||||
cache map[int][]Holiday
|
||||
}
|
||||
|
||||
// NewHolidayService creates a holiday service
|
||||
func NewHolidayService(db *sqlx.DB) *HolidayService {
|
||||
return &HolidayService{
|
||||
db: db,
|
||||
cache: make(map[int][]Holiday),
|
||||
}
|
||||
}
|
||||
|
||||
// dbHoliday matches the holidays table schema
|
||||
type dbHoliday struct {
|
||||
ID int `db:"id"`
|
||||
Date time.Time `db:"date"`
|
||||
Name string `db:"name"`
|
||||
Country string `db:"country"`
|
||||
State *string `db:"state"`
|
||||
HolidayType string `db:"holiday_type"`
|
||||
}
|
||||
|
||||
// LoadHolidaysForYear loads holidays from DB for a given year, merges with
|
||||
// German federal holidays, and caches the result.
|
||||
func (s *HolidayService) LoadHolidaysForYear(year int) ([]Holiday, error) {
|
||||
if cached, ok := s.cache[year]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
holidays := make([]Holiday, 0, 30)
|
||||
|
||||
// Load from DB if available
|
||||
if s.db != nil {
|
||||
var dbHolidays []dbHoliday
|
||||
err := s.db.Select(&dbHolidays,
|
||||
`SELECT id, date, name, country, state, holiday_type
|
||||
FROM holidays
|
||||
WHERE EXTRACT(YEAR FROM date) = $1
|
||||
ORDER BY date`, year)
|
||||
if err == nil {
|
||||
for _, h := range dbHolidays {
|
||||
holidays = append(holidays, Holiday{
|
||||
Date: h.Date,
|
||||
Name: h.Name,
|
||||
IsClosure: h.HolidayType == "public_holiday" || h.HolidayType == "closure",
|
||||
IsVacation: h.HolidayType == "vacation",
|
||||
})
|
||||
}
|
||||
}
|
||||
// If DB query fails, fall through to hardcoded holidays
|
||||
}
|
||||
|
||||
// Always add German federal holidays (if not already present from DB)
|
||||
federal := germanFederalHolidays(year)
|
||||
existing := make(map[string]bool, len(holidays))
|
||||
for _, h := range holidays {
|
||||
existing[h.Date.Format("2006-01-02")] = true
|
||||
}
|
||||
for _, h := range federal {
|
||||
key := h.Date.Format("2006-01-02")
|
||||
if !existing[key] {
|
||||
holidays = append(holidays, h)
|
||||
}
|
||||
}
|
||||
|
||||
s.cache[year] = holidays
|
||||
return holidays, nil
|
||||
}
|
||||
|
||||
// IsHoliday checks if a date is a holiday
|
||||
func (s *HolidayService) IsHoliday(date time.Time) *Holiday {
|
||||
year := date.Year()
|
||||
holidays, err := s.LoadHolidaysForYear(year)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dateStr := date.Format("2006-01-02")
|
||||
for i := range holidays {
|
||||
if holidays[i].Date.Format("2006-01-02") == dateStr {
|
||||
return &holidays[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsNonWorkingDay returns true if the date is a weekend or holiday
|
||||
func (s *HolidayService) IsNonWorkingDay(date time.Time) bool {
|
||||
wd := date.Weekday()
|
||||
if wd == time.Saturday || wd == time.Sunday {
|
||||
return true
|
||||
}
|
||||
return s.IsHoliday(date) != nil
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDays moves the date to the next working day
|
||||
// if it falls on a weekend or holiday.
|
||||
// Returns adjusted date, original date, and whether adjustment was made.
|
||||
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time) (adjusted time.Time, original time.Time, wasAdjusted bool) {
|
||||
original = date
|
||||
adjusted = date
|
||||
|
||||
// Safety limit: max 30 days forward
|
||||
for i := 0; i < 30 && s.IsNonWorkingDay(adjusted); i++ {
|
||||
adjusted = adjusted.AddDate(0, 0, 1)
|
||||
wasAdjusted = true
|
||||
}
|
||||
|
||||
return adjusted, original, wasAdjusted
|
||||
}
|
||||
|
||||
// ClearCache clears the holiday cache (useful after DB updates)
|
||||
func (s *HolidayService) ClearCache() {
|
||||
s.cache = make(map[int][]Holiday)
|
||||
}
|
||||
|
||||
// germanFederalHolidays returns all German federal public holidays for a year.
|
||||
// These are holidays observed in all 16 German states.
|
||||
func germanFederalHolidays(year int) []Holiday {
|
||||
easterMonth, easterDay := CalculateEasterSunday(year)
|
||||
easter := time.Date(year, time.Month(easterMonth), easterDay, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
holidays := []Holiday{
|
||||
{Date: time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), Name: "Neujahr", IsClosure: true},
|
||||
{Date: easter.AddDate(0, 0, -2), Name: "Karfreitag", IsClosure: true},
|
||||
{Date: easter, Name: "Ostersonntag", IsClosure: true},
|
||||
{Date: easter.AddDate(0, 0, 1), Name: "Ostermontag", IsClosure: true},
|
||||
{Date: time.Date(year, time.May, 1, 0, 0, 0, 0, time.UTC), Name: "Tag der Arbeit", IsClosure: true},
|
||||
{Date: easter.AddDate(0, 0, 39), Name: "Christi Himmelfahrt", IsClosure: true},
|
||||
{Date: easter.AddDate(0, 0, 49), Name: "Pfingstsonntag", IsClosure: true},
|
||||
{Date: easter.AddDate(0, 0, 50), Name: "Pfingstmontag", IsClosure: true},
|
||||
{Date: time.Date(year, time.October, 3, 0, 0, 0, 0, time.UTC), Name: "Tag der Deutschen Einheit", IsClosure: true},
|
||||
{Date: time.Date(year, time.December, 25, 0, 0, 0, 0, time.UTC), Name: "1. Weihnachtstag", IsClosure: true},
|
||||
{Date: time.Date(year, time.December, 26, 0, 0, 0, 0, time.UTC), Name: "2. Weihnachtstag", IsClosure: true},
|
||||
}
|
||||
|
||||
return holidays
|
||||
}
|
||||
|
||||
// CalculateEasterSunday computes Easter Sunday using the Anonymous Gregorian algorithm.
|
||||
// Returns month (1-12) and day.
|
||||
func CalculateEasterSunday(year int) (int, int) {
|
||||
a := year % 19
|
||||
b := year / 100
|
||||
c := year % 100
|
||||
d := b / 4
|
||||
e := b % 4
|
||||
f := (b + 8) / 25
|
||||
g := (b - f + 1) / 3
|
||||
h := (19*a + b - d - g + 15) % 30
|
||||
i := c / 4
|
||||
k := c % 4
|
||||
l := (32 + 2*e + 2*i - h - k) % 7
|
||||
m := (a + 11*h + 22*l) / 451
|
||||
month := (h + l - 7*m + 114) / 31
|
||||
day := ((h + l - 7*m + 114) % 31) + 1
|
||||
return month, day
|
||||
}
|
||||
|
||||
// GetHolidaysForYear returns all holidays for a year (for API exposure)
|
||||
func (s *HolidayService) GetHolidaysForYear(year int) ([]Holiday, error) {
|
||||
return s.LoadHolidaysForYear(year)
|
||||
}
|
||||
|
||||
// FormatHolidayList returns a simple string representation of holidays for debugging
|
||||
func FormatHolidayList(holidays []Holiday) string {
|
||||
var b strings.Builder
|
||||
for _, h := range holidays {
|
||||
fmt.Fprintf(&b, "%s: %s\n", h.Date.Format("2006-01-02"), h.Name)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user