feat(vorlage): Vorlage direkt editierbar ohne Versionszwang (Nutzer-Vorgabe)
CI / backend-tests (push) Successful in 2m0s
CI / frontend-build (push) Successful in 18s

Bisher wurde die Sollmenge einer Objektposition live aus der Vorlagenposition
gelesen (objekt.vorlage_id -> vorlagenposition.sollmenge), Bearbeiten der
Vorlage erzeugte deshalb zwingend eine neue Version - sonst hätte sich jede
Änderung rückwirkend auf alle Objekte ausgewirkt, die diese Vorlage nutzen.

- Neue Spalte objektposition.sollmenge_vorlage (Migration 0020): kopiert die
  Sollmenge fest bei Anlage/Duplizieren statt live nachzuschlagen
- sollmenge_effektiv(position) liest jetzt nur noch override/sollmenge_vorlage,
  kein DB-Lookup mehr nötig (sollmenge_map() entfernt)
- Neuer Endpoint PUT /vorlagen/{id}/positionen: ersetzt Positionen direkt,
  ohne neue Version. Bestehende Objekte unbetroffen, nur neu angelegte nutzen
  die aktualisierten Werte (Test: test_aktualisiere_positionen_wirkt_nicht_
  rueckwirkend_auf_bestehendes_objekt)
