feat: agent-12 – Zwei-Stufen-Genehmigung für Abwesenheiten
Optionale zweistufige Freigabe (Feature-Parität mit Urlaubsverwaltung,
Second-Stage-Authority), ohne SSO:
- Firmen-Opt-in companies.two_stage_approval_enabled + two_stage_min_days
(nur Anträge ab X Arbeitstagen brauchen Stufe 2; 0 = alle).
- Ablauf PENDING → FIRST_APPROVED → APPROVED: erste Stufe durch Manager-Rollen,
finale Stufe nur HR/Admin und zwingend eine ANDERE Person als Stufe 1.
- Urlaubs-/FZA-Abzug, CalDAV-Sync und Vertreter-Mail erst bei finaler Genehmigung.
Ablehnen in beiden Stufen möglich; Eigentümer darf FIRST_APPROVED noch stornieren.
- pending_days, Kalender und Reminder-Digest berücksichtigen FIRST_APPROVED.
- Neuer Status-Wert + absences.first_approved_by; System-Kommentar bei Stufe 1.
Frontend: CompanySettingsPage (Toggle + Schwellwert), AbsencesPage
("Endgültig genehmigen"/Ablehnen für HR/Admin ≠ Erstgenehmiger, Status-Badge).
Migration 0038. 190/190 Tests grün. Deployed 137 + 164.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user