Karte 10: Code128-Label als PDF (Objekt + Geräte-Exemplar)
CI / backend-tests (push) Successful in 1m5s
CI / frontend-build (push) Successful in 18s

app/services/label.py generiert druckfertiges Vektor-PDF (python-barcode +
reportlab): Mindesthöhe 10mm, Ruhezone 5mm, Klartext-Fallback als zweite
Zeile (Objekt-/Materialname). Endpunkte GET /objekte/{id}/label.pdf und
GET /objektpositionen/{id}/label.pdf (404 ohne vergebenen Geräte-Code).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-04 17:59:54 +02:00
co-authored by Claude Sonnet 5
parent d8d2816758
commit 66757e4cd6
6 changed files with 179 additions and 1 deletions
+16
View File
@@ -2607,3 +2607,19 @@ Keine Commits in dieser Session.
- frontend/src/styles/global.css | 24 +++++++++++ - 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 +
---
+16
View File
@@ -76,3 +76,19 @@ Keine Commits in dieser Session.
- frontend/src/pages/admin/VorlageSection.tsx | 26 +++--- - 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 +
---
+39 -1
View File
@@ -1,6 +1,6 @@
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession 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.auth import KnotenTyp, RolleTyp, Systemknoten
from app.models.objekt import Objekt from app.models.objekt import Objekt
from app.models.objektposition import Objektposition from app.models.objektposition import Objektposition
from app.models.stammdaten import Material
from app.models.vorlage import Beladungsvorlage from app.models.vorlage import Beladungsvorlage
from app.services.label import generiere_code128_label_pdf
from app.schemas.objekt import ( from app.schemas.objekt import (
ObjektCreate, ObjektCreate,
ObjektDuplizierenRequest, ObjektDuplizierenRequest,
@@ -116,6 +118,42 @@ async def hole_objekt(
return 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]) @router.get("/objekte/{objekt_id}/positionen", response_model=list[ObjektpositionRead])
async def liste_objektpositionen( async def liste_objektpositionen(
objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) objekt_id: int, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
+57
View File
@@ -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()
+2
View File
@@ -18,6 +18,8 @@ dependencies = [
"pyjwt>=2.9,<2.10", "pyjwt>=2.9,<2.10",
"python-multipart>=0.0.12,<0.1", "python-multipart>=0.0.12,<0.1",
"aiosmtplib>=3.0,<3.1", "aiosmtplib>=3.0,<3.1",
"python-barcode[images]>=0.15,<0.16",
"reportlab>=4.2,<4.3",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+49
View File
@@ -249,3 +249,52 @@ async def test_naechster_objektposition_code(
"/api/v1/objektpositionen/naechster-code?praefix=GR", headers=auth_header(token) "/api/v1/objektpositionen/naechster-code?praefix=GR", headers=auth_header(token)
) )
assert response.json()["code"] == "GR-001043" 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")