feat(personal): PERS-004-Lücke - Einheit/Funktion auch für Person ohne Login
CI / backend-tests (push) Successful in 3m20s
CI / frontend-build (push) Successful in 21s

Einsatzfunktion/Einheit gab es bisher nur an Benutzer - eine Person ohne
Systemzugang (Jugendgruppe, externe Helfer) konnte keiner Einheit/Funktion
zugeordnet werden. Migration 0055 ergänzt beide Felder an Person, Admin-Tab
"Personen" bekommt dieselben zwei Auswahlfelder wie "Benutzer". Außerdem
PERS-004-DoD korrigiert (war fälschlich als "komplett neu/fehlt" markiert,
Einsatzfunktion-Konzept existierte bereits für Benutzer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDMvneg6wKfXdXvTYNd3dN
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-12 23:20:14 +02:00
1 parent c0c484cf8a
commit e03696642f
11 files changed
+242 -11

No files matched your search

+64
View File
@@ -15245,3 +15245,67 @@ Keine Commits in dieser Session.
- frontend/src/pages/admin/VerleihSection.tsx | 25 +++++++++---
---
## 2026-09-12 22:55 – 23:04 (9m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- c0c484c feat(pruefung): INSP-002 Phase 3 - Objekttyp-Intervall als Fallback
### Geänderte Dateien
- DEVLOG.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- arbeitskacheln/07_warehouse.md | 12 +++----
- arbeitskacheln/09_inspections.md | 12 +++++++
- backend/app/services/geraet_instanz.py | 13 +++++---
- backend/app/services/pruefung.py | 20 ++++++++++++
- backend/tests/test_geraet_instanz.py | 67 +++++++++++++++++++++++++++++++++++++++
---
## 2026-09-12 23:09 – 23:09 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- arbeitskacheln/07_warehouse.md | 12 +++----
- arbeitskacheln/09_inspections.md | 12 +++++++
- backend/app/services/geraet_instanz.py | 13 +++++---
- backend/app/services/pruefung.py | 20 ++++++++++++
- backend/tests/test_geraet_instanz.py | 67 +++++++++++++++++++++++++++++++++++++++
---
## 2026-09-12 23:11 – 23:11 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- arbeitskacheln/07_warehouse.md | 12 +++----
- arbeitskacheln/09_inspections.md | 12 +++++++
- backend/app/services/geraet_instanz.py | 13 +++++---
- backend/app/services/pruefung.py | 20 ++++++++++++
- backend/tests/test_geraet_instanz.py | 67 +++++++++++++++++++++++++++++++++++++++
---
## 2026-09-12 23:13 – 23:13 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- arbeitskacheln/07_warehouse.md | 12 +++----
- arbeitskacheln/09_inspections.md | 12 +++++++
- backend/app/services/geraet_instanz.py | 13 +++++---
- backend/app/services/pruefung.py | 20 ++++++++++++
- backend/tests/test_geraet_instanz.py | 67 +++++++++++++++++++++++++++++++++++++++
---
+16 -4
View File
@@ -91,10 +91,22 @@ Details zu PERS-001 … PERS-007 (vollständig).
- **Audit:** Änderung geloggt
- **Akzeptanzkriterien:** Person mit Funktion in Einheit zuordnen.
- **Tests:** CRUD-Test.
- **DoD:** komplett neu in MABEA — Einheit-Zuordnung existiert (`Benutzer.einheit_id`),
aber keine fachliche Funktion/Rolle INNERHALB der Einheit (nur System-Rollen wie
„Materialverantwortlicher" existieren — das ist etwas anderes als „Zugführer von
Zug 3").
- **DoD:** ✅ war bereits umgesetzt, DoD-Text war veraltet (Fund 2026-09-12,
gleicher Fall wie WH-006). `Einsatzfunktion` (Stammdaten, `app/models/
personal.py`) + `Benutzer.einsatzfunktion_id` (nicht `funktion`/
`einheit_mitgliedschaft` wie ursprünglich skizziert — einfachere
Einzelfeld-Modellierung statt M:N, siehe Docstring: eine Person hat i.d.R.
genau eine primäre Funktion, keine Historie nötig). CRUD unter
`/einsatzfunktionen`, Zuordnung im Admin-Tab „Benutzer"
(`BenutzerSection.tsx`, zeigt „Name – Einheit – Einsatzfunktion").
**Lücke geschlossen (2026-09-12):** `einheit_id`/`einsatzfunktion_id`
gab es bisher NUR an `Benutzer` — eine `Person` OHNE Login (Jugendgruppe,
externe Helfer, siehe PERS-001) konnte keiner Einheit/Funktion zugeordnet
werden. Migration `0055_person_einheit_funktion.py` ergänzt beide Felder
an `Person`, Admin-Tab „Personen" (`PersonSection.tsx`) hat jetzt dieselben
zwei Auswahlfelder wie „Benutzer". Tests:
`test_person_ohne_login_einheit_und_funktion_zuordnen`
(`backend/tests/test_personal.py`).
## PERS-005 — Qualifikationen/Lehrgänge
@@ -0,0 +1,28 @@
"""PERS-004: Person (Login-lose Helfer) kann jetzt einer Einheit und
Einsatzfunktion zugeordnet werden - bisher gab es das nur an Benutzer.
Revision ID: 0055_person_einheit_funktion
Revises: 0054_ressourcenbuchung_optin
Create Date: 2026-09-12
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0055_person_einheit_funktion"
down_revision: Union[str, None] = "0054_ressourcenbuchung_optin"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TABLE person ADD COLUMN einheit_id INTEGER REFERENCES einheit(id)")
op.execute(
"ALTER TABLE person ADD COLUMN einsatzfunktion_id INTEGER REFERENCES einsatzfunktion(id)"
)
def downgrade() -> None:
op.execute("ALTER TABLE person DROP COLUMN einsatzfunktion_id")
op.execute("ALTER TABLE person DROP COLUMN einheit_id")
+10 -1
View File
@@ -57,7 +57,12 @@ async def erstelle_person_endpunkt(
payload: PersonCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only)
) -> Person:
return await erstelle_person(
db, name=payload.name, geburtsdatum=payload.geburtsdatum, kontakt=payload.kontakt
db,
name=payload.name,
geburtsdatum=payload.geburtsdatum,
kontakt=payload.kontakt,
einheit_id=payload.einheit_id,
einsatzfunktion_id=payload.einsatzfunktion_id,
)
@@ -74,6 +79,10 @@ async def aendere_person(
person.geburtsdatum = payload.geburtsdatum
if "kontakt" in payload.model_fields_set:
person.kontakt = payload.kontakt
if "einheit_id" in payload.model_fields_set:
person.einheit_id = payload.einheit_id
if "einsatzfunktion_id" in payload.model_fields_set:
person.einsatzfunktion_id = payload.einsatzfunktion_id
await db.flush()
return person
+12 -1
View File
@@ -23,7 +23,16 @@ qualifikationskategorie_pg = PgEnum(
class Person(Base):
"""PERS-001: fachliches Konzept einer Person, getrennt vom technischen Login
(Benutzer). Kann ohne Benutzerkonto existieren (z.B. Jugendgruppe, externe
Helfer ohne Systemzugang) - Benutzer.person_id verweist optional hierher."""
Helfer ohne Systemzugang) - Benutzer.person_id verweist optional hierher.
PERS-004-Lücke geschlossen (2026-09-12): einheit_id/einsatzfunktion_id
gab es bisher NUR an Benutzer - eine Person ohne Login (Jugendgruppe,
externe Helfer) konnte damit keiner Einheit/Funktion zugeordnet werden,
"wer ist Zugführer von Zug 3" war für sie unbeantwortbar. Bewusst
dieselbe einfache Einzelfeld-Modellierung wie bei Benutzer (siehe
Einsatzfunktion-Docstring) - keine getrennte Historie/Zeitraum-Logik,
Konsistenz zwischen beiden Entitäten wichtiger als Symmetrie zu
vermeiden."""
__tablename__ = "person"
@@ -31,6 +40,8 @@ class Person(Base):
name: Mapped[str] = mapped_column(String, nullable=False)
geburtsdatum: Mapped[date | None] = mapped_column(Date)
kontakt: Mapped[str | None] = mapped_column(String)
einheit_id: Mapped[int | None] = mapped_column(ForeignKey("einheit.id"))
einsatzfunktion_id: Mapped[int | None] = mapped_column(ForeignKey("einsatzfunktion.id"))
class Einheit(Base):
+6
View File
@@ -10,6 +10,8 @@ class PersonCreate(BaseModel):
name: str
geburtsdatum: date | None = None
kontakt: str | None = None
einheit_id: int | None = None
einsatzfunktion_id: int | None = None
class PersonRead(BaseModel):
@@ -18,12 +20,16 @@ class PersonRead(BaseModel):
name: str
geburtsdatum: date | None
kontakt: str | None
einheit_id: int | None
einsatzfunktion_id: int | None
class PersonUpdate(BaseModel):
name: str | None = None
geburtsdatum: date | None = None
kontakt: str | None = None
einheit_id: int | None = None
einsatzfunktion_id: int | None = None
class EinheitCreate(BaseModel):
+16 -2
View File
@@ -12,8 +12,22 @@ from app.models.personal import (
)
async def erstelle_person(db: AsyncSession, *, name: str, geburtsdatum, kontakt: str | None) -> Person:
person = Person(name=name, geburtsdatum=geburtsdatum, kontakt=kontakt)
async def erstelle_person(
db: AsyncSession,
*,
name: str,
geburtsdatum,
kontakt: str | None,
einheit_id: int | None = None,
einsatzfunktion_id: int | None = None,
) -> Person:
person = Person(
name=name,
geburtsdatum=geburtsdatum,
kontakt=kontakt,
einheit_id=einheit_id,
einsatzfunktion_id=einsatzfunktion_id,
)
db.add(person)
await db.flush()
return person
+33
View File
@@ -266,6 +266,39 @@ async def test_person_ohne_benutzer_anlegbar(client, admin_user):
assert geaendert.json()["kontakt"] == "Erziehungsberechtigter: 0170..."
@pytest.mark.asyncio
async def test_person_ohne_login_einheit_und_funktion_zuordnen(client, admin_user):
"""PERS-004-Lücke: Person OHNE Benutzerkonto (Jugendgruppe/externe Helfer)
konnte bisher keiner Einheit/Einsatzfunktion zugeordnet werden - nur
Benutzer hatte diese Felder."""
token = await login(client, "admin1")
einheit = await client.post("/api/v1/einheiten", json={"name": "Jugendgruppe"}, headers=auth_header(token))
einheit_id = einheit.json()["id"]
funktion = await client.post(
"/api/v1/einsatzfunktionen", json={"name": "Jugendgruppenleiter"}, headers=auth_header(token)
)
funktion_id = funktion.json()["id"]
person = await client.post(
"/api/v1/personen",
json={"name": "Erika Musterfrau", "einheit_id": einheit_id, "einsatzfunktion_id": funktion_id},
headers=auth_header(token),
)
assert person.status_code == 201
assert person.json()["einheit_id"] == einheit_id
assert person.json()["einsatzfunktion_id"] == funktion_id
person_id = person.json()["id"]
geloest = await client.patch(
f"/api/v1/personen/{person_id}",
json={"einheit_id": None, "einsatzfunktion_id": None},
headers=auth_header(token),
)
assert geloest.json()["einheit_id"] is None
assert geloest.json()["einsatzfunktion_id"] is None
@pytest.mark.asyncio
async def test_mitarbeiter_darf_person_nicht_anlegen(client, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
+2
View File
@@ -529,6 +529,8 @@ export interface Person {
name: string;
geburtsdatum: string | null;
kontakt: string | null;
einheit_id: number | null;
einsatzfunktion_id: number | null;
}
export interface Einsatzfunktion {
+7 -1
View File
@@ -369,7 +369,13 @@ export function AdminPage() {
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
<>
<PersonSection personen={personen} onGeaendert={ladeAlles} onFehler={setFehler} />
<PersonSection
personen={personen}
einheiten={einheiten}
einsatzfunktionen={einsatzfunktionen}
onGeaendert={ladeAlles}
onFehler={setFehler}
/>
<EinheitSection einheiten={einheiten} standorte={standorte} onGeaendert={ladeAlles} onFehler={setFehler} />
<QualifikationSection
qualifikationstypen={qualifikationstypen}
+48 -2
View File
@@ -1,15 +1,17 @@
import { useState } from "react";
import { apiRequest } from "../../api/client";
import type { Person } from "../../api/types";
import type { Einheit, Einsatzfunktion, Person } from "../../api/types";
interface Props {
personen: Person[];
einheiten: Einheit[];
einsatzfunktionen: Einsatzfunktion[];
onGeaendert: () => Promise<void>;
onFehler: (text: string) => void;
}
export function PersonSection({ personen, onGeaendert, onFehler }: Props) {
export function PersonSection({ personen, einheiten, einsatzfunktionen, onGeaendert, onFehler }: Props) {
const [name, setName] = useState("");
const [geburtsdatum, setGeburtsdatum] = useState("");
const [kontakt, setKontakt] = useState("");
@@ -18,6 +20,18 @@ export function PersonSection({ personen, onGeaendert, onFehler }: Props) {
const [bearbeitungsName, setBearbeitungsName] = useState("");
const [bearbeitungsGeburtsdatum, setBearbeitungsGeburtsdatum] = useState("");
const [bearbeitungsKontakt, setBearbeitungsKontakt] = useState("");
const [bearbeitungsEinheitId, setBearbeitungsEinheitId] = useState<number | "">("");
const [bearbeitungsEinsatzfunktionId, setBearbeitungsEinsatzfunktionId] = useState<number | "">("");
function einheitName(id: number | null): string {
if (id === null) return "–";
return einheiten.find((e) => e.id === id)?.name ?? `#${id}`;
}
function einsatzfunktionName(id: number | null): string {
if (id === null) return "–";
return einsatzfunktionen.find((f) => f.id === id)?.name ?? `#${id}`;
}
async function anlegen() {
setWirdAngelegt(true);
@@ -42,6 +56,8 @@ export function PersonSection({ personen, onGeaendert, onFehler }: Props) {
setBearbeitungsName(p.name);
setBearbeitungsGeburtsdatum(p.geburtsdatum ?? "");
setBearbeitungsKontakt(p.kontakt ?? "");
setBearbeitungsEinheitId(p.einheit_id ?? "");
setBearbeitungsEinsatzfunktionId(p.einsatzfunktion_id ?? "");
}
async function speichern(id: number) {
@@ -52,6 +68,8 @@ export function PersonSection({ personen, onGeaendert, onFehler }: Props) {
name: bearbeitungsName,
geburtsdatum: bearbeitungsGeburtsdatum || null,
kontakt: bearbeitungsKontakt || null,
einheit_id: bearbeitungsEinheitId === "" ? null : bearbeitungsEinheitId,
einsatzfunktion_id: bearbeitungsEinsatzfunktionId === "" ? null : bearbeitungsEinsatzfunktionId,
},
});
setBearbeiteteId(null);
@@ -114,6 +132,30 @@ export function PersonSection({ personen, onGeaendert, onFehler }: Props) {
onChange={(e) => setBearbeitungsKontakt(e.target.value)}
placeholder="Kontakt"
/>
<select
className="input"
value={bearbeitungsEinheitId}
onChange={(e) => setBearbeitungsEinheitId(Number(e.target.value) || "")}
>
<option value="">Keine Einheit</option>
{einheiten.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
<select
className="input"
value={bearbeitungsEinsatzfunktionId}
onChange={(e) => setBearbeitungsEinsatzfunktionId(Number(e.target.value) || "")}
>
<option value="">Keine Funktion</option>
{einsatzfunktionen.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
<button className="btn btn-primary" onClick={() => speichern(p.id)}>
Speichern
</button>
@@ -127,6 +169,10 @@ export function PersonSection({ personen, onGeaendert, onFehler }: Props) {
{p.name}
{p.geburtsdatum && <span className="text-muted"> · geb. {p.geburtsdatum}</span>}
{p.kontakt && <span className="text-muted"> · {p.kontakt}</span>}
{p.einheit_id !== null && <span className="text-muted"> · {einheitName(p.einheit_id)}</span>}
{p.einsatzfunktion_id !== null && (
<span className="text-muted"> · {einsatzfunktionName(p.einsatzfunktion_id)}</span>
)}
</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(p)}>
Bearbeiten