diff --git a/DEVLOG.md b/DEVLOG.md index 4d0f859..fd288b3 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2607,3 +2607,19 @@ Keine Commits in dieser Session. - frontend/src/styles/global.css | 24 +++++++++++ --- +## 2026-09-04 17:57 – 17:57 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** flutter_app + +### Commits +- d8d2816 Karte 10: Kamera-Scan in Flutter-App (Objekt-Code, Scan-First-Einstieg) + +### Geänderte Dateien +- DEVLOG.md | 16 +++++++ +- flutter_app/DEVLOG.md | 18 ++++++++ +- flutter_app/README.md | 18 +++++++- +- flutter_app/lib/screens/objekt_list_screen.dart | 59 ++++++++++++++++++++++--- +- flutter_app/lib/screens/scanner_screen.dart | 33 ++++++++++++++ +- flutter_app/pubspec.yaml | 1 + + +--- diff --git a/backend/DEVLOG.md b/backend/DEVLOG.md index bb10f6c..2e6308c 100644 --- a/backend/DEVLOG.md +++ b/backend/DEVLOG.md @@ -76,3 +76,19 @@ Keine Commits in dieser Session. - frontend/src/pages/admin/VorlageSection.tsx | 26 +++--- --- +## 2026-09-04 17:57 – 17:59 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 16 +++++++ +- flutter_app/DEVLOG.md | 18 ++++++++ +- flutter_app/README.md | 18 +++++++- +- flutter_app/lib/screens/objekt_list_screen.dart | 59 ++++++++++++++++++++++--- +- flutter_app/lib/screens/scanner_screen.dart | 33 ++++++++++++++ +- flutter_app/pubspec.yaml | 1 + + +--- diff --git a/backend/app/api/v1/endpoints/objekte.py b/backend/app/api/v1/endpoints/objekte.py index a6a231e..d5b420b 100644 --- a/backend/app/api/v1/endpoints/objekte.py +++ b/backend/app/api/v1/endpoints/objekte.py @@ -1,6 +1,6 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -10,7 +10,9 @@ from app.db.session import get_db from app.models.auth import KnotenTyp, RolleTyp, Systemknoten from app.models.objekt import Objekt from app.models.objektposition import Objektposition +from app.models.stammdaten import Material from app.models.vorlage import Beladungsvorlage +from app.services.label import generiere_code128_label_pdf from app.schemas.objekt import ( ObjektCreate, ObjektDuplizierenRequest, @@ -116,6 +118,42 @@ async def hole_objekt( return objekt +@router.get("/objekte/{objekt_id}/label.pdf") +async def objekt_label( + objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> Response: + """Druckfertiges Code128-Label (Karte 10): Code + Objektname als Klartext- + Fallback. Vektor-PDF, skaliert verlustfrei auf jede Labelgröße beim Druck.""" + objekt = await db.get(Objekt, objekt_id) + if objekt is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Objekt nicht gefunden") + pdf = generiere_code128_label_pdf(objekt.code, objekt.name) + return Response( + content=pdf, + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="{objekt.code}.pdf"'}, + ) + + +@router.get("/objektpositionen/{position_id}/label.pdf") +async def objektposition_label( + position_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> Response: + """Geräte-Etikett (Karte 10): eigener Code je Exemplar, Klartext-Zusatz ist + der Materialname (Objektname wäre bei mehreren Geräten am selben Objekt + nicht unterscheidend genug).""" + position = await db.get(Objektposition, position_id) + if position is None or position.code is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kein Geräte-Code vergeben") + material = await db.get(Material, position.material_id) + pdf = generiere_code128_label_pdf(position.code, material.name if material else None) + return Response( + content=pdf, + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="{position.code}.pdf"'}, + ) + + @router.get("/objekte/{objekt_id}/positionen", response_model=list[ObjektpositionRead]) async def liste_objektpositionen( objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) diff --git a/backend/app/services/label.py b/backend/app/services/label.py new file mode 100644 index 0000000..e4c5965 --- /dev/null +++ b/backend/app/services/label.py @@ -0,0 +1,57 @@ +import io + +from barcode import Code128 +from barcode.writer import ImageWriter +from PIL import Image +from reportlab.lib.units import mm +from reportlab.lib.utils import ImageReader +from reportlab.pdfgen import canvas + +# Karte 10: Code128 kameratauglich drucken - Mindesthöhe ~10mm, Ruhezone +# (quiet zone) >= 10 Strichbreiten links/rechts ist laut Karte die häufigste +# Ursache für Scan-Fehler bei selbstgedruckten Etiketten. module_width=0.3mm +# * 10 = 3mm Mindest-Ruhezone, quiet_zone hier bewusst höher (5mm) angesetzt. +_BARCODE_OPTIONEN = { + "module_width": 0.3, + "module_height": 10.0, + "quiet_zone": 5.0, + "font_size": 10, + "text_distance": 3.0, + "write_text": True, + "dpi": 300, +} +_RAND = 5 * mm +_MINDESTBREITE = 60 * mm + + +def generiere_code128_label_pdf(code: str, klartext_zusatz: str | None = None) -> bytes: + """Druckfertiges Vektor-Label: Code128-Barcode + Klartext-Zeile (Code selbst, + via write_text der Bibliothek) + optionale zweite Klartext-Zeile (z. B. + Objekt-/Materialname) als Scan-Ausfall-Fallback (Karte 10).""" + barcode_puffer = io.BytesIO() + Code128(code, writer=ImageWriter()).write(barcode_puffer, options=_BARCODE_OPTIONEN) + barcode_puffer.seek(0) + + bild = Image.open(barcode_puffer) + dpi = _BARCODE_OPTIONEN["dpi"] + breite_pt = bild.width / dpi * 72 + hoehe_pt = bild.height / dpi * 72 + + label_breite = max(breite_pt + 2 * _RAND, _MINDESTBREITE) + zusatz_hoehe = 14 if klartext_zusatz else 0 + label_hoehe = hoehe_pt + zusatz_hoehe + 2 * _RAND + + pdf_puffer = io.BytesIO() + c = canvas.Canvas(pdf_puffer, pagesize=(label_breite, label_hoehe)) + barcode_puffer.seek(0) + bild_x = (label_breite - breite_pt) / 2 + bild_y = label_hoehe - hoehe_pt - _RAND + c.drawImage(ImageReader(barcode_puffer), bild_x, bild_y, width=breite_pt, height=hoehe_pt) + + if klartext_zusatz: + c.setFont("Helvetica", 9) + c.drawCentredString(label_breite / 2, _RAND - 2, klartext_zusatz) + + c.showPage() + c.save() + return pdf_puffer.getvalue() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6bae882..a039032 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -18,6 +18,8 @@ dependencies = [ "pyjwt>=2.9,<2.10", "python-multipart>=0.0.12,<0.1", "aiosmtplib>=3.0,<3.1", + "python-barcode[images]>=0.15,<0.16", + "reportlab>=4.2,<4.3", ] [project.optional-dependencies] diff --git a/backend/tests/test_objekte.py b/backend/tests/test_objekte.py index f9ab1da..84fae53 100644 --- a/backend/tests/test_objekte.py +++ b/backend/tests/test_objekte.py @@ -249,3 +249,52 @@ async def test_naechster_objektposition_code( "/api/v1/objektpositionen/naechster-code?praefix=GR", headers=auth_header(token) ) assert response.json()["code"] == "GR-001043" + + +@pytest.mark.asyncio +async def test_objekt_label_pdf(client, db_session, objekttyp_factory, standort_factory, admin_user): + objekttyp = await objekttyp_factory("Rucksack Label") + standort = await standort_factory("Wache-Label") + vorlage = await erstelle_vorlage( + db_session, objekttyp_id=objekttyp.id, name="Label Standard", positionen=[] + ) + token = await login(client, "admin1") + erstellt = await client.post( + "/api/v1/objekte", + json={ + "code": "LB-01", + "name": "Label Rucksack", + "objekttyp_id": objekttyp.id, + "vorlage_id": vorlage.id, + "standort_id": standort.id, + }, + headers=auth_header(token), + ) + objekt_id = erstellt.json()["id"] + + response = await client.get(f"/api/v1/objekte/{objekt_id}/label.pdf", headers=auth_header(token)) + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + assert response.content.startswith(b"%PDF") + + +@pytest.mark.asyncio +async def test_objektposition_label_pdf_ohne_code_ist_404( + client, db_session, objekt_mit_position, materialverantwortlicher_user +): + objekt, material = objekt_mit_position + token = await login(client, "materialverantwortlicher1") + positionen = await client.get(f"/api/v1/objekte/{objekt.id}/positionen", headers=auth_header(token)) + position_id = positionen.json()[0]["id"] + + ohne_code = await client.get(f"/api/v1/objektpositionen/{position_id}/label.pdf", headers=auth_header(token)) + assert ohne_code.status_code == 404 + + await client.patch( + f"/api/v1/objekte/{objekt.id}/positionen/{position_id}", + json={"code": "GR-000999"}, + headers=auth_header(token), + ) + mit_code = await client.get(f"/api/v1/objektpositionen/{position_id}/label.pdf", headers=auth_header(token)) + assert mit_code.status_code == 200 + assert mit_code.content.startswith(b"%PDF")