feat(identity): IDENT-003 konfigurierbare Nummernschemata je Objekttyp
Neue Stammdaten-Tabelle nummernschema (Migration 0032): Präfix + Stellenzahl einmal je Objekttyp festlegen statt bei jeder Objekt-Anlage manuell einzutippen (Fehlerquelle - Tippfehler, uneinheitliche Schreibweise, falsches Präfix für den Typ). GET /objekte/naechster-code akzeptiert jetzt objekttyp_id als Alternative zu praefix; ohne konfiguriertes Schema bleibt der bisherige manuelle Präfix-Weg nutzbar (Rückwärtskompatibilität). Admin-UI: Nummernschema-Pflege direkt in der Objekttyp-Verwaltung (ObjekttypSection). ObjektAnlegenFormular versucht zuerst das Schema des gewählten Objekttyps, fällt bei 404 auf manuelles Präfix zurück. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -135,7 +135,7 @@ vergeben statt geraten.
|
|||||||
| FOUND-012 | Foundation | Dokumentationsgerüst | P1 | XS | LOW | FOUND-001 | ✅ (`README.md` im Projektwurzelverzeichnis) |
|
| FOUND-012 | Foundation | Dokumentationsgerüst | P1 | XS | LOW | FOUND-001 | ✅ (`README.md` im Projektwurzelverzeichnis) |
|
||||||
| IDENT-001 | Identity | Objekt-ID-Schema | P0 | S | LOW | FOUND-002 | ✅ (Objekt-Code-Vergabe, `/objekte/naechster-code`) |
|
| IDENT-001 | Identity | Objekt-ID-Schema | P0 | S | LOW | FOUND-002 | ✅ (Objekt-Code-Vergabe, `/objekte/naechster-code`) |
|
||||||
| IDENT-002 | Identity | Inventarnummer manuell | P0 | S | LOW | IDENT-001 | ✅ (Objektposition-Code, `objektposition.py`) |
|
| IDENT-002 | Identity | Inventarnummer manuell | P0 | S | LOW | IDENT-001 | ✅ (Objektposition-Code, `objektposition.py`) |
|
||||||
| IDENT-003 | Identity | Inventarnummer-Nummernschemata | P1 | M | MEDIUM | IDENT-002 | ⬜ (kein konfigurierbares Schema gefunden, nur fester Code-Zähler) |
|
| IDENT-003 | Identity | Inventarnummer-Nummernschemata | P1 | M | MEDIUM | IDENT-002 | ✅ (`Nummernschema` je Objekttyp, Migration 0032, `/objekte/naechster-code?objekttyp_id=` nutzt es, Admin-UI in ObjekttypSection) |
|
||||||
| IDENT-004 | Identity | Seriennummer + Eindeutigkeitsprüfung | P0 | S | LOW | IDENT-001 | ✅ (`geraet_instanz.py` Seriennummer-Feld/Unique) |
|
| IDENT-004 | Identity | Seriennummer + Eindeutigkeitsprüfung | P0 | S | LOW | IDENT-001 | ✅ (`geraet_instanz.py` Seriennummer-Feld/Unique) |
|
||||||
| IDENT-005 | Identity | Hersteller/Modell-Stammdaten | P1 | S | LOW | IDENT-004 | ✅ (`Hersteller`/`Modell`-Tabellen, Migration 0030, `geraet_instanz.modell_id` optional; kein eigenes Admin-UI, nur API) |
|
| IDENT-005 | Identity | Hersteller/Modell-Stammdaten | P1 | S | LOW | IDENT-004 | ✅ (`Hersteller`/`Modell`-Tabellen, Migration 0030, `geraet_instanz.modell_id` optional; kein eigenes Admin-UI, nur API) |
|
||||||
| IDENT-006 | Identity | QR-Code-Erzeugung | P0 | S | LOW | IDENT-001 | ✅ (`label.pdf`-Endpunkte für Objekt/Objektposition) |
|
| IDENT-006 | Identity | QR-Code-Erzeugung | P0 | S | LOW | IDENT-001 | ✅ (`label.pdf`-Endpunkte für Objekt/Objektposition) |
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""IDENT-003: Nummernschema je Objekttyp (Präfix + Stellenzahl konfigurierbar).
|
||||||
|
|
||||||
|
Revision ID: 0032_nummernschema
|
||||||
|
Revises: 0031_einsatzfunktion
|
||||||
|
Create Date: 2026-09-08
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0032_nummernschema"
|
||||||
|
down_revision: Union[str, None] = "0031_einsatzfunktion"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE nummernschema (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
objekttyp_id INTEGER NOT NULL UNIQUE REFERENCES objekttyp(id),
|
||||||
|
praefix TEXT NOT NULL,
|
||||||
|
stellenzahl INTEGER NOT NULL DEFAULT 5
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE nummernschema")
|
||||||
@@ -12,7 +12,7 @@ from app.models.auth import KnotenTyp, RolleTyp, Systemknoten
|
|||||||
from app.models.fahrzeugdetails import Fahrzeugdetails
|
from app.models.fahrzeugdetails import Fahrzeugdetails
|
||||||
from app.models.objekt import Objekt
|
from app.models.objekt import Objekt
|
||||||
from app.models.objektposition import Objektposition
|
from app.models.objektposition import Objektposition
|
||||||
from app.models.stammdaten import Material, Objekttyp
|
from app.models.stammdaten import Material, Nummernschema, Objekttyp
|
||||||
from app.models.vorlage import Beladungsvorlage
|
from app.models.vorlage import Beladungsvorlage
|
||||||
from app.services import historie as historie_service
|
from app.services import historie as historie_service
|
||||||
from app.services.label import generiere_code128_label_pdf, generiere_qr_label_pdf
|
from app.services.label import generiere_code128_label_pdf, generiere_qr_label_pdf
|
||||||
@@ -105,11 +105,32 @@ async def hole_objekt_per_code(
|
|||||||
|
|
||||||
@router.get("/objekte/naechster-code")
|
@router.get("/objekte/naechster-code")
|
||||||
async def naechster_objekt_code(
|
async def naechster_objekt_code(
|
||||||
praefix: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
praefix: str | None = None,
|
||||||
|
objekttyp_id: int | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_=Depends(get_current_user),
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Code-Vorschlag beim Anlegen/Duplizieren (Konzept-Ergänzung Karte 10/Prompt 09).
|
"""Code-Vorschlag beim Anlegen/Duplizieren (Konzept-Ergänzung Karte 10/Prompt 09).
|
||||||
Vorschlag ist überschreibbar, Eindeutigkeit erzwingt weiterhin objekt.code UNIQUE."""
|
Vorschlag ist überschreibbar, Eindeutigkeit erzwingt weiterhin objekt.code UNIQUE.
|
||||||
return {"code": await naechster_freier_code(db, Objekt.code, praefix)}
|
|
||||||
|
IDENT-003: `objekttyp_id` statt `praefix` nutzt das konfigurierte
|
||||||
|
Nummernschema (Präfix + Mindest-Stellenzahl) - kein manuelles Eintippen/
|
||||||
|
Merken des richtigen Präfix je Objekttyp mehr nötig. `praefix` bleibt für
|
||||||
|
Objekttypen ohne konfiguriertes Schema nutzbar (Rückwärtskompatibilität)."""
|
||||||
|
stellenzahl = 5
|
||||||
|
if objekttyp_id is not None:
|
||||||
|
result = await db.execute(select(Nummernschema).where(Nummernschema.objekttyp_id == objekttyp_id))
|
||||||
|
schema = result.scalar_one_or_none()
|
||||||
|
if schema is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Kein Nummernschema für diesen Objekttyp konfiguriert - Präfix manuell angeben",
|
||||||
|
)
|
||||||
|
praefix = schema.praefix
|
||||||
|
stellenzahl = schema.stellenzahl
|
||||||
|
if not praefix:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Präfix oder Objekttyp angeben")
|
||||||
|
return {"code": await naechster_freier_code(db, Objekt.code, praefix, stellenzahl)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/objektpositionen/naechster-code")
|
@router.get("/objektpositionen/naechster-code")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from app.api.deps import get_current_user, require_roles, require_roles_or_permi
|
|||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.auth import RolleTyp
|
from app.models.auth import RolleTyp
|
||||||
from app.models.objekt import Objekt
|
from app.models.objekt import Objekt
|
||||||
from app.models.stammdaten import Bereich, Fach, Hersteller, Kategorie, Material, Modell, Objekttyp, Standort
|
from app.models.stammdaten import Bereich, Fach, Hersteller, Kategorie, Material, Modell, Nummernschema, Objekttyp, Standort
|
||||||
from app.models.vorlage import Beladungsvorlage, Vorlagenposition
|
from app.models.vorlage import Beladungsvorlage, Vorlagenposition
|
||||||
from app.services.material_merge import finde_konflikte, fuehre_zusammen, hole_verwendung
|
from app.services.material_merge import finde_konflikte, fuehre_zusammen, hole_verwendung
|
||||||
from app.schemas.stammdaten import (
|
from app.schemas.stammdaten import (
|
||||||
@@ -25,6 +25,9 @@ from app.schemas.stammdaten import (
|
|||||||
KategorieUpdate,
|
KategorieUpdate,
|
||||||
ModellCreate,
|
ModellCreate,
|
||||||
ModellRead,
|
ModellRead,
|
||||||
|
NummernschemaCreate,
|
||||||
|
NummernschemaRead,
|
||||||
|
NummernschemaUpdate,
|
||||||
MaterialCreate,
|
MaterialCreate,
|
||||||
MaterialRead,
|
MaterialRead,
|
||||||
MaterialUpdate,
|
MaterialUpdate,
|
||||||
@@ -141,6 +144,53 @@ async def erstelle_modell(
|
|||||||
return modell
|
return modell
|
||||||
|
|
||||||
|
|
||||||
|
# -- Nummernschema (IDENT-003) ---------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/nummernschemata", response_model=list[NummernschemaRead])
|
||||||
|
async def liste_nummernschemata(
|
||||||
|
db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
|
||||||
|
) -> list[Nummernschema]:
|
||||||
|
result = await db.execute(select(Nummernschema))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/nummernschemata", response_model=NummernschemaRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def erstelle_nummernschema(
|
||||||
|
payload: NummernschemaCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_=Depends(require_roles(RolleTyp.administration)),
|
||||||
|
) -> Nummernschema:
|
||||||
|
if await db.get(Objekttyp, payload.objekttyp_id) is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekttyp nicht gefunden")
|
||||||
|
bestehendes = await db.execute(
|
||||||
|
select(Nummernschema).where(Nummernschema.objekttyp_id == payload.objekttyp_id)
|
||||||
|
)
|
||||||
|
if bestehendes.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Für diesen Objekttyp existiert bereits ein Nummernschema"
|
||||||
|
)
|
||||||
|
schema = Nummernschema(**payload.model_dump())
|
||||||
|
db.add(schema)
|
||||||
|
await db.flush()
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/nummernschemata/{schema_id}", response_model=NummernschemaRead)
|
||||||
|
async def aendere_nummernschema(
|
||||||
|
schema_id: int,
|
||||||
|
payload: NummernschemaUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_=Depends(require_roles(RolleTyp.administration)),
|
||||||
|
) -> Nummernschema:
|
||||||
|
schema = await db.get(Nummernschema, schema_id)
|
||||||
|
if schema is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nummernschema nicht gefunden")
|
||||||
|
for feld, wert in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(schema, feld, wert)
|
||||||
|
await db.flush()
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
# -- Kategorie ------------------------------------------------------------------
|
# -- Kategorie ------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/kategorien", response_model=list[KategorieRead])
|
@router.get("/kategorien", response_model=list[KategorieRead])
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import enum
|
import enum
|
||||||
|
|
||||||
from sqlalchemy import Boolean, ForeignKey, String, Text, UniqueConstraint
|
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.dialects.postgresql import ENUM as PgEnum
|
from sqlalchemy.dialects.postgresql import ENUM as PgEnum
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -54,6 +54,24 @@ class Objekttyp(Base):
|
|||||||
ist_zugfahrzeug: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
ist_zugfahrzeug: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Nummernschema(Base):
|
||||||
|
"""IDENT-003: statt bei jeder Objekt-Anlage das Präfix manuell einzutippen
|
||||||
|
(Fehlerquelle - Tippfehler, uneinheitliche Groß-/Kleinschreibung, falsches
|
||||||
|
Präfix für den Objekttyp) legt der Admin je Objekttyp einmal Präfix +
|
||||||
|
Stellenzahl fest. `naechster_freier_code` schlägt darauf aufbauend den
|
||||||
|
nächsten Code vor (PRAEFIX-NNN...), wie bisher, nur nicht mehr frei
|
||||||
|
eingebbar. Ein Objekttyp OHNE Schema kann weiterhin mit explizitem Präfix
|
||||||
|
angelegt werden (Rückwärtskompatibilität, kein Zwang zur Migration
|
||||||
|
bestehender Objekte)."""
|
||||||
|
|
||||||
|
__tablename__ = "nummernschema"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
objekttyp_id: Mapped[int] = mapped_column(ForeignKey("objekttyp.id"), unique=True, nullable=False)
|
||||||
|
praefix: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
stellenzahl: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
|
||||||
|
|
||||||
|
|
||||||
class Fach(Base):
|
class Fach(Base):
|
||||||
"""Nutzer-Vorgabe: feste Fächer-Liste je Objekttyp, damit beim Anlegen einer
|
"""Nutzer-Vorgabe: feste Fächer-Liste je Objekttyp, damit beim Anlegen einer
|
||||||
Vorlagenposition nicht jedes Mal Freitext getippt werden muss. `sortierung`
|
Vorlagenposition nicht jedes Mal Freitext getippt werden muss. `sortierung`
|
||||||
|
|||||||
@@ -54,6 +54,22 @@ class ModellRead(ModellCreate):
|
|||||||
id: int
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
class NummernschemaCreate(BaseModel):
|
||||||
|
objekttyp_id: int
|
||||||
|
praefix: str
|
||||||
|
stellenzahl: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
class NummernschemaRead(NummernschemaCreate):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
class NummernschemaUpdate(BaseModel):
|
||||||
|
praefix: str | None = None
|
||||||
|
stellenzahl: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class StandortCreate(BaseModel):
|
class StandortCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
adresse: str | None = None
|
adresse: str | None = None
|
||||||
|
|||||||
@@ -13,14 +13,16 @@ from app.models.vorlage import Beladungsvorlage, Vorlagenposition
|
|||||||
_CODE_MUSTER = re.compile(r"^-(\d+)$")
|
_CODE_MUSTER = re.compile(r"^-(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
async def naechster_freier_code(db: AsyncSession, spalte, praefix: str) -> str:
|
async def naechster_freier_code(db: AsyncSession, spalte, praefix: str, mindestbreite: int = 5) -> str:
|
||||||
"""Konzept-Ergänzung Karte 10/Prompt 09: Code-Vorschlag PRAEFIX-NNN mit
|
"""Konzept-Ergänzung Karte 10/Prompt 09: Code-Vorschlag PRAEFIX-NNN mit
|
||||||
fortlaufender Nummer je Präfix. Zahlenbreite folgt der bisher größten
|
fortlaufender Nummer je Präfix. Zahlenbreite folgt der bisher größten
|
||||||
vergebenen Nummer (mind. 5 Stellen bei Erstvergabe), damit die Sortierung im
|
vergebenen Nummer (mindestens `mindestbreite` bei Erstvergabe - IDENT-003:
|
||||||
Regal stabil bleibt, solange nicht mehr als 10^Breite Objekte existieren."""
|
kommt aus dem konfigurierten Nummernschema, falls vorhanden, sonst 5 wie
|
||||||
|
bisher), damit die Sortierung im Regal stabil bleibt, solange nicht mehr
|
||||||
|
als 10^Breite Objekte existieren."""
|
||||||
result = await db.execute(select(spalte).where(spalte.like(f"{praefix}-%")))
|
result = await db.execute(select(spalte).where(spalte.like(f"{praefix}-%")))
|
||||||
hoechste = 0
|
hoechste = 0
|
||||||
breite = 5
|
breite = mindestbreite
|
||||||
for (code,) in result.all():
|
for (code,) in result.all():
|
||||||
treffer = _CODE_MUSTER.match(code[len(praefix) :])
|
treffer = _CODE_MUSTER.match(code[len(praefix) :])
|
||||||
if treffer is None:
|
if treffer is None:
|
||||||
|
|||||||
@@ -262,6 +262,71 @@ async def test_naechster_objekt_code_zaehlt_hoch(
|
|||||||
assert response.json()["code"] == "RK-00018"
|
assert response.json()["code"] == "RK-00018"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nummernschema_liefert_code_ohne_manuelles_praefix(
|
||||||
|
client, objekttyp_factory, admin_user
|
||||||
|
):
|
||||||
|
"""IDENT-003: Präfix + Stellenzahl kommen aus dem konfigurierten Schema,
|
||||||
|
nicht mehr manuell eingegeben."""
|
||||||
|
objekttyp = await objekttyp_factory("Typ-Nummernschema")
|
||||||
|
token = await login(client, "admin1")
|
||||||
|
|
||||||
|
schema = await client.post(
|
||||||
|
"/api/v1/nummernschemata",
|
||||||
|
json={"objekttyp_id": objekttyp.id, "praefix": "SEG", "stellenzahl": 3},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert schema.status_code == 201
|
||||||
|
|
||||||
|
duplikat = await client.post(
|
||||||
|
"/api/v1/nummernschemata",
|
||||||
|
json={"objekttyp_id": objekttyp.id, "praefix": "ANDERES"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert duplikat.status_code == 409
|
||||||
|
|
||||||
|
vorschlag = await client.get(
|
||||||
|
f"/api/v1/objekte/naechster-code?objekttyp_id={objekttyp.id}", headers=auth_header(token)
|
||||||
|
)
|
||||||
|
assert vorschlag.status_code == 200
|
||||||
|
assert vorschlag.json()["code"] == "SEG-001"
|
||||||
|
|
||||||
|
aenderung = await client.patch(
|
||||||
|
f"/api/v1/nummernschemata/{schema.json()['id']}",
|
||||||
|
json={"stellenzahl": 4},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert aenderung.json()["stellenzahl"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nummernschema_ohne_konfiguration_verlangt_praefix(
|
||||||
|
client, objekttyp_factory, admin_user
|
||||||
|
):
|
||||||
|
objekttyp = await objekttyp_factory("Typ-Ohne-Schema")
|
||||||
|
token = await login(client, "admin1")
|
||||||
|
|
||||||
|
ohne_schema = await client.get(
|
||||||
|
f"/api/v1/objekte/naechster-code?objekttyp_id={objekttyp.id}", headers=auth_header(token)
|
||||||
|
)
|
||||||
|
assert ohne_schema.status_code == 404
|
||||||
|
|
||||||
|
ohne_beides = await client.get("/api/v1/objekte/naechster-code", headers=auth_header(token))
|
||||||
|
assert ohne_beides.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mitarbeiter_darf_nummernschema_nicht_anlegen(client, objekttyp_factory, mitarbeiter_user):
|
||||||
|
objekttyp = await objekttyp_factory("Typ-Nummernschema-Verboten")
|
||||||
|
token = await login(client, "mitarbeiter1")
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/nummernschemata",
|
||||||
|
json={"objekttyp_id": objekttyp.id, "praefix": "X"},
|
||||||
|
headers=auth_header(token),
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_naechster_objektposition_code(
|
async def test_naechster_objektposition_code(
|
||||||
client, db_session, objekt_mit_position, materialverantwortlicher_user
|
client, db_session, objekt_mit_position, materialverantwortlicher_user
|
||||||
|
|||||||
@@ -302,6 +302,13 @@ export interface Objekttyp {
|
|||||||
ist_zugfahrzeug: boolean;
|
ist_zugfahrzeug: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Nummernschema {
|
||||||
|
id: number;
|
||||||
|
objekttyp_id: number;
|
||||||
|
praefix: string;
|
||||||
|
stellenzahl: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Fach {
|
export interface Fach {
|
||||||
id: number;
|
id: number;
|
||||||
objekttyp_id: number;
|
objekttyp_id: number;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useSearchParams } from "react-router-dom";
|
|||||||
import { apiRequest } from "../api/client";
|
import { apiRequest } from "../api/client";
|
||||||
import { AusgabeSection } from "./admin/AusgabeSection";
|
import { AusgabeSection } from "./admin/AusgabeSection";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Einsatzfunktion, Fach, Kategorie, Material, Objekt, Objekttyp, Qualifikationstyp, Standort } from "../api/types";
|
import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Einsatzfunktion, Fach, Kategorie, Material, Nummernschema, Objekt, Objekttyp, Qualifikationstyp, Standort } from "../api/types";
|
||||||
import { BenutzerSection } from "./admin/BenutzerSection";
|
import { BenutzerSection } from "./admin/BenutzerSection";
|
||||||
import { BereichSection } from "./admin/BereichSection";
|
import { BereichSection } from "./admin/BereichSection";
|
||||||
import { EinheitSection } from "./admin/EinheitSection";
|
import { EinheitSection } from "./admin/EinheitSection";
|
||||||
@@ -109,6 +109,7 @@ export function AdminPage() {
|
|||||||
const [faecher, setFaecher] = useState<Fach[]>([]);
|
const [faecher, setFaecher] = useState<Fach[]>([]);
|
||||||
const [einheiten, setEinheiten] = useState<Einheit[]>([]);
|
const [einheiten, setEinheiten] = useState<Einheit[]>([]);
|
||||||
const [einsatzfunktionen, setEinsatzfunktionen] = useState<Einsatzfunktion[]>([]);
|
const [einsatzfunktionen, setEinsatzfunktionen] = useState<Einsatzfunktion[]>([]);
|
||||||
|
const [nummernschemata, setNummernschemata] = useState<Nummernschema[]>([]);
|
||||||
const [qualifikationstypen, setQualifikationstypen] = useState<Qualifikationstyp[]>([]);
|
const [qualifikationstypen, setQualifikationstypen] = useState<Qualifikationstyp[]>([]);
|
||||||
const [fehler, setFehler] = useState<string | null>(null);
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
const [laedt, setLaedt] = useState(true);
|
const [laedt, setLaedt] = useState(true);
|
||||||
@@ -128,7 +129,7 @@ export function AdminPage() {
|
|||||||
// Seite als "nicht geladen" zeigen, obwohl deren Tabs (z.B. Kontroll-
|
// Seite als "nicht geladen" zeigen, obwohl deren Tabs (z.B. Kontroll-
|
||||||
// verantwortung) trotzdem funktionieren sollen. allSettled + leerer
|
// verantwortung) trotzdem funktionieren sollen. allSettled + leerer
|
||||||
// Fallback statt hartem Fehler.
|
// Fallback statt hartem Fehler.
|
||||||
const [be, ka, ot, s, v, o, m, b, f, ei, qt, ef] = await Promise.allSettled([
|
const [be, ka, ot, s, v, o, m, b, f, ei, qt, ef, ns] = await Promise.allSettled([
|
||||||
apiRequest<Bereich[]>("/bereiche"),
|
apiRequest<Bereich[]>("/bereiche"),
|
||||||
apiRequest<Kategorie[]>("/kategorien"),
|
apiRequest<Kategorie[]>("/kategorien"),
|
||||||
apiRequest<Objekttyp[]>("/objekttypen"),
|
apiRequest<Objekttyp[]>("/objekttypen"),
|
||||||
@@ -141,6 +142,7 @@ export function AdminPage() {
|
|||||||
apiRequest<Einheit[]>("/einheiten"),
|
apiRequest<Einheit[]>("/einheiten"),
|
||||||
apiRequest<Qualifikationstyp[]>("/qualifikationstypen"),
|
apiRequest<Qualifikationstyp[]>("/qualifikationstypen"),
|
||||||
apiRequest<Einsatzfunktion[]>("/einsatzfunktionen"),
|
apiRequest<Einsatzfunktion[]>("/einsatzfunktionen"),
|
||||||
|
apiRequest<Nummernschema[]>("/nummernschemata"),
|
||||||
]);
|
]);
|
||||||
const wert = <T,>(r: PromiseSettledResult<T[]>): T[] => (r.status === "fulfilled" ? r.value : []);
|
const wert = <T,>(r: PromiseSettledResult<T[]>): T[] => (r.status === "fulfilled" ? r.value : []);
|
||||||
setBereiche(wert(be));
|
setBereiche(wert(be));
|
||||||
@@ -155,6 +157,7 @@ export function AdminPage() {
|
|||||||
setEinheiten(wert(ei));
|
setEinheiten(wert(ei));
|
||||||
setQualifikationstypen(wert(qt));
|
setQualifikationstypen(wert(qt));
|
||||||
setEinsatzfunktionen(wert(ef));
|
setEinsatzfunktionen(wert(ef));
|
||||||
|
setNummernschemata(wert(ns));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -226,6 +229,7 @@ export function AdminPage() {
|
|||||||
bereiche={bereiche}
|
bereiche={bereiche}
|
||||||
kategorien={kategorien}
|
kategorien={kategorien}
|
||||||
objekttypen={objekttypen}
|
objekttypen={objekttypen}
|
||||||
|
nummernschemata={nummernschemata}
|
||||||
onGeaendert={ladeAlles}
|
onGeaendert={ladeAlles}
|
||||||
onFehler={setFehler}
|
onFehler={setFehler}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { apiRequest } from "../../api/client";
|
import { apiRequest, ApiError } from "../../api/client";
|
||||||
import type { Beladungsvorlage, Objekt, Objekttyp, Standort } from "../../api/types";
|
import type { Beladungsvorlage, Objekt, Objekttyp, Standort } from "../../api/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,8 +30,27 @@ export function ObjektAnlegenFormular({ objekte, objekttypen, vorlagen, standort
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function codeVorschlagen() {
|
async function codeVorschlagen() {
|
||||||
|
const objekttypId = vorlagen.find((v) => v.id === vorlageId)?.objekttyp_id;
|
||||||
|
// IDENT-003: erst versuchen, das für den Objekttyp konfigurierte
|
||||||
|
// Nummernschema zu nutzen (kein manuelles Präfix nötig) - nur wenn keins
|
||||||
|
// konfiguriert ist (404), auf das Präfix-Feld zurückfallen.
|
||||||
|
if (objekttypId !== undefined) {
|
||||||
|
try {
|
||||||
|
const antwort = await apiRequest<{ code: string }>(
|
||||||
|
`/objekte/naechster-code?objekttyp_id=${objekttypId}`
|
||||||
|
);
|
||||||
|
setCode(antwort.code);
|
||||||
|
return;
|
||||||
|
} catch (fehler) {
|
||||||
|
if (!(fehler instanceof ApiError && fehler.status === 404)) {
|
||||||
|
onFehler("Code-Vorschlag fehlgeschlagen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// kein Nummernschema für diesen Objekttyp -> weiter mit manuellem Präfix
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!praefix) {
|
if (!praefix) {
|
||||||
onFehler("Präfix eingeben (z. B. RK, FZ).");
|
onFehler("Kein Nummernschema für diesen Objekttyp konfiguriert - Präfix eingeben (z. B. RK, FZ).");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { apiRequest } from "../../api/client";
|
import { apiRequest } from "../../api/client";
|
||||||
import type { Bereich, Kategorie, Objekttyp } from "../../api/types";
|
import type { Bereich, Kategorie, Nummernschema, Objekttyp } from "../../api/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bereiche: Bereich[];
|
bereiche: Bereich[];
|
||||||
kategorien: Kategorie[];
|
kategorien: Kategorie[];
|
||||||
objekttypen: Objekttyp[];
|
objekttypen: Objekttyp[];
|
||||||
|
nummernschemata: Nummernschema[];
|
||||||
onGeaendert: () => Promise<void>;
|
onGeaendert: () => Promise<void>;
|
||||||
onFehler: (text: string) => void;
|
onFehler: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaendert, onFehler }: Props) {
|
export function ObjekttypSection({
|
||||||
|
bereiche,
|
||||||
|
kategorien,
|
||||||
|
objekttypen,
|
||||||
|
nummernschemata,
|
||||||
|
onGeaendert,
|
||||||
|
onFehler,
|
||||||
|
}: Props) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [bereichId, setBereichId] = useState<number | "">("");
|
const [bereichId, setBereichId] = useState<number | "">("");
|
||||||
const [kategorieId, setKategorieId] = useState<number | "">("");
|
const [kategorieId, setKategorieId] = useState<number | "">("");
|
||||||
@@ -20,6 +28,13 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
|
|||||||
const [bearbeitungsName, setBearbeitungsName] = useState("");
|
const [bearbeitungsName, setBearbeitungsName] = useState("");
|
||||||
const [istZugfahrzeug, setIstZugfahrzeug] = useState(false);
|
const [istZugfahrzeug, setIstZugfahrzeug] = useState(false);
|
||||||
const [bearbeitungsZugfahrzeug, setBearbeitungsZugfahrzeug] = useState(false);
|
const [bearbeitungsZugfahrzeug, setBearbeitungsZugfahrzeug] = useState(false);
|
||||||
|
const [schemaBearbeitenId, setSchemaBearbeitenId] = useState<number | null>(null);
|
||||||
|
const [schemaPraefix, setSchemaPraefix] = useState("");
|
||||||
|
const [schemaStellenzahl, setSchemaStellenzahl] = useState(5);
|
||||||
|
|
||||||
|
function schemaFuer(objekttypId: number): Nummernschema | undefined {
|
||||||
|
return nummernschemata.find((n) => n.objekttyp_id === objekttypId);
|
||||||
|
}
|
||||||
|
|
||||||
function bearbeiten(o: Objekttyp) {
|
function bearbeiten(o: Objekttyp) {
|
||||||
setBearbeiteteId(o.id);
|
setBearbeiteteId(o.id);
|
||||||
@@ -61,6 +76,38 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function schemaBearbeiten(objekttypId: number) {
|
||||||
|
const bestehendes = schemaFuer(objekttypId);
|
||||||
|
setSchemaBearbeitenId(objekttypId);
|
||||||
|
setSchemaPraefix(bestehendes?.praefix ?? "");
|
||||||
|
setSchemaStellenzahl(bestehendes?.stellenzahl ?? 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schemaSpeichern(objekttypId: number) {
|
||||||
|
if (!schemaPraefix.trim()) {
|
||||||
|
onFehler("Präfix eingeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bestehendes = schemaFuer(objekttypId);
|
||||||
|
try {
|
||||||
|
if (bestehendes) {
|
||||||
|
await apiRequest(`/nummernschemata/${bestehendes.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: { praefix: schemaPraefix.toUpperCase(), stellenzahl: schemaStellenzahl },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await apiRequest("/nummernschemata", {
|
||||||
|
method: "POST",
|
||||||
|
body: { objekttyp_id: objekttypId, praefix: schemaPraefix.toUpperCase(), stellenzahl: schemaStellenzahl },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSchemaBearbeitenId(null);
|
||||||
|
await onGeaendert();
|
||||||
|
} catch {
|
||||||
|
onFehler("Nummernschema konnte nicht gespeichert werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<h2>Objekttyp anlegen</h2>
|
<h2>Objekttyp anlegen</h2>
|
||||||
@@ -105,8 +152,11 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
|
|||||||
)}
|
)}
|
||||||
{objekttypen.length > 0 && (
|
{objekttypen.length > 0 && (
|
||||||
<ul className="card-list">
|
<ul className="card-list">
|
||||||
{objekttypen.map((o) => (
|
{objekttypen.map((o) => {
|
||||||
<li key={o.id} className="row-between" style={{ padding: "0.4rem 0" }}>
|
const schema = schemaFuer(o.id);
|
||||||
|
return (
|
||||||
|
<li key={o.id} style={{ padding: "0.4rem 0" }}>
|
||||||
|
<div className="row-between">
|
||||||
{bearbeiteteId === o.id ? (
|
{bearbeiteteId === o.id ? (
|
||||||
<div className="row" style={{ flex: 1 }}>
|
<div className="row" style={{ flex: 1 }}>
|
||||||
<input
|
<input
|
||||||
@@ -140,8 +190,52 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ alignItems: "center", gap: "0.4rem", marginTop: "0.25rem" }}>
|
||||||
|
{schemaBearbeitenId === o.id ? (
|
||||||
|
<>
|
||||||
|
<label htmlFor={`schema-praefix-${o.id}`} className="sr-only">
|
||||||
|
Präfix für {o.name}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`schema-praefix-${o.id}`}
|
||||||
|
className="input"
|
||||||
|
style={{ maxWidth: "6rem" }}
|
||||||
|
value={schemaPraefix}
|
||||||
|
onChange={(e) => setSchemaPraefix(e.target.value)}
|
||||||
|
placeholder="Präfix"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={8}
|
||||||
|
style={{ maxWidth: "5rem" }}
|
||||||
|
value={schemaStellenzahl}
|
||||||
|
onChange={(e) => setSchemaStellenzahl(Number(e.target.value) || 5)}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-primary" onClick={() => schemaSpeichern(o.id)}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setSchemaBearbeitenId(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-muted" style={{ fontSize: "0.85rem" }}>
|
||||||
|
Nummernschema:{" "}
|
||||||
|
{schema ? `${schema.praefix}-${"0".repeat(schema.stellenzahl)}` : "keins konfiguriert"}
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-secondary" onClick={() => schemaBearbeiten(o.id)}>
|
||||||
|
{schema ? "Ändern" : "Festlegen"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user