Files
Vorrania/backend/app/routers/items.py
T
ScarriffleandClaude Opus 5 ca4e9a2264 Einzelstueck anlegen: im Verlauf sichtbar und vom Artikel aus erreichbar
Zwei Luecken, die zusammen den Eindruck machten, ein angelegtes Einzelstueck
sei verschwunden.

Der Verlauf kannte nur das Verschwinden. remove_item schreibt seit jeher eine
Bewegung, create_items nicht - Abgaenge von Einzelstuecken tauchten also auf,
Zugaenge nie, im Verlauf wie im Dashboard. Jetzt schreibt das Anlegen je Stueck
eine in-Bewegung mit UID (oder der eigenen Notiz, falls angegeben), Lagerort und
Benutzer, spiegelbildlich zum Entfernen. Der Bestand bleibt unberuehrt: er
zaehlt bei Einzelstuecken die Zeilen in items und rechnet nicht aus Bewegungen.

Und der Weg dorthin fehlte. ProductFormView liefert den angelegten Artikel, aber
zwei der drei Aufrufer warfen ihn weg ("{ _ in showNew = false }") - nur der
Einlagern-Weg nutzte ihn. "Einzelstuecke anlegen" steckt ausschliesslich in der
Artikelansicht, die man vom Scan-Tab und aus den Listen nach dem Anlegen gar
nicht erreicht. Man musste den frischen Artikel von Hand wiederfinden.

Bei Gegenstaenden mit eigener UID je Stueck oeffnet sich jetzt direkt das
Anlege-Formular. Gesetzt wird das im onDismiss des Artikelformulars: beides im
selben Zug zu setzen verschluckt SwiftUI, weil das zweite Sheet erscheinen soll,
waehrend das erste noch verschwindet.

Der Parameter von create_items heisst dadurch user statt _; die drei Testaufrufe
sind mitgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:12:42 +02:00

