diff --git a/arbeitskacheln/00_index.md b/arbeitskacheln/00_index.md index c7e93bd..d182ae9 100644 --- a/arbeitskacheln/00_index.md +++ b/arbeitskacheln/00_index.md @@ -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-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-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`) | | 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`) | diff --git a/backend/alembic/versions/0029_dokument_original.py b/backend/alembic/versions/0029_dokument_original.py new file mode 100644 index 0000000..547f190 --- /dev/null +++ b/backend/alembic/versions/0029_dokument_original.py @@ -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") diff --git a/backend/app/api/v1/endpoints/dokument.py b/backend/app/api/v1/endpoints/dokument.py index 96b35e5..49f3846 100644 --- a/backend/app/api/v1/endpoints/dokument.py +++ b/backend/app/api/v1/endpoints/dokument.py @@ -72,6 +72,7 @@ async def lade_dokument_hoch( entitaet_id: str = Form(...), dokumenttyp: DokumentTyp = Form(...), beschreibung: str | None = Form(None), + ist_original: bool = Form(False), datei: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user=Depends(_mitarbeiter_plus), @@ -88,6 +89,7 @@ async def lade_dokument_hoch( dokumenttyp=dokumenttyp, beschreibung=beschreibung, hochgeladen_von=current_user.id, + ist_original=ist_original, ) except DateityperlaubtError as exc: raise HTTPException( @@ -128,6 +130,7 @@ async def lade_dokument_herunter( async def dokument_ersetzen( dokument_id: uuid.UUID, beschreibung: str | None = Form(None), + ist_original: bool = Form(False), datei: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user=Depends(_mitarbeiter_plus), @@ -148,6 +151,7 @@ async def dokument_ersetzen( inhalt=inhalt, beschreibung=beschreibung, hochgeladen_von=current_user.id, + ist_original=ist_original, ) except DateityperlaubtError as exc: raise HTTPException( diff --git a/backend/app/models/dokument.py b/backend/app/models/dokument.py index c7aa389..151910f 100644 --- a/backend/app/models/dokument.py +++ b/backend/app/models/dokument.py @@ -2,7 +2,7 @@ import enum import uuid 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.orm import Mapped, mapped_column @@ -67,3 +67,8 @@ class Dokument(Base): vorgaenger_id: Mapped[uuid.UUID | None] = mapped_column( 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) diff --git a/backend/app/schemas/dokument.py b/backend/app/schemas/dokument.py index 1352e59..58ef885 100644 --- a/backend/app/schemas/dokument.py +++ b/backend/app/schemas/dokument.py @@ -27,3 +27,4 @@ class DokumentRead(BaseModel): hochgeladen_von: int hochgeladen_am: datetime vorgaenger_id: uuid.UUID | None + ist_original: bool diff --git a/backend/app/services/dokument.py b/backend/app/services/dokument.py index 4b2adae..4688a08 100644 --- a/backend/app/services/dokument.py +++ b/backend/app/services/dokument.py @@ -56,6 +56,7 @@ async def _pruefe_und_speichere( beschreibung: str | None, hochgeladen_von: int, vorgaenger_id: uuid.UUID | None, + ist_original: bool, ) -> Dokument: if mime_type not in ERLAUBTE_MIME_TYPES: raise DateityperlaubtError(mime_type) @@ -94,6 +95,7 @@ async def _pruefe_und_speichere( hochgeladen_von=hochgeladen_von, hochgeladen_am=datetime.now(timezone.utc), vorgaenger_id=vorgaenger_id, + ist_original=ist_original, ) db.add(dokument) await db.flush() @@ -111,6 +113,7 @@ async def speichere_dokument( dokumenttyp: DokumentTyp, beschreibung: str | None, hochgeladen_von: int, + ist_original: bool, ) -> Dokument: return await _pruefe_und_speichere( db, @@ -123,6 +126,7 @@ async def speichere_dokument( beschreibung=beschreibung, hochgeladen_von=hochgeladen_von, vorgaenger_id=None, + ist_original=ist_original, ) @@ -135,11 +139,14 @@ async def ersetze_dokument( inhalt: bytes, beschreibung: str | None, hochgeladen_von: int, + ist_original: bool, ) -> Dokument: """DOC-003: neue Version anlegen statt zu überschreiben - altes_dokument bleibt unverändert als Historieneintrag erhalten, entitaet_typ/entitaet_id/ 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( db, entitaet_typ=altes_dokument.entitaet_typ, @@ -151,6 +158,7 @@ async def ersetze_dokument( beschreibung=beschreibung, hochgeladen_von=hochgeladen_von, vorgaenger_id=altes_dokument.id, + ist_original=ist_original, ) diff --git a/backend/tests/test_dokument.py b/backend/tests/test_dokument.py index 6401f21..8746a09 100644 --- a/backend/tests/test_dokument.py +++ b/backend/tests/test_dokument.py @@ -368,3 +368,44 @@ async def test_versionshistorie_respektiert_zugriffsrechte( mitarbeiter_token = await login(client, "mitarbeiter1") verweigert = await client.get(f"/api/v1/dokumente/{v1_id}/versionen", headers=auth_header(mitarbeiter_token)) 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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index da69758..e8c5046 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -69,6 +69,7 @@ export async function ladeDokumentHoch(felder: { entitaet_id: string; dokumenttyp: string; beschreibung?: string; + istOriginal: boolean; datei: File; }): Promise { const formData = new FormData(); @@ -76,6 +77,7 @@ export async function ladeDokumentHoch(felder: { formData.append("entitaet_id", felder.entitaet_id); formData.append("dokumenttyp", felder.dokumenttyp); if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung); + formData.append("ist_original", String(felder.istOriginal)); formData.append("datei", felder.datei); const headers: Record = {}; @@ -93,10 +95,12 @@ export async function ladeDokumentHoch(felder: { export async function ladeDokumentErsetzen(felder: { dokumentId: string; beschreibung?: string; + istOriginal: boolean; datei: File; }): Promise { const formData = new FormData(); if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung); + formData.append("ist_original", String(felder.istOriginal)); formData.append("datei", felder.datei); const headers: Record = {}; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b20d013..e1b6408 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -145,6 +145,7 @@ export interface Dokument { hochgeladen_von: number; hochgeladen_am: string; vorgaenger_id: string | null; + ist_original: boolean; } export interface Lagerbewegung { diff --git a/frontend/src/components/DokumentePanel.tsx b/frontend/src/components/DokumentePanel.tsx index 6a0c13c..3c82eca 100644 --- a/frontend/src/components/DokumentePanel.tsx +++ b/frontend/src/components/DokumentePanel.tsx @@ -48,6 +48,7 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { const [dokumente, setDokumente] = useState([]); const [laedt, setLaedt] = useState(true); const [beschreibung, setBeschreibung] = useState(""); + const [istOriginal, setIstOriginal] = useState(false); const [dokumenttyp, setDokumenttyp] = useState("sonstiges"); const [filterTyp, setFilterTyp] = useState(""); const [wirdHochgeladen, setWirdHochgeladen] = useState(false); @@ -79,8 +80,16 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { if (!datei) return; setWirdHochgeladen(true); 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(""); + setIstOriginal(false); e.target.value = ""; await laden(); } catch (fehler) { @@ -99,7 +108,7 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { const datei = e.target.files?.[0]; if (!datei) return; try { - await ladeDokumentErsetzen({ dokumentId, datei }); + await ladeDokumentErsetzen({ dokumentId, istOriginal, datei }); e.target.value = ""; delete versionenOffen[dokumentId]; await laden(); @@ -177,6 +186,10 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { placeholder="Beschreibung (optional)" style={{ maxWidth: "16rem" }} /> + {dokumente.length > 0 || filterTyp ? ( @@ -205,6 +218,15 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { {DOKUMENTTYP_LABEL[d.dokumenttyp]} + {d.ist_original && ( + + Original + + )}