fix(absences): Feiertage über Jahreswechsel korrekt aus working_days rausrechnen
Security Audit / Python Dependency Audit (push) Canceled after 0s
Security Audit / Node.js Dependency Audit (push) Canceled after 0s
Security Audit / Frontend Build (tsc + vite) (push) Canceled after 0s

_get_holiday_dates() fragte nur PublicHoliday für data.start_date.year ab.
Ein Antrag über den Jahreswechsel (z.B. 28.12.-01.01.) rechnete den
Neujahrsfeiertag im Folgejahr daher nicht heraus -> Mitarbeiter verlor
einen Urlaubstag, den er nicht verbraucht hat. Neuer year_to-Parameter
deckt den Jahres-Range ab, beide Aufrufstellen (create_absence,
update_absence) angepasst. Regressionstest ergänzt.

Gefunden während Coverage-Arbeit an absence_service.py/report_service.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
This commit is contained in:
2026-09-02 23:50:28 +02:00
co-authored by Claude Sonnet 5
parent dd044793c6
commit 548738c9ea
2 changed files with 51 additions and 11 deletions
+13 -5
View File
@@ -154,7 +154,7 @@ class AbsenceService:
# Arbeitstage berechnen # Arbeitstage berechnen
holidays = await self._get_holiday_dates( 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( working_days = self._calc_working_days(
data.start_date, data.end_date, holidays, data.start_date, data.end_date, holidays,
@@ -302,7 +302,9 @@ class AbsenceService:
absence.approved_by = None absence.approved_by = None
# Arbeitstage neu berechnen # 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( absence.working_days = Decimal(str(
self._calc_working_days(absence.start_date, absence.end_date, self._calc_working_days(absence.start_date, absence.end_date,
holiday_dates, absence.half_day_start, absence.half_day_end) 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) ob.taken_hours = max(Decimal("0"), ob.taken_hours - hours_to_refund)
async def _get_holiday_dates( 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]: ) -> 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 app.models.company import Company
from sqlalchemy import or_ from sqlalchemy import or_
@@ -846,8 +853,9 @@ class AbsenceService:
country = company.country if company else "DE" country = company.country if company else "DE"
state = company.state if company else None 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( q = select(PublicHoliday.date).where(
PublicHoliday.year == year, PublicHoliday.year.in_(years),
PublicHoliday.country == country, PublicHoliday.country == country,
) )
if state: if state:
+38 -6
View File
@@ -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 # Feiertage für 2027 vorab befüllen, damit absence_service._get_holiday_dates
# (fragt PublicHoliday direkt ab, ohne Auto-Generierung) sie kennt. # (fragt PublicHoliday direkt ab, ohne Auto-Generierung) sie kennt.
# NB: _get_holiday_dates(company_id, data.start_date.year, db) holt Feiertage # Jahreswechsel-Fall (Feiertag im Folgejahr) siehe separat
# NUR für das Jahr von start_date -- ein Zeitraum über den Jahreswechsel (z.B. # test_holiday_dates_cover_year_boundary weiter unten -- hier bewusst
# 28.12.-01.01.) würde den Neujahrs-Feiertag daher NICHT rausrechnen (eigener, # innerhalb eines einzigen Jahres, um Feiertag-vs-Urlaub-Vorrang isoliert
# hier entdeckter Bug, siehe Abschlussbericht). Um genau den hier zu testenden # zu prüfen.
# Feiertag-vs-Urlaub-Vorrang isoliert zu prüfen, bleibt der Zeitraum bewusst
# innerhalb eines einzigen Jahres (Januar 2027).
await db_session.execute(text("SET LOCAL app.bypass_rls = 'on'")) 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(2027, "BY", db_session)
await db_session.commit() 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) 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 ───────────── # ── 6. _categorize_hours: Nacht über Mitternacht + Sonntag/Feiertag ─────────────
def test_categorize_hours_night_shift_over_midnight(): def test_categorize_hours_night_shift_over_midnight():