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-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`) |
@@ -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(...),
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(
+6 -1
View File
@@ -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)
+1
View File
@@ -27,3 +27,4 @@ class DokumentRead(BaseModel):
hochgeladen_von: int
hochgeladen_am: datetime
vorgaenger_id: uuid.UUID | None
ist_original: bool
+9 -1
View File
@@ -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,
)
+41
View File
@@ -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
+4
View File
@@ -69,6 +69,7 @@ export async function ladeDokumentHoch(felder: {
entitaet_id: string;
dokumenttyp: string;
beschreibung?: string;
istOriginal: boolean;
datei: File;
}): Promise<unknown> {
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<string, string> = {};
@@ -93,10 +95,12 @@ export async function ladeDokumentHoch(felder: {
export async function ladeDokumentErsetzen(felder: {
dokumentId: string;
beschreibung?: string;
istOriginal: boolean;
datei: File;
}): Promise<unknown> {
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<string, string> = {};
+1
View File
@@ -145,6 +145,7 @@ export interface Dokument {
hochgeladen_von: number;
hochgeladen_am: string;
vorgaenger_id: string | null;
ist_original: boolean;
}
export interface Lagerbewegung {
+24 -2
View File
@@ -48,6 +48,7 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
const [dokumente, setDokumente] = useState<Dokument[]>([]);
const [laedt, setLaedt] = useState(true);
const [beschreibung, setBeschreibung] = useState("");
const [istOriginal, setIstOriginal] = useState(false);
const [dokumenttyp, setDokumenttyp] = useState<DokumentTyp>("sonstiges");
const [filterTyp, setFilterTyp] = useState<DokumentTyp | "">("");
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" }}
/>
<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} />
</div>
{dokumente.length > 0 || filterTyp ? (
@@ -205,6 +218,15 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
<span className="badge badge-neutral" style={{ marginRight: "0.4rem" }}>
{DOKUMENTTYP_LABEL[d.dokumenttyp]}
</span>
{d.ist_original && (
<span
className="badge badge-success"
style={{ marginRight: "0.4rem" }}
title="Als Original gekennzeichnet, nicht nur Kopie/Scan"
>
Original
</span>
)}
<button
onClick={() => ansehen(d.id)}
title="In neuem Tab ansehen"