diff --git a/backend/app/services/absence_service.py b/backend/app/services/absence_service.py index f19b9ad..3113aea 100644 --- a/backend/app/services/absence_service.py +++ b/backend/app/services/absence_service.py @@ -154,7 +154,7 @@ class AbsenceService: # Arbeitstage berechnen holidays = await self._get_holiday_dates( - current_user.company_id, data.start_date.year, db + current_user.company_id, data.start_date.year, db, year_to=data.end_date.year ) working_days = self._calc_working_days( data.start_date, data.end_date, holidays, @@ -302,7 +302,9 @@ class AbsenceService: absence.approved_by = None # Arbeitstage neu berechnen - holiday_dates = await self._get_holiday_dates(current_user.company_id, absence.start_date.year, db) + holiday_dates = await self._get_holiday_dates( + current_user.company_id, absence.start_date.year, db, year_to=absence.end_date.year + ) absence.working_days = Decimal(str( self._calc_working_days(absence.start_date, absence.end_date, holiday_dates, absence.half_day_start, absence.half_day_end) @@ -836,9 +838,14 @@ class AbsenceService: ob.taken_hours = max(Decimal("0"), ob.taken_hours - hours_to_refund) async def _get_holiday_dates( - self, company_id: UUID, year: int, db: AsyncSession + self, company_id: UUID, year: int, db: AsyncSession, year_to: int | None = None ) -> set[date]: - """Feiertage für die Company-Country holen.""" + """Feiertage für die Company-Country holen. + + `year_to` optional für Zeiträume über einen Jahreswechsel (z.B. 28.12.-02.01.) – + ohne würde der Feiertag im Folgejahr (Neujahr) fälschlich nicht aus + working_days rausgerechnet. + """ from app.models.company import Company from sqlalchemy import or_ @@ -846,8 +853,9 @@ class AbsenceService: country = company.country if company else "DE" state = company.state if company else None + years = [year] if year_to is None or year_to == year else list(range(year, year_to + 1)) q = select(PublicHoliday.date).where( - PublicHoliday.year == year, + PublicHoliday.year.in_(years), PublicHoliday.country == country, ) if state: diff --git a/backend/tests/test_coverage_gaps.py b/backend/tests/test_coverage_gaps.py index e31c1c6..5fb85a5 100644 --- a/backend/tests/test_coverage_gaps.py +++ b/backend/tests/test_coverage_gaps.py @@ -456,12 +456,10 @@ async def test_datev_holiday_takes_precedence_over_vacation( # Feiertage für 2027 vorab befüllen, damit absence_service._get_holiday_dates # (fragt PublicHoliday direkt ab, ohne Auto-Generierung) sie kennt. - # NB: _get_holiday_dates(company_id, data.start_date.year, db) holt Feiertage - # NUR für das Jahr von start_date -- ein Zeitraum über den Jahreswechsel (z.B. - # 28.12.-01.01.) würde den Neujahrs-Feiertag daher NICHT rausrechnen (eigener, - # hier entdeckter Bug, siehe Abschlussbericht). Um genau den hier zu testenden - # Feiertag-vs-Urlaub-Vorrang isoliert zu prüfen, bleibt der Zeitraum bewusst - # innerhalb eines einzigen Jahres (Januar 2027). + # Jahreswechsel-Fall (Feiertag im Folgejahr) siehe separat + # test_holiday_dates_cover_year_boundary weiter unten -- hier bewusst + # innerhalb eines einzigen Jahres, um Feiertag-vs-Urlaub-Vorrang isoliert + # zu prüfen. await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) await ensure_holidays_for_year(2027, "BY", db_session) await db_session.commit() @@ -511,6 +509,40 @@ async def test_datev_holiday_takes_precedence_over_vacation( await client.patch("/api/v1/companies/me", json={"state": None}, headers=cov_headers) +async def test_holiday_dates_cover_year_boundary( + client: AsyncClient, db_session: AsyncSession, cov_company, cov_headers, cov_vacation_type_id, +): + """Regressionstest für den in test_datev_holiday_takes_precedence_over_vacation + dokumentierten Bug: ein Antrag über den Jahreswechsel muss den Feiertag im + Folgejahr (Neujahr) korrekt aus working_days herausrechnen. Fix: + _get_holiday_dates(..., year_to=end_date.year) statt nur start_date.year.""" + from app.services.holiday_service import ensure_holidays_for_year + + r = await client.patch("/api/v1/companies/me", json={"state": "BY"}, headers=cov_headers) + assert r.status_code == 200, r.text + + await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) + await ensure_holidays_for_year(2027, "BY", db_session) + await ensure_holidays_for_year(2028, "BY", db_session) + await db_session.commit() + + # Di 28.12.2027 - Sa 01.01.2028: Wochentage Di/Mi/Do/Fr (29./30./31.12., 01.01.), + # davon Neujahr (01.01.2028) ein Feiertag -> 3 Werktage + start = date(2027, 12, 28) + end = date(2028, 1, 1) + resp = await client.post("/api/v1/absences/", json={ + "type_id": str(cov_vacation_type_id), "start_date": str(start), "end_date": str(end), + }, headers=cov_headers) + assert resp.status_code == 201, resp.text + data = resp.json() + assert data["working_days"] == 3, ( + f"Neujahr (Folgejahr) muss aus working_days herausgerechnet werden, " + f"got {data['working_days']}" + ) + + await client.patch("/api/v1/companies/me", json={"state": None}, headers=cov_headers) + + # ── 6. _categorize_hours: Nacht über Mitternacht + Sonntag/Feiertag ───────────── def test_categorize_hours_night_shift_over_midnight():