feat(personal): PERS-004-Lücke - Einheit/Funktion auch für Person ohne Login
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:
1 parent
c0c484cf8a
commit
e03696642f
11 files changed
+242
-11
No files matched your search
@@ -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 +++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user