370 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Einzelstücke (Items) eines Gegenstands: anlegen, ändern, entfernen, per UID finden.
Ein Item ist ein physisches Exemplar mit eigener UID/QR und eigenen Angaben
(Lagerort, Kaufdatum, Garantie, Bezugsquelle, Notiz). Nur sinnvoll für
Gegenstands-Produkte mit ``Product.individual``.
"""
import re
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session, joinedload
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import (
Item,
ItemDocument,
ItemLoan,
Location,
Movement,
MovementType,
Product,
Shop,
User,
)
from ..schemas import (
DocumentSuggestions,
ItemCreate,
ItemDocumentUploadOut,
ItemOut,
ItemRemove,
ItemUpdate,
)
from ..services.items import generate_uid, item_to_out
from ..services.warranty import (
extract_pdf_text,
guess_acquired_on,
guess_price_candidates,
guess_price_cents,
guess_shop,
guess_warranty_until,
)
router = APIRouter(tags=["items"])
# Belege sind meist kleine PDFs; die Grenze schuetzt vor Ausreissern und passt
# zur nginx-Grenze (client_max_body_size 12m).
DOC_MAX_BYTES = 10 * 1024 * 1024
def _product_or_404(db: Session, product_id: int) -> Product:
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
return product
def _item_or_404(db: Session, item_id: int) -> Item:
item = db.get(Item, item_id)
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einzelstück nicht gefunden")
return item
def _check_refs(db: Session, shop_id: int | None, location_id: str | None) -> None:
if shop_id is not None and db.get(Shop, shop_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
if location_id is not None and db.get(Location, location_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
@router.get("/products/{product_id}/items", response_model=list[ItemOut])
def list_items(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ItemOut]:
_product_or_404(db, product_id)
rows = db.query(Item).filter(Item.product_id == product_id).order_by(Item.id).all()
return [item_to_out(i) for i in rows]
@router.post(
"/products/{product_id}/items", response_model=list[ItemOut],
status_code=status.HTTP_201_CREATED,
)
def create_items(
product_id: int,
payload: ItemCreate,
db: Session = Depends(get_db),
user: User = Depends(require_admin),
) -> list[ItemOut]:
"""Ein oder mehrere (count) Einzelstücke mit gemeinsamen Startwerten anlegen."""
product = _product_or_404(db, product_id)
_check_refs(db, payload.shop_id, payload.location_id)
created: list[Item] = []
for _ in range(payload.count):
item = Item(
uid=generate_uid(db),
product_id=product.id,
location_id=payload.location_id,
shop_id=payload.shop_id,
acquired_on=payload.acquired_on,
warranty_until=payload.warranty_until,
note=payload.note,
price_cents=payload.price_cents,
currency=payload.currency,
)
db.add(item)
db.flush()
# Wie beim Entfernen eine Bewegung schreiben (siehe remove_item). Ohne
# sie kannte der Verlauf nur das Verschwinden eines Einzelstuecks, nie
# sein Entstehen - und das Dashboard zeigte Abgaenge ohne Zugaenge.
# Fuer den Bestand aendert das nichts: der zaehlt bei Einzelstuecken die
# Zeilen in items (services/stock.py), er rechnet nicht aus Bewegungen.
db.add(
Movement(
product_id=product.id,
lot_id=None,
user_id=user.id,
type=MovementType.in_,
quantity=1,
unit_used="Stück",
note=(payload.note or f"Einzelstück {item.uid}"),
location_id=payload.location_id,
)
)
created.append(item)
db.commit()
for item in created:
db.refresh(item)
return [item_to_out(i) for i in created]
@router.get("/items", response_model=list[ItemOut])
def list_all_items(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ItemOut]:
"""Alle Einzelstücke über alle Produkte – Grundlage der Einzelstück-Liste.
Angereichert mit Produkt-, Kategorie-, Lagerort- und Shop-Angaben; die
Beziehungen werden vorgeladen, damit die Liste nicht in N+1-Abfragen zerfällt.
"""
rows = (
db.query(Item)
.options(
joinedload(Item.product).joinedload(Product.category),
joinedload(Item.location),
joinedload(Item.shop),
joinedload(Item.documents),
# Verleihe samt Konto mitladen: Sonst fragt item_to_out je
# verliehenem Stueck einzeln nach (N+1).
joinedload(Item.loans).joinedload(ItemLoan.user),
)
.order_by(Item.id)
.all()
)
return [item_to_out(i) for i in rows]
@router.get("/items/by-uid/{uid}", response_model=ItemOut)
def item_by_uid(
uid: str,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> ItemOut:
"""QR-Auflösung: UID → Einzelstück (mit Produktangaben)."""
item = db.query(Item).filter(Item.uid == uid.strip().upper()).first()
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Einzelstück mit dieser UID")
return item_to_out(item)
@router.get("/items/{item_id}", response_model=ItemOut)
def get_item(
item_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> ItemOut:
return item_to_out(_item_or_404(db, item_id))
@router.patch("/items/{item_id}", response_model=ItemOut)
def update_item(
item_id: int,
payload: ItemUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ItemOut:
item = _item_or_404(db, item_id)
data = payload.model_dump(exclude_unset=True)
_check_refs(db, data.get("shop_id"), data.get("location_id"))
for field, value in data.items():
setattr(item, field, value)
db.commit()
db.refresh(item)
return item_to_out(item)
@router.post("/items/{item_id}/remove", status_code=status.HTTP_204_NO_CONTENT)
def remove_item(
item_id: int,
payload: ItemRemove,
db: Session = Depends(get_db),
user: User = Depends(require_admin),
) -> None:
"""Einzelstück mit Grund entfernen – als Bewegung protokolliert (Statistik je Modell)."""
item = _item_or_404(db, item_id)
db.add(
Movement(
product_id=item.product_id,
lot_id=None,
user_id=user.id,
type=MovementType.out,
quantity=1,
unit_used="Stück",
note=(payload.note or f"Einzelstück {item.uid}"),
location_id=item.location_id,
reason=payload.reason.value,
)
)
db.delete(item)
db.commit()
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(
item_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Einzelstück ohne Grund löschen (Korrektur)."""
db.delete(_item_or_404(db, item_id))
db.commit()
# --------------------------------------------------------------------------
# Belege (Rechnung/Garantieschein) je Einzelstück
# --------------------------------------------------------------------------
def _document_or_404(db: Session, item_id: int, doc_id: int) -> ItemDocument:
doc = (
db.query(ItemDocument)
.filter(ItemDocument.id == doc_id, ItemDocument.item_id == item_id)
.first()
)
if doc is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Beleg nicht gefunden")
return doc
def _safe_filename(name: str | None) -> str:
"""Steuerzeichen/Anführungszeichen raus – sonst Header-Injektion im Download."""
cleaned = re.sub(r'[\r\n"]', "", (name or "").strip())
return cleaned[:255] or "beleg"
def _read_document(file: UploadFile, data: bytes) -> str:
"""Validiert Größe/Typ eines hochgeladenen Belegs und gibt den Content-Type."""
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
if len(data) > DOC_MAX_BYTES:
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"Datei zu groß (max. {DOC_MAX_BYTES // (1024 * 1024)} MB).",
)
content_type = file.content_type or "application/octet-stream"
if content_type != "application/pdf" and not content_type.startswith("image/"):
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt."
)
return content_type
def _doc_suggestions(
db: Session, data: bytes, content_type: str, item_acquired_on=None
) -> DocumentSuggestions:
"""Garantie, Preis, Kaufdatum und Shop aus einem PDF schätzen (Bilder: leer)."""
if content_type != "application/pdf":
return DocumentSuggestions()
text = extract_pdf_text(data)
shop_id, shop_name = guess_shop(text, [(s.id, s.name) for s in db.query(Shop).all()])
return DocumentSuggestions(
suggested_warranty_until=guess_warranty_until(text, acquired_on=item_acquired_on),
suggested_price_cents=guess_price_cents(text),
suggested_price_candidates=guess_price_candidates(text),
suggested_acquired_on=guess_acquired_on(text),
suggested_shop_id=shop_id,
suggested_shop_name=shop_name,
)
@router.post(
"/items/{item_id}/documents",
response_model=ItemDocumentUploadOut,
status_code=status.HTTP_201_CREATED,
)
async def upload_item_document(
item_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ItemDocumentUploadOut:
"""Beleg (PDF oder Bild) hochladen. Bei PDF werden Garantie, Preis, Kaufdatum
und Shop vorgeschlagen."""
item = _item_or_404(db, item_id)
data = await file.read()
content_type = _read_document(file, data)
doc = ItemDocument(
item_id=item.id,
filename=_safe_filename(file.filename),
content_type=content_type,
data=data,
)
db.add(doc)
db.commit()
db.refresh(doc)
vorschlag = _doc_suggestions(db, data, content_type, item.acquired_on)
return ItemDocumentUploadOut(
id=doc.id,
filename=doc.filename,
content_type=doc.content_type,
uploaded_at=doc.uploaded_at,
**vorschlag.model_dump(),
)
@router.post("/items/analyze-document", response_model=DocumentSuggestions)
async def analyze_item_document(
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> DocumentSuggestions:
"""Beleg nur analysieren (nichts speichern) – zum Vorbefüllen beim Anlegen."""
data = await file.read()
content_type = _read_document(file, data)
return _doc_suggestions(db, data, content_type)
@router.get("/items/{item_id}/documents/{doc_id}")
def get_item_document(
item_id: int,
doc_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Response:
"""Beleg anzeigen/herunterladen."""
doc = _document_or_404(db, item_id, doc_id)
return Response(
content=doc.data,
media_type=doc.content_type,
headers={"Content-Disposition": f'inline; filename="{doc.filename}"'},
)
@router.delete(
"/items/{item_id}/documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT
)
def delete_item_document(
item_id: int,
doc_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
db.delete(_document_or_404(db, item_id, doc_id))
db.commit()