fix(absences): Feiertage über Jahreswechsel korrekt aus working_days rausrechnen
_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:
@@ -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:
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user