Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln
Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende: Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich ueber Open Products Facts. Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,17 +2,41 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..crud import product_to_out
|
||||
from ..crud import product_to_out, product_tracking
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Barcode, BaseUnit, Category, Group, Product, ProductImage, User
|
||||
from ..models import (
|
||||
Barcode,
|
||||
BaseUnit,
|
||||
Category,
|
||||
Group,
|
||||
Location,
|
||||
Movement,
|
||||
MovementType,
|
||||
Product,
|
||||
ProductImage,
|
||||
RemovalReason,
|
||||
Shop,
|
||||
User,
|
||||
)
|
||||
from ..off import lookup_barcode
|
||||
from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate
|
||||
from ..schemas import (
|
||||
BarcodeCreate,
|
||||
LookupResult,
|
||||
ProductCreate,
|
||||
ProductOut,
|
||||
ProductUpdate,
|
||||
RemovalHistoryItem,
|
||||
RemovalStat,
|
||||
RemovalSummary,
|
||||
)
|
||||
from ..services import images
|
||||
from ..services.categories import suggest_category
|
||||
from .categories import descendant_ids
|
||||
from ..services.conversion import ConversionError, resolve_product_unit
|
||||
from ..services.fields import FieldError, apply_field_values
|
||||
from ..services.group_codes import detach as detach_group_code, sync as sync_group_code
|
||||
from ..services.stock import removal_stats
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["products"])
|
||||
|
||||
@@ -92,6 +116,56 @@ def get_product(
|
||||
return product_to_out(db, product)
|
||||
|
||||
|
||||
@router.get("/{product_id}/removals", response_model=RemovalSummary)
|
||||
def product_removals(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> RemovalSummary:
|
||||
"""Entnahmen mit Grund: Summe je Grund plus die jüngsten Einträge."""
|
||||
product = db.get(Product, product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||
|
||||
stats = [
|
||||
RemovalStat(reason=RemovalReason(grund), quantity=v["quantity"], count=v["count"])
|
||||
for grund, v in removal_stats(db, product_id).items()
|
||||
]
|
||||
stats.sort(key=lambda s: s.quantity, reverse=True)
|
||||
|
||||
rows = (
|
||||
db.query(Movement)
|
||||
.filter(
|
||||
Movement.product_id == product_id,
|
||||
Movement.type == MovementType.out,
|
||||
Movement.reason.isnot(None),
|
||||
)
|
||||
.order_by(Movement.created_at.desc())
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
loc_names = {loc.id: loc.name for loc in db.query(Location).all()}
|
||||
user_ids = {m.user_id for m in rows if m.user_id}
|
||||
users = (
|
||||
{u.id: u.username for u in db.query(User).filter(User.id.in_(user_ids)).all()}
|
||||
if user_ids
|
||||
else {}
|
||||
)
|
||||
history = [
|
||||
RemovalHistoryItem(
|
||||
reason=RemovalReason(m.reason),
|
||||
quantity=m.quantity,
|
||||
location_id=m.location_id,
|
||||
location_name=loc_names.get(m.location_id),
|
||||
note=m.note,
|
||||
username=users.get(m.user_id),
|
||||
created_at=m.created_at,
|
||||
)
|
||||
for m in rows
|
||||
]
|
||||
return RemovalSummary(stats=stats, history=history)
|
||||
|
||||
|
||||
@router.get("/{product_id}/off", response_model=LookupResult)
|
||||
def off_vergleich(
|
||||
product_id: int,
|
||||
@@ -123,8 +197,10 @@ def off_vergleich(
|
||||
"Dieser Artikel hat keinen Barcode – ohne den kann Open Food Facts nichts finden.",
|
||||
)
|
||||
|
||||
# Gegenstände zuerst in der allgemeinen Produkt-DB nachschlagen, Lebensmittel in OFF.
|
||||
prefer = "object" if product_tracking(db, product) == "object" else None
|
||||
for code in codes:
|
||||
suggestion = lookup_barcode(code)
|
||||
suggestion = lookup_barcode(code, prefer=prefer)
|
||||
if suggestion:
|
||||
category = suggest_category(db, suggestion)
|
||||
return LookupResult(
|
||||
@@ -183,6 +259,8 @@ def create_product(
|
||||
base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id)
|
||||
except ConversionError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
if payload.shop_id is not None and db.get(Shop, payload.shop_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||
product = Product(
|
||||
barcode=payload.barcode or None,
|
||||
name=payload.name,
|
||||
@@ -198,11 +276,17 @@ def create_product(
|
||||
min_stock=payload.min_stock,
|
||||
min_stock_unit_id=payload.min_stock_unit_id,
|
||||
min_stock_in_packages=bool(payload.min_stock_in_packages),
|
||||
shop_id=payload.shop_id,
|
||||
product_url=payload.product_url or None,
|
||||
source="manual",
|
||||
)
|
||||
db.add(product)
|
||||
db.flush() # product.id fuer den Gruppen-Code
|
||||
sync_group_code(db, product)
|
||||
try:
|
||||
apply_field_values(db, product, payload.field_values)
|
||||
except FieldError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
if product.image_url:
|
||||
# Gleich beim Anlegen holen. Schlaegt es fehl, entsteht kein Fehler:
|
||||
# Ein fehlendes Bild darf das Anlegen eines Artikels nicht verhindern.
|
||||
@@ -224,6 +308,8 @@ def update_product(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
# Feldwerte sind keine Spalte, sondern eigene Zeilen – getrennt behandeln.
|
||||
field_values = data.pop("field_values", None)
|
||||
if "barcode" in data and data["barcode"]:
|
||||
clash = (
|
||||
db.query(Product)
|
||||
@@ -234,6 +320,8 @@ def update_product(
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits"
|
||||
)
|
||||
if data.get("shop_id") is not None and db.get(Shop, data["shop_id"]) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||
# Einheit: unit_id (falls gesetzt) bestimmt base_unit + Anzeigeeinheit.
|
||||
if "unit_id" in data:
|
||||
unit_id = data.pop("unit_id")
|
||||
@@ -253,6 +341,11 @@ def update_product(
|
||||
data["date_precision"] = data["date_precision"].value
|
||||
for field, value in data.items():
|
||||
setattr(product, field, value)
|
||||
if field_values is not None:
|
||||
try:
|
||||
apply_field_values(db, product, field_values)
|
||||
except FieldError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
# Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen.
|
||||
sync_group_code(db, product)
|
||||
if "image_url" in data:
|
||||
|
||||
Reference in New Issue
Block a user