- POST /vorlagen/{id}/neue-version bleibt zusätzlich bestehen (nicht entfernt)
- Frontend: "Neue Version speichern" -> "Positionen speichern" (PUT statt POST)
- 140 Tests grün

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-05 22:33:08 +02:00
co-authored by Claude Sonnet 5
parent cce643fbf8
commit 0249921bd5
9 changed files with 175 additions and 36 deletions
@@ -0,0 +1,38 @@
"""Nutzer-Vorgabe 2026-09-05: Vorlagen sollen direkt editierbar sein (keine
Versionierungspflicht mehr), ohne dass das rückwirkend bereits angelegte
Objekte verändert. Bisher wurde die Sollmenge live aus Vorlagenposition
gelesen (objekt.vorlage_id -> vorlagenposition.sollmenge) - das koppelt jede
Vorlagen-Änderung sofort an alle Objekte, die diese Vorlage nutzen. Neue Spalte
objektposition.sollmenge_vorlage kopiert die Sollmenge fest bei Anlage/
Duplizieren, Backfill übernimmt den aktuell gültigen Wert für Bestandsdaten.
Revision ID: 0020_sollmenge_vorlage
Revises: 0019_lager
Create Date: 2026-09-05
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0020_sollmenge_vorlage"
down_revision: Union[str, None] = "0019_lager"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TABLE objektposition ADD COLUMN sollmenge_vorlage NUMERIC")
op.execute(
"""
UPDATE objektposition op
SET sollmenge_vorlage = vp.sollmenge
FROM objekt o, vorlagenposition vp
WHERE op.objekt_id = o.id
AND vp.vorlage_id = o.vorlage_id
AND vp.material_id = op.material_id
"""
)
def downgrade() -> None:
op.execute("ALTER TABLE objektposition DROP COLUMN sollmenge_vorlage")
+3 -7
View File
@@ -32,7 +32,6 @@ from app.services.objekte import (
naechster_freier_code, naechster_freier_code,
objekt_aus_vorlage, objekt_aus_vorlage,
sollmenge_effektiv, sollmenge_effektiv,
sollmenge_map,
) )
@@ -131,13 +130,12 @@ async def hole_objektposition_per_code(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objektposition nicht gefunden")
objekt = await db.get(Objekt, position.objekt_id) objekt = await db.get(Objekt, position.objekt_id)
vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id if objekt else None)
return ObjektpositionRead( return ObjektpositionRead(
id=position.id, id=position.id,
objekt_id=position.objekt_id, objekt_id=position.objekt_id,
material_id=position.material_id, material_id=position.material_id,
sollmenge_override=position.sollmenge_override, sollmenge_override=position.sollmenge_override,
sollmenge_effektiv=sollmenge_effektiv(position, vorlagen_sollmengen), sollmenge_effektiv=sollmenge_effektiv(position),
ist_status=position.ist_status, ist_status=position.ist_status,
istmenge=position.istmenge, istmenge=position.istmenge,
seriennummer=position.seriennummer, seriennummer=position.seriennummer,
@@ -203,7 +201,6 @@ async def liste_objektpositionen(
if objekt is None: if objekt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden")
vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id)
result = await db.execute(select(Objektposition).where(Objektposition.objekt_id == objekt_id)) result = await db.execute(select(Objektposition).where(Objektposition.objekt_id == objekt_id))
positionen = result.scalars().all() positionen = result.scalars().all()
return [ return [
@@ -212,7 +209,7 @@ async def liste_objektpositionen(
objekt_id=p.objekt_id, objekt_id=p.objekt_id,
material_id=p.material_id, material_id=p.material_id,
sollmenge_override=p.sollmenge_override, sollmenge_override=p.sollmenge_override,
sollmenge_effektiv=sollmenge_effektiv(p, vorlagen_sollmengen), sollmenge_effektiv=sollmenge_effektiv(p),
ist_status=p.ist_status, ist_status=p.ist_status,
istmenge=p.istmenge, istmenge=p.istmenge,
seriennummer=p.seriennummer, seriennummer=p.seriennummer,
@@ -365,13 +362,12 @@ async def aendere_objektposition(
await db.flush() await db.flush()
objekt = await db.get(Objekt, objekt_id) objekt = await db.get(Objekt, objekt_id)
vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id if objekt else None)
return ObjektpositionRead( return ObjektpositionRead(
id=position.id, id=position.id,
objekt_id=position.objekt_id, objekt_id=position.objekt_id,
material_id=position.material_id, material_id=position.material_id,
sollmenge_override=position.sollmenge_override, sollmenge_override=position.sollmenge_override,
sollmenge_effektiv=sollmenge_effektiv(position, vorlagen_sollmengen), sollmenge_effektiv=sollmenge_effektiv(position),
ist_status=position.ist_status, ist_status=position.ist_status,
istmenge=position.istmenge, istmenge=position.istmenge,
seriennummer=position.seriennummer, seriennummer=position.seriennummer,
+19 -1
View File
@@ -8,7 +8,7 @@ from app.models.auth import RolleTyp
from app.models.objekt import Objekt from app.models.objekt import Objekt
from app.models.vorlage import Beladungsvorlage, Vorlagenposition from app.models.vorlage import Beladungsvorlage, Vorlagenposition
from app.schemas.vorlage import BeladungsvorlageCreate, BeladungsvorlageRead, VorlagenAenderung from app.schemas.vorlage import BeladungsvorlageCreate, BeladungsvorlageRead, VorlagenAenderung
from app.services.vorlagen import erstelle_vorlage, hole_positionen, neue_version from app.services.vorlagen import aktualisiere_positionen, erstelle_vorlage, hole_positionen, neue_version
router = APIRouter() router = APIRouter()
@@ -79,6 +79,24 @@ async def erzeuge_neue_version(
return await _mit_positionen(db, neue) return await _mit_positionen(db, neue)
@router.put("/vorlagen/{vorlage_id}/positionen", response_model=BeladungsvorlageRead)
async def aktualisiere_vorlage_positionen(
vorlage_id: int,
payload: VorlagenAenderung,
db: AsyncSession = Depends(get_db),
_=Depends(_ersteller),
) -> BeladungsvorlageRead:
"""Nutzer-Vorgabe 2026-09-05: Vorlage muss keine Versionen haben - ersetzt
die Positionen der bestehenden Vorlage direkt, ohne neue Version anzulegen.
Bereits angelegte Objekte sind unbetroffen (sollmenge_vorlage ist an der
Objektposition fest kopiert, siehe app/services/objekte.py)."""
vorlage = await db.get(Beladungsvorlage, vorlage_id)
if vorlage is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vorlage nicht gefunden")
aktualisiert = await aktualisiere_positionen(db, vorlage=vorlage, positionen=payload.positionen)
return await _mit_positionen(db, aktualisiert)
@router.delete("/vorlagen/{vorlage_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/vorlagen/{vorlage_id}", status_code=status.HTTP_204_NO_CONTENT)
async def loesche_vorlage( async def loesche_vorlage(
vorlage_id: int, db: AsyncSession = Depends(get_db), _=Depends(_ersteller) vorlage_id: int, db: AsyncSession = Depends(get_db), _=Depends(_ersteller)
+7 -1
View File
@@ -30,8 +30,14 @@ class Objektposition(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False) objekt_id: Mapped[int] = mapped_column(ForeignKey("objekt.id"), nullable=False)
material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False) material_id: Mapped[int] = mapped_column(ForeignKey("material.id"), nullable=False)
# NULL = Sollmenge folgt dynamisch der aktuellen Vorlagenposition (Prompt 10). # NULL = Sollmenge folgt sollmenge_vorlage (individuelle Abweichung, Prompt 10).
sollmenge_override: Mapped[Decimal | None] = mapped_column(Numeric) sollmenge_override: Mapped[Decimal | None] = mapped_column(Numeric)
# Fest kopierte Sollmenge aus der Vorlage bei Anlage/Duplizieren (Nutzer-
# Vorgabe 2026-09-05: Vorlage direkt editierbar, ohne bestehende Objekte
# rückwirkend zu verändern - vorher live aus Vorlagenposition gelesen).
# NULL bei Positionen, die nicht aus einer Vorlage stammen (Zusatzposition,
# dort ist sollmenge_override Pflicht).
sollmenge_vorlage: Mapped[Decimal | None] = mapped_column(Numeric)
ist_status: Mapped[ObjektpositionStatus] = mapped_column( ist_status: Mapped[ObjektpositionStatus] = mapped_column(
objektposition_status_pg, nullable=False, default=ObjektpositionStatus.aktiv objektposition_status_pg, nullable=False, default=ObjektpositionStatus.aktiv
) )
+2 -3
View File
@@ -11,7 +11,7 @@ from app.models.objekt import Objekt
from app.models.objektposition import Objektposition from app.models.objektposition import Objektposition
from app.services import benachrichtigung as benachrichtigung_service from app.services import benachrichtigung as benachrichtigung_service
from app.services import historie as historie_service from app.services import historie as historie_service
from app.services.objekte import sollmenge_effektiv, sollmenge_map from app.services.objekte import sollmenge_effektiv
async def erfasse_position( async def erfasse_position(
@@ -36,14 +36,13 @@ async def erfasse_position(
sie beschreiben das physische Exemplar, nicht die Ist-Menge, und werden sie beschreiben das physische Exemplar, nicht die Ist-Menge, und werden
daher hier - auf Wunsch als Teil der normalen Kontroll-Erfassung, nicht nur daher hier - auf Wunsch als Teil der normalen Kontroll-Erfassung, nicht nur
als separate Admin-Pflege - direkt mitgepflegt.""" als separate Admin-Pflege - direkt mitgepflegt."""
vorlagen_sollmengen = await sollmenge_map(db, objekt.vorlage_id)
result = await db.execute( result = await db.execute(
select(Objektposition).where( select(Objektposition).where(
Objektposition.objekt_id == objekt.id, Objektposition.material_id == material_id Objektposition.objekt_id == objekt.id, Objektposition.material_id == material_id
) )
) )
objektposition = result.scalar_one_or_none() objektposition = result.scalar_one_or_none()
sollmenge = sollmenge_effektiv(objektposition, vorlagen_sollmengen) if objektposition else Decimal(0) sollmenge = sollmenge_effektiv(objektposition) if objektposition else Decimal(0)
if objektposition is not None: if objektposition is not None:
if ablaufdatum is not None: if ablaufdatum is not None:
+10 -15
View File
@@ -47,23 +47,16 @@ async def letzte_kontrollen_map(db: AsyncSession, objekt_ids: list[int]) -> dict
return {objekt_id: beendet_am for objekt_id, beendet_am in result.all()} return {objekt_id: beendet_am for objekt_id, beendet_am in result.all()}
async def sollmenge_map(db: AsyncSession, vorlage_id: int | None) -> dict[int, Decimal]: def sollmenge_effektiv(position: Objektposition) -> Decimal:
"""material_id -> Sollmenge laut Vorlage (Prompt 10: Basis für die dynamische """Nutzer-Vorgabe 2026-09-05: Vorlagen sollen direkt editierbar sein, ohne
Auflösung, wenn eine Objektposition keinen Override hat).""" dass das rückwirkend alle Objekte verändert, die auf sie zeigen (bisher:
if vorlage_id is None: Sollmenge wurde live aus der Vorlage gelesen, keine Versionierung mehr nötig
return {} heißt aber auch keine implizite Live-Kopplung mehr). sollmenge_vorlage wird
result = await db.execute( daher bei Objekt-Anlage/Duplizieren FEST kopiert (siehe objekt_aus_vorlage/
select(Vorlagenposition.material_id, Vorlagenposition.sollmenge).where( dupliziere_objekt) statt bei jeder Anzeige neu aus der Vorlage gelesen."""
Vorlagenposition.vorlage_id == vorlage_id
)
)
return {material_id: sollmenge for material_id, sollmenge in result.all()}
def sollmenge_effektiv(position: Objektposition, vorlagen_sollmengen: dict[int, Decimal]) -> Decimal:
if position.sollmenge_override is not None: if position.sollmenge_override is not None:
return position.sollmenge_override return position.sollmenge_override
return vorlagen_sollmengen.get(position.material_id, Decimal(0)) return position.sollmenge_vorlage or Decimal(0)
async def objekt_aus_vorlage( async def objekt_aus_vorlage(
@@ -97,6 +90,7 @@ async def objekt_aus_vorlage(
Objektposition( Objektposition(
objekt_id=objekt.id, objekt_id=objekt.id,
material_id=vorlagenposition.material_id, material_id=vorlagenposition.material_id,
sollmenge_vorlage=vorlagenposition.sollmenge,
ist_status=ObjektpositionStatus.aktiv, ist_status=ObjektpositionStatus.aktiv,
istmenge=Decimal(0), istmenge=Decimal(0),
) )
@@ -136,6 +130,7 @@ async def dupliziere_objekt(
objekt_id=neues_objekt.id, objekt_id=neues_objekt.id,
material_id=quellposition.material_id, material_id=quellposition.material_id,
sollmenge_override=quellposition.sollmenge_override, sollmenge_override=quellposition.sollmenge_override,
sollmenge_vorlage=quellposition.sollmenge_vorlage,
ist_status=quellposition.ist_status, ist_status=quellposition.ist_status,
istmenge=Decimal(0), istmenge=Decimal(0),
) )
+21
View File
@@ -41,6 +41,27 @@ async def neue_version(
return neue return neue
async def aktualisiere_positionen(
db: AsyncSession, *, vorlage: Beladungsvorlage, positionen: list[VorlagenpositionCreate]
) -> Beladungsvorlage:
"""Nutzer-Vorgabe 2026-09-05: Vorlage muss keine Versionen haben - direktes
Bearbeiten der bestehenden Version statt Zwang zu `neue_version()`. Wirkt
sich NICHT auf bereits angelegte Objekte aus, da deren Sollmenge fest in
`objektposition.sollmenge_vorlage` kopiert ist (siehe services/objekte.py),
nicht mehr live aus der Vorlage gelesen wird."""
bisherige = await hole_positionen(db, vorlage.id)
for position in bisherige:
await db.delete(position)
# Flush zwischen Löschen und Neuanlegen: gleiches Muster wie beim Löschen
# einer ganzen Vorlage (DELETE /vorlagen/{id}) - keine relationship(), also
# keine automatische Abhängigkeits-Reihenfolge durch SQLAlchemy.
await db.flush()
for pos in positionen:
db.add(Vorlagenposition(vorlage_id=vorlage.id, **pos.model_dump()))
await db.flush()
return vorlage
async def hole_positionen(db: AsyncSession, vorlage_id: int) -> list[Vorlagenposition]: async def hole_positionen(db: AsyncSession, vorlage_id: int) -> list[Vorlagenposition]:
result = await db.execute(select(Vorlagenposition).where(Vorlagenposition.vorlage_id == vorlage_id)) result = await db.execute(select(Vorlagenposition).where(Vorlagenposition.vorlage_id == vorlage_id))
return list(result.scalars().all()) return list(result.scalars().all())
+63 -1
View File
@@ -1,6 +1,6 @@
import pytest import pytest
from app.services.vorlagen import erstelle_vorlage, hole_positionen, neue_version from app.services.vorlagen import aktualisiere_positionen, erstelle_vorlage, hole_positionen, neue_version
from app.schemas.vorlage import VorlagenpositionCreate from app.schemas.vorlage import VorlagenpositionCreate
@@ -34,6 +34,68 @@ async def test_neue_version_alte_bleibt_lesbar(db_session, objekttyp_factory, ma
assert v2_positionen[0].sollmenge == 15 assert v2_positionen[0].sollmenge == 15
@pytest.mark.asyncio
async def test_aktualisiere_positionen_wirkt_nicht_rueckwirkend_auf_bestehendes_objekt(
db_session, hauptserver_id, objekttyp_factory, material_factory, standort_factory
):
"""Nutzer-Vorgabe 2026-09-05: Vorlage muss keine Versionen haben - direktes
Bearbeiten der Positionen darf die Sollmenge bereits angelegter Objekte
NICHT rückwirkend verändern (sollmenge_vorlage ist fest kopiert)."""
from app.services.objekte import objekt_aus_vorlage, sollmenge_effektiv
from app.models.objektposition import Objektposition
from sqlalchemy import select
objekttyp = await objekttyp_factory("Rucksack In-Place-Edit")
material = await material_factory("Verbandpäckchen-IPE")
standort = await standort_factory("Wache-IPE")
vorlage = await erstelle_vorlage(
db_session,
objekttyp_id=objekttyp.id,
name="In-Place-Edit Standard",
positionen=[VorlagenpositionCreate(material_id=material.id, sollmenge=10)],
)
objekt = await objekt_aus_vorlage(
db_session,
code="IPE-01",
name="In-Place-Edit Objekt",
standort_id=standort.id,
vorlage=vorlage,
zustaendiger_server_id=hauptserver_id,
)
await aktualisiere_positionen(
db_session, vorlage=vorlage, positionen=[VorlagenpositionCreate(material_id=material.id, sollmenge=99)]
)
# Version bleibt unverändert (kein Versions-Zwang mehr, Vorlage bleibt "v1").
assert vorlage.version == 1
# Bestehendes Objekt behält seine ursprünglich kopierte Sollmenge.
result = await db_session.execute(
select(Objektposition).where(Objektposition.objekt_id == objekt.id, Objektposition.material_id == material.id)
)
position = result.scalar_one()
assert sollmenge_effektiv(position) == 10
# Ein NEUES Objekt aus derselben Vorlage bekommt die aktualisierte Sollmenge.
neues_objekt = await objekt_aus_vorlage(
db_session,
code="IPE-02",
name="In-Place-Edit Objekt 2",
standort_id=standort.id,
vorlage=vorlage,
zustaendiger_server_id=hauptserver_id,
)
result2 = await db_session.execute(
select(Objektposition).where(
Objektposition.objekt_id == neues_objekt.id, Objektposition.material_id == material.id
)
)
neue_position = result2.scalar_one()
assert sollmenge_effektiv(neue_position) == 99
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_loesche_unbenutzte_vorlage(client, db_session, objekttyp_factory, admin_user): async def test_loesche_unbenutzte_vorlage(client, db_session, objekttyp_factory, admin_user):
from tests.conftest import auth_header, login from tests.conftest import auth_header, login
+12 -8
View File
@@ -45,17 +45,20 @@ export function VorlageSection({ vorlagen, objekttypen, materialien, faecher, on
setNeueVersionZeilen((bisherige) => bisherige.map((z, i) => (i === index ? { ...z, ...zeile } : z))); setNeueVersionZeilen((bisherige) => bisherige.map((z, i) => (i === index ? { ...z, ...zeile } : z)));
} }
async function neueVersionSpeichern(vorlageId: number) { async function positionenSpeichern(vorlageId: number) {
const positionen = neueVersionZeilen const positionen = neueVersionZeilen
.filter((z) => z.materialId !== "") .filter((z) => z.materialId !== "")
.map((z) => ({ material_id: z.materialId, fach: z.fach || null, sollmenge: z.sollmenge })); .map((z) => ({ material_id: z.materialId, fach: z.fach || null, sollmenge: z.sollmenge }));
setWirdVersioniert(true); setWirdVersioniert(true);
try { try {
await apiRequest(`/vorlagen/${vorlageId}/neue-version`, { method: "POST", body: { positionen } }); // Nutzer-Vorgabe 2026-09-05: Vorlage muss keine Versionen haben - ersetzt
// die Positionen direkt (PUT), keine "neue Version" mehr. Bereits
// angelegte Objekte sind unbetroffen (Sollmenge fest kopiert, Backend).
await apiRequest(`/vorlagen/${vorlageId}/positionen`, { method: "PUT", body: { positionen } });
setAusgeklapptId(null); setAusgeklapptId(null);
await onGeaendert(); await onGeaendert();
} catch { } catch {
onFehler("Neue Version konnte nicht angelegt werden."); onFehler("Positionen konnten nicht gespeichert werden.");
} finally { } finally {
setWirdVersioniert(false); setWirdVersioniert(false);
} }
@@ -164,14 +167,15 @@ export function VorlageSection({ vorlagen, objekttypen, materialien, faecher, on
{v.name} <span className="badge badge-neutral">v{v.version}</span> {v.name} <span className="badge badge-neutral">v{v.version}</span>
</span> </span>
<button className="btn btn-secondary" onClick={() => versionBearbeiten(v)}> <button className="btn btn-secondary" onClick={() => versionBearbeiten(v)}>
{ausgeklapptId === v.id ? "Schließen" : "Positionen / neue Version"} {ausgeklapptId === v.id ? "Schließen" : "Positionen bearbeiten"}
</button> </button>
</div> </div>
{ausgeklapptId === v.id && ( {ausgeklapptId === v.id && (
<div className="stack" style={{ marginTop: "0.6rem" }}> <div className="stack" style={{ marginTop: "0.6rem" }}>
<p className="text-muted" style={{ marginBottom: 0 }}> <p className="text-muted" style={{ marginBottom: 0 }}>
Änderungen erzeugen eine neue Version die aktuelle v{v.version} bleibt unverändert Änderungen wirken direkt auf diese Vorlage, ohne neue Version. Bereits angelegte
erhalten. Objekte behalten ihre bisherige Sollmenge nur neu angelegte Objekte nutzen die
aktualisierten Werte.
</p> </p>
{neueVersionZeilen.map((zeile, i) => ( {neueVersionZeilen.map((zeile, i) => (
<div className="row" key={i}> <div className="row" key={i}>
@@ -211,10 +215,10 @@ export function VorlageSection({ vorlagen, objekttypen, materialien, faecher, on
</button> </button>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => neueVersionSpeichern(v.id)} onClick={() => positionenSpeichern(v.id)}
disabled={wirdVersioniert} disabled={wirdVersioniert}
> >
Neue Version speichern Positionen speichern
</button> </button>
</div> </div>
)} )}