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:
@@ -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.objekt import Objekt
|
||||
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.services import historie as historie_service
|
||||
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")
|
||||
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]:
|
||||
"""Code-Vorschlag beim Anlegen/Duplizieren (Konzept-Ergänzung Karte 10/Prompt 09).
|
||||
Vorschlag ist überschreibbar, Eindeutigkeit erzwingt weiterhin objekt.code UNIQUE."""
|
||||
return {"code": await naechster_freier_code(db, Objekt.code, praefix)}
|
||||
Vorschlag ist überschreibbar, Eindeutigkeit erzwingt weiterhin objekt.code UNIQUE.
|
||||
|
||||
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")
|
||||
|
||||
@@ -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.models.auth import RolleTyp
|
||||
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.services.material_merge import finde_konflikte, fuehre_zusammen, hole_verwendung
|
||||
from app.schemas.stammdaten import (
|
||||
@@ -25,6 +25,9 @@ from app.schemas.stammdaten import (
|
||||
KategorieUpdate,
|
||||
ModellCreate,
|
||||
ModellRead,
|
||||
NummernschemaCreate,
|
||||
NummernschemaRead,
|
||||
NummernschemaUpdate,
|
||||
MaterialCreate,
|
||||
MaterialRead,
|
||||
MaterialUpdate,
|
||||
@@ -141,6 +144,53 @@ async def erstelle_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 ------------------------------------------------------------------
|
||||
|
||||
@router.get("/kategorien", response_model=list[KategorieRead])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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.orm import Mapped, mapped_column
|
||||
|
||||
@@ -54,6 +54,24 @@ class Objekttyp(Base):
|
||||
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):
|
||||
"""Nutzer-Vorgabe: feste Fächer-Liste je Objekttyp, damit beim Anlegen einer
|
||||
Vorlagenposition nicht jedes Mal Freitext getippt werden muss. `sortierung`
|
||||
|
||||
@@ -54,6 +54,22 @@ class ModellRead(ModellCreate):
|
||||
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):
|
||||
name: str
|
||||
adresse: str | None = None
|
||||
|
||||
@@ -13,14 +13,16 @@ from app.models.vorlage import Beladungsvorlage, Vorlagenposition
|
||||
_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
|
||||
fortlaufender Nummer je Präfix. Zahlenbreite folgt der bisher größten
|
||||
vergebenen Nummer (mind. 5 Stellen bei Erstvergabe), damit die Sortierung im
|
||||
Regal stabil bleibt, solange nicht mehr als 10^Breite Objekte existieren."""
|
||||
vergebenen Nummer (mindestens `mindestbreite` bei Erstvergabe - IDENT-003:
|
||||
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}-%")))
|
||||
hoechste = 0
|
||||
breite = 5
|
||||
breite = mindestbreite
|
||||
for (code,) in result.all():
|
||||
treffer = _CODE_MUSTER.match(code[len(praefix) :])
|
||||
if treffer is None:
|
||||
|
||||
@@ -262,6 +262,71 @@ async def test_naechster_objekt_code_zaehlt_hoch(
|
||||
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
|
||||
async def test_naechster_objektposition_code(
|
||||
client, db_session, objekt_mit_position, materialverantwortlicher_user
|
||||
|
||||
Reference in New Issue
Block a user