feat(dokumente): DOC-004 Original-vs-Kopie-Kennzeichnung
CI / backend-tests (push) Successful in 2m16s
CI / frontend-build (push) Successful in 27s

Neues Feld ist_original (Boolean, Migration 0029) - explizit beim Upload
und beim Ersetzen (DOC-003) setzbar, kein Default in eine Richtung erraten.
Wird beim Ersetzen bewusst NICHT vom Vorgänger übernommen (neue Version
könnte ein nachgereichtes Original sein, das eine bisherige Kopie ersetzt,
oder umgekehrt). Frontend: Checkbox "Original (nicht nur Kopie/Scan)" im
Upload-Formular, "Original"-Badge in der Dokumentenliste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-08 12:16:25 +02:00
co-authored by Claude Sonnet 5
parent 17cac8543f
commit ae09433c11
10 changed files with 118 additions and 5 deletions
+1 -1
View File
@@ -217,7 +217,7 @@ vergeben statt geraten.
| DOC-001 | Documents | Dokument-Grundgerüst (Upload) | P1 | M | MEDIUM | FILE-001 | ✅ (`dokument.py` Model+Endpunkt, inkl. SHA-256-Duplikaterkennung, Migration 0024/0016 referenzieren DOC-001) | | DOC-001 | Documents | Dokument-Grundgerüst (Upload) | P1 | M | MEDIUM | FILE-001 | ✅ (`dokument.py` Model+Endpunkt, inkl. SHA-256-Duplikaterkennung, Migration 0024/0016 referenzieren DOC-001) |
| DOC-002 | Documents | Dokumenttypen | P2 | XS | LOW | DOC-001 | ✅ (`DokumentTyp`-Enum, Migration 0026, Filter beim Listen, Auswahl beim Upload) | | DOC-002 | Documents | Dokumenttypen | P2 | XS | LOW | DOC-001 | ✅ (`DokumentTyp`-Enum, Migration 0026, Filter beim Listen, Auswahl beim Upload) |
| DOC-003 | Documents | Versionierung | P2 | M | MEDIUM | DOC-001 | ✅ (`vorgaenger_id`-Kette, Migration 0027, `/ersetzen`+`/versionen`-Endpunkte, alte Version bleibt erhalten) | | DOC-003 | Documents | Versionierung | P2 | M | MEDIUM | DOC-001 | ✅ (`vorgaenger_id`-Kette, Migration 0027, `/ersetzen`+`/versionen`-Endpunkte, alte Version bleibt erhalten) |
| DOC-004 | Documents | Original-vs-Kopie-Kennzeichnung | P2 | S | LOW | DOC-001 | (kein entsprechendes Feld gefunden) | | DOC-004 | Documents | Original-vs-Kopie-Kennzeichnung | P2 | S | LOW | DOC-001 | (`ist_original`-Feld, Migration 0029, beim Upload/Ersetzen explizit setzbar) |
| DOC-005 | Documents | Zugriffsrechte je Dokument | P2 | S | MEDIUM | DOC-001, FOUND-004 | ✅ (nach Dokumenttyp: Rechnungen nur für Materialverantwortliche+, per `EINGESCHRAENKTE_DOKUMENTTYPEN`) | | DOC-005 | Documents | Zugriffsrechte je Dokument | P2 | S | MEDIUM | DOC-001, FOUND-004 | ✅ (nach Dokumenttyp: Rechnungen nur für Materialverantwortliche+, per `EINGESCHRAENKTE_DOKUMENTTYPEN`) |
| MOBILE-001 | Mobile | PWA-Grundgerüst | P1 | M | MEDIUM | FOUND-005 | ✅ (Vite-PWA-Build erzeugt `manifest.webmanifest`/`sw.js`, `vite-plugin-pwa` in package.json) | | MOBILE-001 | Mobile | PWA-Grundgerüst | P1 | M | MEDIUM | FOUND-005 | ✅ (Vite-PWA-Build erzeugt `manifest.webmanifest`/`sw.js`, `vite-plugin-pwa` in package.json) |
| MOBILE-002 | Mobile | QR-Scan-Workflow (Kernablauf) | P1 | M | MEDIUM | MOBILE-001, IDENT-008 | ✅ (`BarcodeScanner.tsx` verdrahtet in `ObjektListPage.tsx`) | | MOBILE-002 | Mobile | QR-Scan-Workflow (Kernablauf) | P1 | M | MEDIUM | MOBILE-001, IDENT-008 | ✅ (`BarcodeScanner.tsx` verdrahtet in `ObjektListPage.tsx`) |
@@ -0,0 +1,27 @@
"""DOC-004: Original-vs-Kopie-Kennzeichnung bei Dokumenten.
Revision ID: 0029_dokument_original
Revises: 0028_fk_indizes
Create Date: 2026-09-08
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0029_dokument_original"
down_revision: Union[str, None] = "0028_fk_indizes"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"ALTER TABLE dokument ADD COLUMN ist_original BOOLEAN NOT NULL DEFAULT false"
)
# Default nur für den Backfill nötig - künftige Inserts geben es immer
# explizit an (Anwendungsebene).
op.execute("ALTER TABLE dokument ALTER COLUMN ist_original DROP DEFAULT")
def downgrade() -> None:
op.execute("ALTER TABLE dokument DROP COLUMN ist_original")
+4
View File
@@ -72,6 +72,7 @@ async def lade_dokument_hoch(
entitaet_id: str = Form(...), entitaet_id: str = Form(...),
dokumenttyp: DokumentTyp = Form(...), dokumenttyp: DokumentTyp = Form(...),
beschreibung: str | None = Form(None), beschreibung: str | None = Form(None),
ist_original: bool = Form(False),
datei: UploadFile = File(...), datei: UploadFile = File(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user=Depends(_mitarbeiter_plus), current_user=Depends(_mitarbeiter_plus),
@@ -88,6 +89,7 @@ async def lade_dokument_hoch(
dokumenttyp=dokumenttyp, dokumenttyp=dokumenttyp,
beschreibung=beschreibung, beschreibung=beschreibung,
hochgeladen_von=current_user.id, hochgeladen_von=current_user.id,
ist_original=ist_original,
) )
except DateityperlaubtError as exc: except DateityperlaubtError as exc:
raise HTTPException( raise HTTPException(
@@ -128,6 +130,7 @@ async def lade_dokument_herunter(
async def dokument_ersetzen( async def dokument_ersetzen(
dokument_id: uuid.UUID, dokument_id: uuid.UUID,
beschreibung: str | None = Form(None), beschreibung: str | None = Form(None),
ist_original: bool = Form(False),
datei: UploadFile = File(...), datei: UploadFile = File(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user=Depends(_mitarbeiter_plus), current_user=Depends(_mitarbeiter_plus),
@@ -148,6 +151,7 @@ async def dokument_ersetzen(
inhalt=inhalt, inhalt=inhalt,
beschreibung=beschreibung, beschreibung=beschreibung,
hochgeladen_von=current_user.id, hochgeladen_von=current_user.id,
ist_original=ist_original,
) )
except DateityperlaubtError as exc: except DateityperlaubtError as exc:
raise HTTPException( raise HTTPException(
+6 -1
View File
@@ -2,7 +2,7 @@ import enum
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String from sqlalchemy import Boolean, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -67,3 +67,8 @@ class Dokument(Base):
vorgaenger_id: Mapped[uuid.UUID | None] = mapped_column( vorgaenger_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("dokument.id"), nullable=True UUID(as_uuid=True), ForeignKey("dokument.id"), nullable=True
) )
# DOC-004: rechtlich/organisatorisch relevant bei Zulassungsdokumenten/
# Rechnungen - ist das hier das Original (z.B. eingescannt vom Papier-
# Original) oder nur eine Kopie/ein Duplikat davon? Explizite Angabe beim
# Upload statt Annahme, kein Default in eine Richtung.
ist_original: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+1
View File
@@ -27,3 +27,4 @@ class DokumentRead(BaseModel):
hochgeladen_von: int hochgeladen_von: int
hochgeladen_am: datetime hochgeladen_am: datetime
vorgaenger_id: uuid.UUID | None vorgaenger_id: uuid.UUID | None
ist_original: bool
+9 -1
View File
@@ -56,6 +56,7 @@ async def _pruefe_und_speichere(
beschreibung: str | None, beschreibung: str | None,
hochgeladen_von: int, hochgeladen_von: int,
vorgaenger_id: uuid.UUID | None, vorgaenger_id: uuid.UUID | None,
ist_original: bool,
) -> Dokument: ) -> Dokument:
if mime_type not in ERLAUBTE_MIME_TYPES: if mime_type not in ERLAUBTE_MIME_TYPES:
raise DateityperlaubtError(mime_type) raise DateityperlaubtError(mime_type)
@@ -94,6 +95,7 @@ async def _pruefe_und_speichere(
hochgeladen_von=hochgeladen_von, hochgeladen_von=hochgeladen_von,
hochgeladen_am=datetime.now(timezone.utc), hochgeladen_am=datetime.now(timezone.utc),
vorgaenger_id=vorgaenger_id, vorgaenger_id=vorgaenger_id,
ist_original=ist_original,
) )
db.add(dokument) db.add(dokument)
await db.flush() await db.flush()
@@ -111,6 +113,7 @@ async def speichere_dokument(
dokumenttyp: DokumentTyp, dokumenttyp: DokumentTyp,
beschreibung: str | None, beschreibung: str | None,
hochgeladen_von: int, hochgeladen_von: int,
ist_original: bool,
) -> Dokument: ) -> Dokument:
return await _pruefe_und_speichere( return await _pruefe_und_speichere(
db, db,
@@ -123,6 +126,7 @@ async def speichere_dokument(
beschreibung=beschreibung, beschreibung=beschreibung,
hochgeladen_von=hochgeladen_von, hochgeladen_von=hochgeladen_von,
vorgaenger_id=None, vorgaenger_id=None,
ist_original=ist_original,
) )
@@ -135,11 +139,14 @@ async def ersetze_dokument(
inhalt: bytes, inhalt: bytes,
beschreibung: str | None, beschreibung: str | None,
hochgeladen_von: int, hochgeladen_von: int,
ist_original: bool,
) -> Dokument: ) -> Dokument:
"""DOC-003: neue Version anlegen statt zu überschreiben - altes_dokument """DOC-003: neue Version anlegen statt zu überschreiben - altes_dokument
bleibt unverändert als Historieneintrag erhalten, entitaet_typ/entitaet_id/ bleibt unverändert als Historieneintrag erhalten, entitaet_typ/entitaet_id/
dokumenttyp werden von ihm übernommen (nicht neu wählbar, es ist dieselbe dokumenttyp werden von ihm übernommen (nicht neu wählbar, es ist dieselbe
Sache in neuer Fassung).""" Sache in neuer Fassung). ist_original wird bewusst NICHT vom Vorgänger
übernommen (DOC-004) - die neue Version könnte z.B. ein nachgereichtes
Original sein, das die bisherige Kopie ersetzt."""
return await _pruefe_und_speichere( return await _pruefe_und_speichere(
db, db,
entitaet_typ=altes_dokument.entitaet_typ, entitaet_typ=altes_dokument.entitaet_typ,
@@ -151,6 +158,7 @@ async def ersetze_dokument(
beschreibung=beschreibung, beschreibung=beschreibung,
hochgeladen_von=hochgeladen_von, hochgeladen_von=hochgeladen_von,
vorgaenger_id=altes_dokument.id, vorgaenger_id=altes_dokument.id,
ist_original=ist_original,
) )
+41
View File
@@ -368,3 +368,44 @@ async def test_versionshistorie_respektiert_zugriffsrechte(
mitarbeiter_token = await login(client, "mitarbeiter1") mitarbeiter_token = await login(client, "mitarbeiter1")
verweigert = await client.get(f"/api/v1/dokumente/{v1_id}/versionen", headers=auth_header(mitarbeiter_token)) verweigert = await client.get(f"/api/v1/dokumente/{v1_id}/versionen", headers=auth_header(mitarbeiter_token))
assert verweigert.status_code == 403 assert verweigert.status_code == 403
@pytest.mark.asyncio
async def test_original_kennzeichnung_default_false_und_explizit_setzbar(
client, objekt_mit_position, mitarbeiter_user
):
"""DOC-004: ohne Angabe gilt eine Datei als Kopie (False), kann aber
explizit als Original markiert werden - auch beim Ersetzen unabhängig
vom Vorgänger neu wählbar."""
objekt, _material = objekt_mit_position
token = await login(client, "mitarbeiter1")
ohne_angabe = await client.post(
"/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "zulassungsdokument"},
files={"datei": ("kopie.pdf", b"%PDF-1.4 kopie", "application/pdf")},
headers=auth_header(token),
)
assert ohne_angabe.json()["ist_original"] is False
original = await client.post(
"/api/v1/dokumente",
data={
"entitaet_typ": "objekt",
"entitaet_id": str(objekt.id),
"dokumenttyp": "zulassungsdokument",
"ist_original": "true",
},
files={"datei": ("original.pdf", b"%PDF-1.4 original", "application/pdf")},
headers=auth_header(token),
)
original_body = original.json()
assert original_body["ist_original"] is True
ersetzt = await client.post(
f"/api/v1/dokumente/{original_body['id']}/ersetzen",
data={"ist_original": "false"},
files={"datei": ("nachtraeglich_kopie.pdf", b"%PDF-1.4 v2", "application/pdf")},
headers=auth_header(token),
)
assert ersetzt.json()["ist_original"] is False
+4
View File
@@ -69,6 +69,7 @@ export async function ladeDokumentHoch(felder: {
entitaet_id: string; entitaet_id: string;
dokumenttyp: string; dokumenttyp: string;
beschreibung?: string; beschreibung?: string;
istOriginal: boolean;
datei: File; datei: File;
}): Promise<unknown> { }): Promise<unknown> {
const formData = new FormData(); const formData = new FormData();
@@ -76,6 +77,7 @@ export async function ladeDokumentHoch(felder: {
formData.append("entitaet_id", felder.entitaet_id); formData.append("entitaet_id", felder.entitaet_id);
formData.append("dokumenttyp", felder.dokumenttyp); formData.append("dokumenttyp", felder.dokumenttyp);
if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung); if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung);
formData.append("ist_original", String(felder.istOriginal));
formData.append("datei", felder.datei); formData.append("datei", felder.datei);
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@@ -93,10 +95,12 @@ export async function ladeDokumentHoch(felder: {
export async function ladeDokumentErsetzen(felder: { export async function ladeDokumentErsetzen(felder: {
dokumentId: string; dokumentId: string;
beschreibung?: string; beschreibung?: string;
istOriginal: boolean;
datei: File; datei: File;
}): Promise<unknown> { }): Promise<unknown> {
const formData = new FormData(); const formData = new FormData();
if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung); if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung);
formData.append("ist_original", String(felder.istOriginal));
formData.append("datei", felder.datei); formData.append("datei", felder.datei);
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
+1
View File
@@ -145,6 +145,7 @@ export interface Dokument {
hochgeladen_von: number; hochgeladen_von: number;
hochgeladen_am: string; hochgeladen_am: string;
vorgaenger_id: string | null; vorgaenger_id: string | null;
ist_original: boolean;
} }
export interface Lagerbewegung { export interface Lagerbewegung {
+24 -2
View File
@@ -48,6 +48,7 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
const [dokumente, setDokumente] = useState<Dokument[]>([]); const [dokumente, setDokumente] = useState<Dokument[]>([]);
const [laedt, setLaedt] = useState(true); const [laedt, setLaedt] = useState(true);
const [beschreibung, setBeschreibung] = useState(""); const [beschreibung, setBeschreibung] = useState("");
const [istOriginal, setIstOriginal] = useState(false);
const [dokumenttyp, setDokumenttyp] = useState<DokumentTyp>("sonstiges"); const [dokumenttyp, setDokumenttyp] = useState<DokumentTyp>("sonstiges");
const [filterTyp, setFilterTyp] = useState<DokumentTyp | "">(""); const [filterTyp, setFilterTyp] = useState<DokumentTyp | "">("");
const [wirdHochgeladen, setWirdHochgeladen] = useState(false); const [wirdHochgeladen, setWirdHochgeladen] = useState(false);
@@ -79,8 +80,16 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
if (!datei) return; if (!datei) return;
setWirdHochgeladen(true); setWirdHochgeladen(true);
try { try {
await ladeDokumentHoch({ entitaet_typ: entitaetTyp, entitaet_id: entitaetId, dokumenttyp, beschreibung, datei }); await ladeDokumentHoch({
entitaet_typ: entitaetTyp,
entitaet_id: entitaetId,
dokumenttyp,
beschreibung,
istOriginal,
datei,
});
setBeschreibung(""); setBeschreibung("");
setIstOriginal(false);
e.target.value = ""; e.target.value = "";
await laden(); await laden();
} catch (fehler) { } catch (fehler) {
@@ -99,7 +108,7 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
const datei = e.target.files?.[0]; const datei = e.target.files?.[0];
if (!datei) return; if (!datei) return;
try { try {
await ladeDokumentErsetzen({ dokumentId, datei }); await ladeDokumentErsetzen({ dokumentId, istOriginal, datei });
e.target.value = ""; e.target.value = "";
delete versionenOffen[dokumentId]; delete versionenOffen[dokumentId];
await laden(); await laden();
@@ -177,6 +186,10 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
placeholder="Beschreibung (optional)" placeholder="Beschreibung (optional)"
style={{ maxWidth: "16rem" }} style={{ maxWidth: "16rem" }}
/> />
<label className="row" style={{ gap: "0.3rem", alignItems: "center", fontSize: "0.9rem" }}>
<input type="checkbox" checked={istOriginal} onChange={(e) => setIstOriginal(e.target.checked)} />
Original (nicht nur Kopie/Scan)
</label>
<input type="file" accept=".pdf,.jpg,.jpeg,.png,.webp" onChange={hochladen} disabled={wirdHochgeladen} /> <input type="file" accept=".pdf,.jpg,.jpeg,.png,.webp" onChange={hochladen} disabled={wirdHochgeladen} />
</div> </div>
{dokumente.length > 0 || filterTyp ? ( {dokumente.length > 0 || filterTyp ? (
@@ -205,6 +218,15 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
<span className="badge badge-neutral" style={{ marginRight: "0.4rem" }}> <span className="badge badge-neutral" style={{ marginRight: "0.4rem" }}>
{DOKUMENTTYP_LABEL[d.dokumenttyp]} {DOKUMENTTYP_LABEL[d.dokumenttyp]}
</span> </span>
{d.ist_original && (
<span
className="badge badge-success"
style={{ marginRight: "0.4rem" }}
title="Als Original gekennzeichnet, nicht nur Kopie/Scan"
>
Original
</span>
)}
<button <button
onClick={() => ansehen(d.id)} onClick={() => ansehen(d.id)}
title="In neuem Tab ansehen" title="In neuem Tab ansehen"