feat: Sondervertretungs-Faktoren (special_assignments)

- Neues Model SpecialAssignment mit AssignmentMode (fza|payroll|both)
- CRUD-Endpunkte unter /users/{id}/special-assignments
- Payroll-Report: GET /reports/special-assignments/payroll?year=&month=
- Migration 0029: special_assignments Tabelle + btree_gist Overlap-Constraint
- _recalculate_overtime_balance berücksichtigt FZA-Faktoren
- Frontend: Sondervertretungs-Zeiträume im UsersPage Edit-Modal
- Frontend: ReportsPage neuer Tab 'Sondervertretungen' mit Payroll-Tabelle + CSV-Export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:55:47 +02:00
parent 1170e59e49
commit d60349df67
12 changed files with 837 additions and 39 deletions
@@ -0,0 +1,61 @@
"""Sondervertretungs-Faktoren: special_assignments Tabelle
Revision ID: 0029
Revises: 0028
Create Date: 2026-05-25
Neue Tabelle special_assignments:
- user_id + company_id (ForeignKeys mit CASCADE)
- date_from / date_to
- factor NUMERIC(5,3) Multiplikator (z.B. 1.5)
- mode ENUM(fza|payroll|both)
- label / description (optional)
- Overlap-Check per Constraint (date_from <= date_to) + App-seitige Prüfung
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0029"
down_revision = "0028"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS btree_gist")
# Enum erzeugen
op.execute("CREATE TYPE assignment_mode AS ENUM ('fza', 'payroll', 'both')")
op.create_table(
"special_assignments",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("company_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("date_from", sa.Date, nullable=False),
sa.Column("date_to", sa.Date, nullable=False),
sa.Column("factor", sa.Numeric(5, 3), nullable=False),
sa.Column("mode", sa.Enum("fza", "payroll", "both", name="assignment_mode", create_type=False), nullable=False, server_default="both"),
sa.Column("label", sa.String(100), nullable=True),
sa.Column("description", sa.Text, nullable=True),
sa.CheckConstraint("factor > 0 AND factor <= 10", name="ck_special_assignment_factor"),
sa.CheckConstraint("date_from <= date_to", name="ck_special_assignment_dates"),
)
# Exclusion Constraint: kein überlappender Zeitraum pro User
op.execute(
"""
ALTER TABLE special_assignments
ADD CONSTRAINT special_assignments_no_overlap
EXCLUDE USING gist (
user_id WITH =,
daterange(date_from, date_to, '[]') WITH &&
)
"""
)
def downgrade() -> None:
op.drop_table("special_assignments")
op.execute("DROP TYPE IF EXISTS assignment_mode")