diff --git a/backend/app/models/absence.py b/backend/app/models/absence.py index 8474ecb..06ce69a 100644 --- a/backend/app/models/absence.py +++ b/backend/app/models/absence.py @@ -17,6 +17,8 @@ if TYPE_CHECKING: class AbsenceStatus(str, enum.Enum): PENDING = "pending" + # Zwei-Stufen-Genehmigung: erste Stufe erteilt, wartet auf finale Genehmigung. + FIRST_APPROVED = "first_approved" APPROVED = "approved" REJECTED = "rejected" CANCELLED = "cancelled" @@ -47,6 +49,10 @@ class Absence(Base): approved_by: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL") ) + # Zwei-Stufen-Genehmigung: Genehmiger der ersten Stufe (muss != finalem Genehmiger sein). + first_approved_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL") + ) substitute_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL") ) diff --git a/backend/app/models/company.py b/backend/app/models/company.py index f8b4a19..e0e6106 100644 --- a/backend/app/models/company.py +++ b/backend/app/models/company.py @@ -78,6 +78,11 @@ class Company(Base): # Teilzeit: Anspruch aus Arbeitstagen/Woche des WorkSchedule ableiten (opt-in). vacation_from_schedule: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # Zwei-Stufen-Genehmigung (agent-12): erst Manager, dann HR/Admin (anderer Genehmiger). + two_stage_approval_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # Nur Anträge ab dieser Arbeitstage-Zahl brauchen die zweite Stufe (0 = alle). + two_stage_min_days: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # Freizeitausgleich-Konfiguration overtime_overdraft_allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) overtime_warning_threshold_hours: Mapped[int] = mapped_column(Integer, nullable=False, default=0) diff --git a/backend/app/schemas/absence.py b/backend/app/schemas/absence.py index 5e360c7..2ff6037 100644 --- a/backend/app/schemas/absence.py +++ b/backend/app/schemas/absence.py @@ -71,6 +71,7 @@ class AbsenceOut(BaseModel): fza_hours: Decimal | None = None status: AbsenceStatus approved_by: uuid.UUID | None + first_approved_by: uuid.UUID | None = None substitute_id: uuid.UUID | None note: str | None correction_note: str | None diff --git a/backend/app/schemas/company.py b/backend/app/schemas/company.py index e236164..fbeb937 100644 --- a/backend/app/schemas/company.py +++ b/backend/app/schemas/company.py @@ -50,6 +50,8 @@ class CompanyOut(BaseModel): vacation_default_days: int = 30 vacation_prorate_first_year: bool = True vacation_from_schedule: bool = False + two_stage_approval_enabled: bool = False + two_stage_min_days: int = 0 class PublicStampTokenStatus(BaseModel): @@ -88,6 +90,8 @@ class CompanyUpdate(BaseModel): vacation_default_days: int | None = Field(None, ge=0, le=365) vacation_prorate_first_year: bool | None = None vacation_from_schedule: bool | None = None + two_stage_approval_enabled: bool | None = None + two_stage_min_days: int | None = Field(None, ge=0, le=365) class DepartmentOut(BaseModel): diff --git a/backend/app/services/absence_service.py b/backend/app/services/absence_service.py index e5a9780..f19b9ad 100644 --- a/backend/app/services/absence_service.py +++ b/backend/app/services/absence_service.py @@ -351,7 +351,7 @@ class AbsenceService: await self._refund_overtime( absence.user_id, absence.working_days, db, fza_hours=absence.fza_hours ) - elif absence.status != AbsenceStatus.PENDING: + elif absence.status not in (AbsenceStatus.PENDING, AbsenceStatus.FIRST_APPROVED): raise HTTPException( status_code=409, detail="Nur ausstehende oder genehmigte Anträge können storniert werden." @@ -407,7 +407,39 @@ class AbsenceService: await self._apply_cancellation(absence, current_user, db, from_request=True) return absence, [] - if absence.status != AbsenceStatus.PENDING: + # Zwei-Stufen-Genehmigung (firmenweit opt-in, optional ab Schwellwert) + company = await db.get(Company, current_user.company_id) + two_stage = bool( + company and company.two_stage_approval_enabled + and absence.working_days >= (company.two_stage_min_days or 0) + ) + _final_roles = (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN) + + if absence.status == AbsenceStatus.FIRST_APPROVED: + # Zweite (finale) Stufe – nur HR/Admin und ein anderer Genehmiger als Stufe 1 + if current_user.role not in _final_roles: + raise HTTPException(status_code=403, detail="Nur HR/Admin kann die finale Genehmigung erteilen.") + if absence.first_approved_by == current_user.id: + raise HTTPException(status_code=409, detail="Die finale Genehmigung muss von einer anderen Person erfolgen.") + # → fällt durch zur finalen Genehmigung unten + elif absence.status == AbsenceStatus.PENDING: + if two_stage: + absence.status = AbsenceStatus.FIRST_APPROVED + absence.first_approved_by = current_user.id + db.add(AuditLog( + company_id=current_user.company_id, user_id=current_user.id, + action="absence_first_approved", entity_type="absence", entity_id=absence.id, + old_value={"status": "pending"}, + new_value={"status": "first_approved", "first_approved_by": str(current_user.id), + "absence_user_id": str(absence.user_id)}, + )) + await self._add_system_comment( + absence, current_user.company_id, current_user.id, + f"Erste Stufe genehmigt von {current_user.full_name} – wartet auf finale Genehmigung.", db, + ) + return absence, [] + # einstufig → direkt finale Genehmigung unten + else: raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können genehmigt werden.") absence.status = AbsenceStatus.APPROVED @@ -487,7 +519,7 @@ class AbsenceService: ) return absence - if absence.status != AbsenceStatus.PENDING: + if absence.status not in (AbsenceStatus.PENDING, AbsenceStatus.FIRST_APPROVED): raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können abgelehnt werden.") absence.status = AbsenceStatus.REJECTED @@ -532,7 +564,7 @@ class AbsenceService: .join(AbsenceType, Absence.type_id == AbsenceType.id) .where( User.company_id == company_id, - Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.APPROVED]), + Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.FIRST_APPROVED, AbsenceStatus.APPROVED]), ) ) if month: @@ -576,7 +608,7 @@ class AbsenceService: .join(AbsenceType, Absence.type_id == AbsenceType.id) .where( Absence.user_id == user_id, - Absence.status == AbsenceStatus.PENDING, + Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.FIRST_APPROVED]), AbsenceType.deducts_vacation.is_(True), func.extract("year", Absence.start_date) == year, ) diff --git a/backend/app/services/scheduler_service.py b/backend/app/services/scheduler_service.py index 8e7c24a..cfe28f0 100644 --- a/backend/app/services/scheduler_service.py +++ b/backend/app/services/scheduler_service.py @@ -63,7 +63,10 @@ async def run_pending_approvals(db: AsyncSession, company_id=None) -> int: select(Absence, User, AbsenceType) .join(User, Absence.user_id == User.id) .join(AbsenceType, Absence.type_id == AbsenceType.id) - .where(User.company_id == company.id, Absence.status == AbsenceStatus.PENDING) + .where( + User.company_id == company.id, + Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.FIRST_APPROVED]), + ) .order_by(Absence.start_date) )).all() if not rows: diff --git a/backend/migrations/versions/0038_two_stage_approval.py b/backend/migrations/versions/0038_two_stage_approval.py new file mode 100644 index 0000000..f42a890 --- /dev/null +++ b/backend/migrations/versions/0038_two_stage_approval.py @@ -0,0 +1,39 @@ +"""Two-stage absence approval (agent-12) + +Revision ID: 0038 +Revises: 0037 +Create Date: 2026-06-23 + +- AbsenceStatus.FIRST_APPROVED (erste Stufe erteilt, wartet auf finale Genehmigung) +- absences.first_approved_by (Genehmiger Stufe 1; muss != finalem Genehmiger sein) +- companies.two_stage_approval_enabled + two_stage_min_days (firmenweites Opt-in) +""" +from alembic import op + +revision = "0038" +down_revision = "0037" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("ALTER TYPE absencestatus ADD VALUE IF NOT EXISTS 'first_approved'") + op.execute( + "ALTER TABLE absences ADD COLUMN IF NOT EXISTS first_approved_by UUID " + "REFERENCES users(id) ON DELETE SET NULL" + ) + op.execute( + "ALTER TABLE companies ADD COLUMN IF NOT EXISTS " + "two_stage_approval_enabled BOOLEAN NOT NULL DEFAULT FALSE" + ) + op.execute( + "ALTER TABLE companies ADD COLUMN IF NOT EXISTS " + "two_stage_min_days INTEGER NOT NULL DEFAULT 0" + ) + + +def downgrade() -> None: + # Enum-Wert bleibt (PostgreSQL kann ihn nicht entfernen). + op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS two_stage_min_days") + op.execute("ALTER TABLE companies DROP COLUMN IF EXISTS two_stage_approval_enabled") + op.execute("ALTER TABLE absences DROP COLUMN IF EXISTS first_approved_by") diff --git a/backend/tests/test_absences.py b/backend/tests/test_absences.py index 9882bb9..d389aa8 100644 --- a/backend/tests/test_absences.py +++ b/backend/tests/test_absences.py @@ -692,3 +692,69 @@ async def test_part_time_entitlement_from_schedule(client: AsyncClient, abs_head assert bal.status_code == 200, bal.text assert bal.json()["entitled_days"] == 18 # 30 * 3/5 await client.patch("/api/v1/companies/me", json={"vacation_from_schedule": False}, headers=abs_headers) + + +# ── agent-12: Zwei-Stufen-Genehmigung ───────────────────────────────────────── + +@pytest.mark.asyncio +async def test_two_stage_approval_flow( + client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id +): + await client.patch("/api/v1/companies/me", + json={"two_stage_approval_enabled": True}, headers=abs_headers) + inv = await client.post("/api/v1/users/invite", json={ + "first_name": "Two", "last_name": "Stage", "email": "twostage@absenceag.de", + "role": "EMPLOYEE", "initial_password": "Secret123", + }, headers=abs_headers) + assert inv.status_code == 201, inv.text + login = await client.post("/api/v1/auth/login", json={ + "email": "twostage@absenceag.de", "password": "Secret123"}) + emp_h = {"Authorization": f"Bearer {login.json()['access_token']}"} + + start = _future_monday(17) + cr = await client.post("/api/v1/absences/", json={ + "type_id": str(vacation_type_id), "start_date": str(start), + "end_date": str(start + timedelta(days=2)), + }, headers=emp_h) + aid = cr.json()["id"] + + # Erste Stufe: Approver A → first_approved (noch kein Abzug) + r1 = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers) + assert r1.status_code == 200, r1.text + assert r1.json()["status"] == "first_approved" + + # Gleicher Genehmiger darf die finale Stufe nicht erteilen + same = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers) + assert same.status_code == 409 + + # Finale Stufe: anderer Admin → approved + r2 = await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_headers) + assert r2.status_code == 200, r2.text + assert r2.json()["status"] == "approved" + + await client.patch("/api/v1/companies/me", + json={"two_stage_approval_enabled": False}, headers=abs_headers) + + +@pytest.mark.asyncio +async def test_two_stage_reject_at_first_stage( + client: AsyncClient, abs_headers, abs_approver_headers, vacation_type_id +): + await client.patch("/api/v1/companies/me", + json={"two_stage_approval_enabled": True}, headers=abs_headers) + login = await client.post("/api/v1/auth/login", json={ + "email": "twostage@absenceag.de", "password": "Secret123"}) + emp_h = {"Authorization": f"Bearer {login.json()['access_token']}"} + start = _future_monday(19) + cr = await client.post("/api/v1/absences/", json={ + "type_id": str(vacation_type_id), "start_date": str(start), + "end_date": str(start + timedelta(days=1)), + }, headers=emp_h) + aid = cr.json()["id"] + await client.post(f"/api/v1/absences/{aid}/approve", headers=abs_approver_headers) + rej = await client.post(f"/api/v1/absences/{aid}/reject", + json={"rejection_reason": "Doch nicht"}, headers=abs_headers) + assert rej.status_code == 200, rej.text + assert rej.json()["status"] == "rejected" + await client.patch("/api/v1/companies/me", + json={"two_stage_approval_enabled": False}, headers=abs_headers) diff --git a/frontend/src/pages/AbsencesPage.tsx b/frontend/src/pages/AbsencesPage.tsx index b08dc8a..acbfc77 100644 --- a/frontend/src/pages/AbsencesPage.tsx +++ b/frontend/src/pages/AbsencesPage.tsx @@ -477,6 +477,12 @@ export function AbsencesPage() { >)} + {a.status === 'first_approved' + && ['HR', 'COMPANY_ADMIN', 'SUPER_ADMIN'].includes(user.role) + && a.first_approved_by !== user.id && (<> + + + >)} {isManager && a.status === 'cancellation_requested' && (<> diff --git a/frontend/src/pages/CompanySettingsPage.tsx b/frontend/src/pages/CompanySettingsPage.tsx index fbcec76..2183554 100644 --- a/frontend/src/pages/CompanySettingsPage.tsx +++ b/frontend/src/pages/CompanySettingsPage.tsx @@ -53,6 +53,9 @@ export function CompanySettingsPage() { const [vacationDefaultDays, setVacationDefaultDays] = useState(30) const [vacationProrate, setVacationProrate] = useState(true) const [vacationFromSchedule, setVacationFromSchedule] = useState(false) + // Zwei-Stufen-Genehmigung (agent-12) + const [twoStageEnabled, setTwoStageEnabled] = useState(false) + const [twoStageMinDays, setTwoStageMinDays] = useState(0) // Personalnummern const [pnRequired, setPnRequired] = useState(false) const [pnMode, setPnMode] = useState<'manual' | 'auto'>('manual') @@ -110,10 +113,12 @@ export function CompanySettingsPage() { setOvertimeExpiryMonth(cc.overtime_expiry_month ?? 3) setOvertimeExpiryDay(cc.overtime_expiry_day ?? 31) setOvertimeMaxCarryoverHours(cc.overtime_max_carryover_hours ?? null) - const vc = c as CompanyOut & { vacation_default_days?: number; vacation_prorate_first_year?: boolean; vacation_from_schedule?: boolean } + const vc = c as CompanyOut & { vacation_default_days?: number; vacation_prorate_first_year?: boolean; vacation_from_schedule?: boolean; two_stage_approval_enabled?: boolean; two_stage_min_days?: number } setVacationDefaultDays(vc.vacation_default_days ?? 30) setVacationProrate(vc.vacation_prorate_first_year ?? true) setVacationFromSchedule(vc.vacation_from_schedule ?? false) + setTwoStageEnabled(vc.two_stage_approval_enabled ?? false) + setTwoStageMinDays(vc.two_stage_min_days ?? 0) }).catch(() => {}) api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token') .then(setBlStatus) @@ -185,6 +190,8 @@ export function CompanySettingsPage() { vacation_default_days: vacationDefaultDays, vacation_prorate_first_year: vacationProrate, vacation_from_schedule: vacationFromSchedule, + two_stage_approval_enabled: twoStageEnabled, + two_stage_min_days: twoStageMinDays, overtime_overdraft_allowed: fzaOverdraftAllowed, overtime_warning_threshold_hours: fzaWarningThreshold, kiosk_require_approval: kioskRequireApproval, @@ -387,6 +394,25 @@ export function CompanySettingsPage() { Anspruch = Jahresurlaub × Arbeitstage pro Woche ÷ 5. +