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:
Scarriffle
2026-07-25 15:33:56 +02:00
parent 580afcc133
commit 5b952524d7
29 changed files with 3116 additions and 115 deletions

View File

@@ -18,8 +18,10 @@ class Settings(BaseSettings):
admin_username: str = "admin"
admin_password: str = "changeme"
# Open Food Facts
# Open Food Facts (Lebensmittel)
off_base_url: str = "https://world.openfoodfacts.org"
# Open Products Facts (allgemeine Produkte / Gegenstände) gleiche API-Struktur.
opf_base_url: str = "https://world.openproductsfacts.org"
off_timeout_seconds: float = 8.0
# Behaviour

View File

@@ -7,12 +7,25 @@ from datetime import date
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from .models import Barcode, Lot, Product
from .models import Barcode, Category, CategoryTracking, Lot, Product
from .schemas import BarcodeOut, ProductOut
from .services.conversion import KIND_OF_BASE, display_unit_info
from .services.stock import current_stock
def product_tracking(db: Session, product: Product) -> str:
"""Verwaltungsart eines Artikels: aus seiner Kategorie abgeleitet.
Ohne Kategorie gilt "food" so bleibt das Verhalten bestehender
(reiner Lebensmittel-)Installationen unverändert. Ein Artikel in einer
Gegenstands-Kategorie wird als "object" geführt.
"""
if product.category_id is None:
return CategoryTracking.food.value
cat = product.category or db.get(Category, product.category_id)
return cat.tracking if cat and cat.tracking else CategoryTracking.food.value
def product_to_out(db: Session, product: Product) -> ProductOut:
out = ProductOut.model_validate(product)
out.stock = current_stock(db, product.id)
@@ -31,6 +44,8 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all()
]
out.category_name = product.category.name if product.category else None
out.tracking = CategoryTracking(product_tracking(db, product))
out.shop_name = product.shop.name if product.shop else None
out.kind = KIND_OF_BASE[product.base_unit].value
name, factor = display_unit_info(product)
out.unit_name = name

View File

@@ -12,12 +12,14 @@ from .routers import (
branding,
categories,
dashboard,
field_definitions,
groups,
locations,
maintenance,
package_types,
products,
settings as settings_router,
shops,
stock,
transfer,
units,
@@ -28,6 +30,7 @@ from .seed import (
ensure_builtin_categories,
ensure_builtin_package_types,
ensure_builtin_units,
ensure_example_object_categories,
ensure_first_admin,
)
from .services.group_codes import backfill as backfill_group_codes
@@ -66,6 +69,18 @@ def _ensure_schema() -> None:
"NOT NULL DEFAULT 'Übersicht'",
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS position INTEGER "
"NOT NULL DEFAULT 0",
# Gegenstands-Verwaltung: Verwaltungsart je Kategorie. Bestehende
# (reine Lebensmittel-)Kategorien werden dabei auf "food" gesetzt.
"ALTER TABLE categories ADD COLUMN IF NOT EXISTS tracking VARCHAR(16) "
"NOT NULL DEFAULT 'food'",
# Bezugsquelle und Onlineshop-Link am Artikel (nur für Gegenstände genutzt).
"ALTER TABLE products ADD COLUMN IF NOT EXISTS shop_id INTEGER "
"REFERENCES shops(id) ON DELETE SET NULL",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS product_url VARCHAR(1024)",
# Bewegungen: Lagerort (Gegenstands-Buchungen) und Entnahmegrund.
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id INTEGER "
"REFERENCES locations(id) ON DELETE SET NULL",
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
]
with engine.begin() as conn:
for stmt in stmts:
@@ -93,6 +108,7 @@ async def lifespan(app: FastAPI):
ensure_builtin_units(db)
ensure_builtin_package_types(db)
ensure_builtin_categories(db)
ensure_example_object_categories(db)
ensure_first_admin(db)
# Codes bestehender Gruppen-Zuordnungen nachziehen.
backfill_group_codes(db)
@@ -136,3 +152,5 @@ app.include_router(branding.router)
app.include_router(categories.router)
app.include_router(maintenance.router)
app.include_router(dashboard.router)
app.include_router(shops.router)
app.include_router(field_definitions.router)

View File

@@ -60,6 +60,39 @@ class UnitKind(str, enum.Enum):
volume = "volume" # Basis: Milliliter
class CategoryTracking(str, enum.Enum):
"""Wie Artikel einer Kategorie verwaltet werden.
food = Lebensmittel: Chargen mit MHD und FEFO (bestehende Logik, unverändert).
object = Gegenstand: nur Menge pro Lagerort, kein MHD, keine Chargen.
Bewusst als kurzer String gespeichert (wie DatePrecision), damit sich die
Spalte per ADD COLUMN nachziehen lässt, ohne einen Postgres-Enumtyp anzulegen.
"""
food = "food"
object = "object"
class FieldType(str, enum.Enum):
"""Art eines selbst definierten Feldes an einer Kategorie."""
text = "text" # einzeilig
textarea = "textarea" # mehrzeilig
number = "number" # Zahl, optional mit Einheit
date = "date" # Datum (z.B. Kaufdatum)
select = "select" # Auswahlliste (options = JSON-Liste)
boolean = "boolean" # Ja/Nein
class RemovalReason(str, enum.Enum):
"""Grund einer Gegenstands-Entnahme aus dem Bestand."""
lost = "lost" # verloren
broken = "broken" # kaputt
given_away = "given_away" # verschenkt
sold = "sold" # verkauft
used_up = "used_up" # aufgebraucht
other = "other" # sonstiges
class Unit(Base):
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
@@ -130,6 +163,15 @@ class Category(Base):
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Verwaltungsart der Artikel dieser Kategorie: "food" (Chargen+MHD) oder
# "object" (Menge je Lagerort). Neue Kategorien sind Gegenstände; bestehende
# (reine Lebensmittel-Installationen) werden bei der Migration auf "food"
# gesetzt. Als kurzer String wie date_precision, damit per ADD COLUMN nachziehbar.
tracking: Mapped[str] = mapped_column(
String(16), nullable=False,
default=CategoryTracking.object.value,
server_default=CategoryTracking.food.value,
)
class Product(Base):
@@ -181,15 +223,26 @@ class Product(Base):
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF
# Nur für Gegenstände: Bezugsquelle ("gekauft bei") und Link zum Onlineshop.
shop_id: Mapped[int | None] = mapped_column(
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
)
product_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
group: Mapped[Group | None] = relationship(back_populates="products")
category: Mapped[Category | None] = relationship()
shop: Mapped[Shop | None] = relationship()
display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id])
min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id])
lots: Mapped[list[Lot]] = relationship(
back_populates="product", cascade="all, delete-orphan"
)
field_values: Mapped[list[ProductFieldValue]] = relationship(
back_populates="product", cascade="all, delete-orphan"
)
class ApiToken(Base):
@@ -274,6 +327,14 @@ class Movement(Base):
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered
note: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Bei Gegenstands-Buchungen der betroffene Lagerort (Lebensmittel führen den
# Ort an der Charge/Lot). Nullable, damit bestehende Bewegungen gültig bleiben.
location_id: Mapped[int | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
# Grund einer Entnahme (lost/broken/…), nur bei type=out aus dem Entfernen-Dialog.
# Als kurzer String, damit per ADD COLUMN nachziehbar (kein Postgres-Enumtyp).
reason: Mapped[str | None] = mapped_column(String(16), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -369,3 +430,71 @@ class Setting(Base):
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(String(255), nullable=False)
class Shop(Base):
"""Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei").
Vom Admin verwaltbare Liste (wie Lagerorte). Am Artikel optional "unbekannt"
oder "mehrere Herkünfte" bleibt einfach leer.
"""
__tablename__ = "shops"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
website: Mapped[str | None] = mapped_column(String(1024), nullable=True)
class FieldDefinition(Base):
"""Selbst definiertes Feld an einer Kategorie (z.B. „Kapazität“ in mAh).
Gilt für Artikel dieser Kategorie und über die Vererbung im Kategorie-Baum
auch für deren Unterkategorien. Der konkrete Wert je Artikel steht in
:class:`ProductFieldValue`.
"""
__tablename__ = "field_definitions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
category_id: Mapped[int] = mapped_column(
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
)
label: Mapped[str] = mapped_column(String(120), nullable=False)
# Maschinenlesbarer Schlüssel (aus dem Label abgeleitet) für Export/Anzeige.
key: Mapped[str] = mapped_column(String(120), nullable=False)
field_type: Mapped[str] = mapped_column(
String(16), nullable=False, default=FieldType.text.value
)
# Einheit für Zahlenfelder (z.B. "mAh", "g", "W").
unit: Mapped[str | None] = mapped_column(String(32), nullable=True)
# JSON-Liste der Auswahlmöglichkeiten für field_type == "select".
options: Mapped[str | None] = mapped_column(Text, nullable=True)
required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class ProductFieldValue(Base):
"""Wert eines selbst definierten Feldes für einen konkreten Artikel."""
__tablename__ = "product_field_values"
__table_args__ = (
UniqueConstraint(
"product_id", "field_definition_id", name="uq_pfv_product_field"
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
)
field_definition_id: Mapped[int] = mapped_column(
ForeignKey("field_definitions.id", ondelete="CASCADE"), nullable=False
)
# Immer als Text gespeichert; typgerecht interpretiert wird beim Lesen/Schreiben.
value: Mapped[str | None] = mapped_column(Text, nullable=True)
product: Mapped[Product] = relationship(back_populates="field_values")
field_definition: Mapped[FieldDefinition] = relationship()

View File

@@ -42,15 +42,16 @@ def parse_quantity(quantity: str | None) -> tuple[str, float | None]:
return base_unit, (package_size if package_size > 0 else None)
def lookup_barcode(barcode: str) -> dict | None:
"""Fragt Open Food Facts nach einem Barcode.
def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
"""Fragt eine Open-Facts-Instanz (OFF oder Open Products Facts) nach einem Barcode.
Gibt ein vorbefülltes Produkt-Dict zurück oder None, wenn nicht gefunden.
Beide Dienste teilen sich dieselbe API-Struktur; es unterscheidet sich nur die
Basis-URL. Gibt ein vorbefülltes Produkt-Dict zurück oder None.
"""
# v0-API: liefert bei Treffer status=1 + product, bei Nicht-Treffer status=0
# (HTTP 200). Die v2-API hat kein status-Feld und antwortet mit HTTP 404,
# weshalb wir bewusst v0 nutzen.
url = f"{settings.off_base_url}/api/v0/product/{barcode}.json"
url = f"{base_url}/api/v0/product/{barcode}.json"
try:
resp = httpx.get(
url,
@@ -96,6 +97,26 @@ def lookup_barcode(barcode: str) -> dict | None:
"quantity_text": product.get("quantity"),
"category_suggestion": categories.split(",")[0].strip() if categories else None,
"category_tags": category_tags,
"source": "off",
"source": source,
"off_raw": json.dumps(product)[:20000],
}
def lookup_barcode(barcode: str, prefer: str | None = None) -> dict | None:
"""Sucht einen Barcode in den offenen Datenbanken.
Beim Scannen steht die Kategorie noch nicht fest, deshalb werden beide Quellen
der Reihe nach befragt: Lebensmittel (Open Food Facts) und allgemeine Produkte
(Open Products Facts). ``prefer="object"`` stellt die allgemeine Produkt-DB nach
vorn (z.B. beim erneuten Abgleich eines Gegenstands), sonst gewinnt Essen.
"""
off = (settings.off_base_url, "off")
opf = (settings.opf_base_url, "opf")
reihenfolge = [opf, off] if prefer == "object" else [off, opf]
for base_url, source in reihenfolge:
if not base_url:
continue
treffer = _lookup_at(barcode, base_url, source)
if treffer is not None:
return treffer
return None

View File

@@ -10,8 +10,17 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Category, Product, User
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate
from ..models import (
Category,
CategoryTracking,
FieldDefinition,
Product,
ProductFieldValue,
User,
)
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut
from ..services.fields import effective_field_definitions
from .field_definitions import to_out as field_to_out
router = APIRouter(prefix="/categories", tags=["categories"])
@@ -61,9 +70,19 @@ def create_category(
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> CategoryOut:
if payload.parent_id is not None and db.get(Category, payload.parent_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden")
category = Category(name=payload.name, parent_id=payload.parent_id)
parent = None
if payload.parent_id is not None:
parent = db.get(Category, payload.parent_id)
if parent is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden")
# Modus: ausdrücklich gewählt > vom Elternteil geerbt > neue Oberkategorie = Gegenstand.
if payload.tracking is not None:
tracking = payload.tracking.value
elif parent is not None:
tracking = parent.tracking
else:
tracking = CategoryTracking.object.value
category = Category(name=payload.name, parent_id=payload.parent_id, tracking=tracking)
db.add(category)
db.commit()
db.refresh(category)
@@ -95,6 +114,9 @@ def update_category(
"Unterkategorien untergeordnet werden.",
)
# Modus als kurzen String ablegen (Spalte ist String, nicht Enum-Typ).
if "tracking" in data and data["tracking"] is not None:
data["tracking"] = CategoryTracking(data["tracking"]).value
for field, value in data.items():
setattr(category, field, value)
db.commit()
@@ -120,6 +142,35 @@ def delete_category(
db.query(Category).filter(Category.parent_id == category_id).update(
{Category.parent_id: None}
)
# Eigene Felder dieser Kategorie samt aller erfassten Werte entfernen.
feld_ids = [
fid
for (fid,) in db.query(FieldDefinition.id)
.filter(FieldDefinition.category_id == category_id)
.all()
]
if feld_ids:
db.query(ProductFieldValue).filter(
ProductFieldValue.field_definition_id.in_(feld_ids)
).delete(synchronize_session=False)
db.query(FieldDefinition).filter(
FieldDefinition.category_id == category_id
).delete(synchronize_session=False)
db.delete(category)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{category_id}/fields", response_model=list[FieldDefinitionOut])
def category_fields(
category_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[FieldDefinitionOut]:
"""Alle für eine Kategorie geltenden Felder inkl. der von Oberkategorien geerbten."""
if db.get(Category, category_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
return [
field_to_out(fd, inherited=inherited)
for fd, inherited in effective_field_definitions(db, category_id)
]

View File

@@ -0,0 +1,143 @@
"""Verwaltung der selbst definierten Felder je Kategorie."""
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Category, FieldDefinition, FieldType, ProductFieldValue, User
from ..schemas import FieldDefinitionCreate, FieldDefinitionOut, FieldDefinitionUpdate
from ..services.fields import options_list, slugify
router = APIRouter(prefix="/field-definitions", tags=["field-definitions"])
def to_out(fd: FieldDefinition, inherited: bool = False) -> FieldDefinitionOut:
return FieldDefinitionOut(
id=fd.id,
category_id=fd.category_id,
label=fd.label,
key=fd.key,
field_type=FieldType(fd.field_type),
unit=fd.unit,
options=options_list(fd),
required=fd.required,
position=fd.position,
is_builtin=fd.is_builtin,
inherited=inherited,
)
def _unique_key(db: Session, category_id: int, label: str, exclude_id: int | None = None) -> str:
"""Eindeutigen Schlüssel je Kategorie erzeugen (kapazitaet, kapazitaet_2, …)."""
basis = slugify(label)
kandidat = basis
n = 1
while True:
query = db.query(FieldDefinition).filter(
FieldDefinition.category_id == category_id, FieldDefinition.key == kandidat
)
if exclude_id is not None:
query = query.filter(FieldDefinition.id != exclude_id)
if query.first() is None:
return kandidat
n += 1
kandidat = f"{basis}_{n}"
def _options_json(field_type: FieldType, options: list[str] | None) -> str | None:
if field_type != FieldType.select:
return None
return json.dumps([o.strip() for o in (options or []) if o.strip()])
@router.get("", response_model=list[FieldDefinitionOut])
def list_field_definitions(
category_id: int | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[FieldDefinitionOut]:
"""Felder auflisten ohne category_id alle, sonst nur die dieser Kategorie."""
query = db.query(FieldDefinition)
if category_id is not None:
query = query.filter(FieldDefinition.category_id == category_id)
rows = query.order_by(
FieldDefinition.category_id, FieldDefinition.position, FieldDefinition.id
).all()
return [to_out(fd) for fd in rows]
@router.post("", response_model=FieldDefinitionOut, status_code=status.HTTP_201_CREATED)
def create_field_definition(
payload: FieldDefinitionCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> FieldDefinitionOut:
if db.get(Category, payload.category_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
fd = FieldDefinition(
category_id=payload.category_id,
label=payload.label.strip(),
key=_unique_key(db, payload.category_id, payload.label),
field_type=payload.field_type.value,
unit=(payload.unit or None),
options=_options_json(payload.field_type, payload.options),
required=bool(payload.required),
position=payload.position,
)
db.add(fd)
db.commit()
db.refresh(fd)
return to_out(fd)
@router.patch("/{field_id}", response_model=FieldDefinitionOut)
def update_field_definition(
field_id: int,
payload: FieldDefinitionUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> FieldDefinitionOut:
fd = db.get(FieldDefinition, field_id)
if fd is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Feld nicht gefunden")
data = payload.model_dump(exclude_unset=True)
if "label" in data and data["label"]:
fd.label = data["label"].strip()
fd.key = _unique_key(db, fd.category_id, fd.label, exclude_id=fd.id)
if "field_type" in data and data["field_type"] is not None:
fd.field_type = data["field_type"].value
if "unit" in data:
fd.unit = data["unit"] or None
if "required" in data and data["required"] is not None:
fd.required = bool(data["required"])
if "position" in data and data["position"] is not None:
fd.position = data["position"]
# Optionen immer passend zum (ggf. neuen) Typ ablegen.
if "options" in data or "field_type" in data:
neuer_typ = FieldType(fd.field_type)
optionen = data["options"] if "options" in data else options_list(fd)
fd.options = _options_json(neuer_typ, optionen)
db.commit()
db.refresh(fd)
return to_out(fd)
@router.delete("/{field_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_field_definition(
field_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Löscht das Feld samt aller dazu erfassten Werte (ON DELETE CASCADE)."""
fd = db.get(FieldDefinition, field_id)
if fd is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Feld nicht gefunden")
db.query(ProductFieldValue).filter(
ProductFieldValue.field_definition_id == field_id
).delete()
db.delete(fd)
db.commit()

View File

@@ -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:

View File

@@ -0,0 +1,88 @@
"""Shops / Bezugsquellen: verwaltbare Liste für „gekauft bei“ (nur Gegenstände)."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Product, Shop, User
from ..schemas import ShopCreate, ShopOut, ShopUpdate
router = APIRouter(prefix="/shops", tags=["shops"])
def _to_out(db: Session, shop: Shop) -> ShopOut:
out = ShopOut.model_validate(shop)
out.product_count = db.query(Product).filter(Product.shop_id == shop.id).count()
return out
@router.get("", response_model=list[ShopOut])
def list_shops(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[ShopOut]:
return [_to_out(db, s) for s in db.query(Shop).order_by(Shop.name).all()]
@router.post("", response_model=ShopOut, status_code=status.HTTP_201_CREATED)
def create_shop(
payload: ShopCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ShopOut:
name = payload.name.strip()
if db.query(Shop).filter(func.lower(Shop.name) == name.lower()).first():
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Shop gibt es bereits")
shop = Shop(name=name, website=(payload.website or None))
db.add(shop)
db.commit()
db.refresh(shop)
return _to_out(db, shop)
@router.patch("/{shop_id}", response_model=ShopOut)
def update_shop(
shop_id: int,
payload: ShopUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ShopOut:
shop = db.get(Shop, shop_id)
if shop is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
data = payload.model_dump(exclude_unset=True)
if "name" in data and data["name"]:
name = data["name"].strip()
doppelt = (
db.query(Shop)
.filter(func.lower(Shop.name) == name.lower(), Shop.id != shop_id)
.first()
)
if doppelt is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Shop gibt es bereits")
shop.name = name
if "website" in data:
shop.website = data["website"] or None
db.commit()
db.refresh(shop)
return _to_out(db, shop)
@router.delete("/{shop_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shop(
shop_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Artikel bleiben bestehen, sie verlieren nur die Bezugsquelle (SET NULL)."""
shop = db.get(Shop, shop_id)
if shop is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
# SQLite setzt Fremdschlüssel nicht ohne Weiteres um, deshalb ausdrücklich.
db.query(Product).filter(Product.shop_id == shop_id).update(
{Product.shop_id: None}
)
db.delete(shop)
db.commit()

View File

@@ -1,10 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..crud import resolve_product
from ..crud import product_tracking, resolve_product
from ..database import get_db
from ..deps import get_current_user
from ..models import Lot, Movement, MovementType, User
from ..models import CategoryTracking, Lot, Movement, MovementType, User
from ..schemas import (
BatchCheckInRequest,
BatchCheckInResponse,
@@ -14,6 +14,9 @@ from ..schemas import (
CheckOutResponse,
LotOut,
LotUpdate,
RelocateRequest,
RemoveRequest,
StockActionResponse,
)
from ..services.conversion import ConversionError
from ..services.dates import clean_precision, normalize_best_before
@@ -23,10 +26,15 @@ from ..services.stock import (
check_out,
check_out_lot,
current_stock,
object_add,
object_relocate,
object_remove,
)
router = APIRouter(tags=["stock"])
OBJECT = CategoryTracking.object.value
@router.post("/stock/checkin", response_model=CheckInResponse)
def stock_checkin(
@@ -35,6 +43,21 @@ def stock_checkin(
user: User = Depends(get_current_user),
) -> CheckInResponse:
product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) == OBJECT:
# Gegenstände: Menge am Lagerort erhöhen, kein MHD, keine Charge-Auswahl.
lot = object_add(
db,
product=product,
quantity=payload.quantity,
location_id=payload.location_id,
user=user,
note=payload.note,
)
db.commit()
db.refresh(lot)
return CheckInResponse(
lot=LotOut.model_validate(lot), product_stock=current_stock(db, product.id)
)
try:
lot = check_in(
db,
@@ -103,6 +126,12 @@ def stock_checkout(
user: User = Depends(get_current_user),
) -> CheckOutResponse:
product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) == OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Gegenstände werden über „Entfernen“ (mit Grund) oder „Umlagern“ gebucht, "
"nicht ausgecheckt.",
)
try:
if payload.lot_id is not None:
lot = db.get(Lot, payload.lot_id)
@@ -138,6 +167,63 @@ def stock_checkout(
)
@router.post("/stock/relocate", response_model=StockActionResponse)
def stock_relocate(
payload: RelocateRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> StockActionResponse:
"""Gegenstands-Menge von einem Lagerort zum anderen umbuchen (ohne Grund)."""
product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) != OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Umlagern gibt es nur für Gegenstände."
)
try:
object_relocate(
db,
product=product,
quantity=payload.quantity,
from_location_id=payload.from_location_id,
to_location_id=payload.to_location_id,
user=user,
note=payload.note,
)
except StockError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
db.commit()
return StockActionResponse(product_stock=current_stock(db, product.id))
@router.post("/stock/remove", response_model=StockActionResponse)
def stock_remove(
payload: RemoveRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> StockActionResponse:
"""Gegenstands-Menge mit Pflicht-Grund aus dem Bestand entfernen."""
product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) != OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Entfernen mit Grund gibt es nur für Gegenstände.",
)
try:
object_remove(
db,
product=product,
quantity=payload.quantity,
location_id=payload.location_id,
reason=payload.reason.value,
user=user,
note=payload.note,
)
except StockError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
db.commit()
return StockActionResponse(product_stock=current_stock(db, product.id))
@router.get("/lots", response_model=list[LotOut])
def list_lots(
product_id: int | None = None,

View File

@@ -22,18 +22,28 @@ from ..services.group_codes import sync as sync_group_code
from ..models import (
BaseUnit,
Category,
CategoryTracking,
DatePrecision,
FieldDefinition,
Group,
Location,
Lot,
Movement,
MovementType,
Product,
Shop,
Unit,
UnitKind,
User,
)
from ..services.conversion import BASE_OF_KIND, display_unit_info, find_unit
from ..services.fields import (
FieldError,
apply_field_values,
effective_field_definitions,
options_list,
slugify,
)
router = APIRouter(tags=["transfer"])
@@ -128,7 +138,7 @@ def export_backup_json(
loc_name = {loc.id: loc.name for loc in locations}
data = {
"version": 1,
"version": 2,
"exported_at": datetime.now(timezone.utc).isoformat(),
"exported_at_local": datetime.now().isoformat(timespec="seconds"),
"units": [
@@ -147,6 +157,32 @@ def export_backup_json(
{"name": loc.name, "parent": loc_name.get(loc.parent_id)}
for loc in locations
],
# Kategorien mit Verwaltungsart, damit Lebensmittel/Gegenstände beim
# Wiederherstellen erhalten bleiben (auch leere Kategorien).
"categories": [
{"path": _category_path(db, c), "tracking": c.tracking}
for c in db.query(Category).order_by(Category.id).all()
],
# Bezugsquellen (nur für Gegenstände).
"shops": [
{"name": s.name, "website": s.website}
for s in db.query(Shop).order_by(Shop.id).all()
],
# Selbst definierte Felder je Kategorie.
"field_definitions": [
{
"category": _category_path(db, db.get(Category, fd.category_id)),
"label": fd.label,
"field_type": fd.field_type,
"unit": fd.unit,
"options": options_list(fd),
"required": fd.required,
"position": fd.position,
}
for fd in db.query(FieldDefinition)
.order_by(FieldDefinition.category_id, FieldDefinition.position, FieldDefinition.id)
.all()
],
"products": [],
}
@@ -168,6 +204,12 @@ def export_backup_json(
"min_stock": p.min_stock,
"min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None,
"min_stock_in_packages": bool(p.min_stock_in_packages),
# Gegenstands-Felder:
"shop": p.shop.name if p.shop else None,
"product_url": p.product_url,
"field_values": {
pfv.field_definition.label: pfv.value for pfv in p.field_values
},
"lots": [
{
"quantity": lot.quantity,
@@ -307,8 +349,16 @@ def _category_path(db: Session, category: Category | None) -> str:
return " > ".join(reversed(teile))
def _get_or_create_category(db: Session, path: str | None) -> Category | None:
"""Legt den ganzen Pfad an, falls Teile davon fehlen."""
def _get_or_create_category(
db: Session, path: str | None, tracking: str = CategoryTracking.food.value
) -> Category | None:
"""Legt den ganzen Pfad an, falls Teile davon fehlen.
Importierte Kategorien sind standardmäßig „food": Backups stammen aus der
Lebensmittel-Ausgabe, und so verhalten sich wiederhergestellte Artikel wie
zuvor. Der Modus aus einem neueren Backup (Liste ``categories``) überschreibt
das anschließend.
"""
path = (path or "").strip()
if not path:
return None
@@ -320,13 +370,29 @@ def _get_or_create_category(db: Session, path: str | None) -> Category | None:
)
node = query.first()
if node is None:
node = Category(name=name, parent_id=parent.id if parent else None)
node = Category(
name=name,
parent_id=parent.id if parent else None,
tracking=tracking,
)
db.add(node)
db.flush()
parent = node
return parent
def _get_or_create_shop(db: Session, name: str | None, website: str | None = None) -> Shop | None:
name = (name or "").strip()
if not name:
return None
shop = db.query(Shop).filter(Shop.name == name).first()
if shop is None:
shop = Shop(name=name, website=(website or None))
db.add(shop)
db.flush()
return shop
def _get_or_create_group(db: Session, name: str | None) -> Group | None:
name = (name or "").strip()
if not name:
@@ -524,6 +590,42 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
if child and parent and child.parent_id is None and child.id != parent.id:
child.parent_id = parent.id
# Kategorien samt Verwaltungsart (Backup v2). Ältere Backups ohne diese Liste
# legen ihre Kategorien weiter über die Produktpfade an (Standard: food).
for entry in data.get("categories", []):
cat = _get_or_create_category(
db,
entry.get("path"),
tracking=entry.get("tracking") or CategoryTracking.food.value,
)
if cat is not None and entry.get("tracking"):
cat.tracking = entry["tracking"]
for entry in data.get("shops", []):
_get_or_create_shop(db, entry.get("name"), entry.get("website"))
db.flush()
for entry in data.get("field_definitions", []):
cat = _get_or_create_category(db, entry.get("category"))
label = (entry.get("label") or "").strip()
if cat is None or not label:
continue
if db.query(FieldDefinition).filter_by(category_id=cat.id, label=label).first():
continue
ftype = entry.get("field_type") or "text"
optionen = entry.get("options") or []
db.add(
FieldDefinition(
category_id=cat.id,
label=label,
key=slugify(label),
field_type=ftype,
unit=(entry.get("unit") or None),
options=json.dumps(optionen) if ftype == "select" and optionen else None,
required=bool(entry.get("required")),
position=int(entry.get("position") or 0),
)
)
db.flush()
for entry in data.get("products", []):
try:
row = {
@@ -543,6 +645,27 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
"mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
}
product = _get_or_create_product(db, row, created_products)
# Gegenstands-Zusatzfelder nur ergänzend, nichts überschreiben.
shop_name = (entry.get("shop") or "").strip()
if shop_name and product.shop_id is None:
shop = _get_or_create_shop(db, shop_name)
product.shop_id = shop.id if shop else None
if entry.get("product_url") and not product.product_url:
product.product_url = entry["product_url"]
feldwerte = entry.get("field_values") or {}
if feldwerte and product.category_id:
nach_label = {
fd.label: fd
for fd, _ in effective_field_definitions(db, product.category_id)
}
for label, value in feldwerte.items():
fd = nach_label.get(label)
if fd is None:
continue
try:
apply_field_values(db, product, {fd.id: value})
except FieldError:
pass
if mode == "replace_listed" and product.id not in cleared:
_clear_lots(db, product, user)
cleared.add(product.id)

View File

@@ -2,9 +2,17 @@ from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from .models import BaseUnit, DatePrecision, Role, UnitKind
from .models import (
BaseUnit,
CategoryTracking,
DatePrecision,
FieldType,
RemovalReason,
Role,
UnitKind,
)
# ---- Units ----
@@ -131,23 +139,86 @@ class GroupUpdate(BaseModel):
# ---- Categories ----
class CategoryOut(BaseModel):
"""Reine Ordnungshilfe kein Bestand, kein Mindestbestand, keine EAN-Codes."""
"""Ordnungshilfe plus Verwaltungsart (Lebensmittel/Gegenstand)."""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
parent_id: int | None = None
is_builtin: bool = False
# "food" = Chargen+MHD, "object" = Menge je Lagerort.
tracking: CategoryTracking = CategoryTracking.food
product_count: int = 0
class CategoryCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
parent_id: int | None = None
# None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand.
tracking: CategoryTracking | None = None
class CategoryUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
parent_id: int | None = None
tracking: CategoryTracking | None = None
# ---- Shops (Bezugsquellen, nur für Gegenstände) ----
class ShopOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
website: str | None = None
product_count: int = 0
class ShopCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
website: str | None = Field(default=None, max_length=1024)
class ShopUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
website: str | None = Field(default=None, max_length=1024)
# ---- Selbst definierte Felder je Kategorie ----
class FieldDefinitionBase(BaseModel):
label: str = Field(min_length=1, max_length=120)
field_type: FieldType = FieldType.text
unit: str | None = Field(default=None, max_length=32)
options: list[str] | None = None # nur für field_type == "select"
required: bool = False
position: int = 0
class FieldDefinitionCreate(FieldDefinitionBase):
category_id: int
class FieldDefinitionUpdate(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=120)
field_type: FieldType | None = None
unit: str | None = Field(default=None, max_length=32)
options: list[str] | None = None
required: bool | None = None
position: int | None = None
class FieldDefinitionOut(BaseModel):
id: int
category_id: int
label: str
key: str
field_type: FieldType
unit: str | None = None
options: list[str] = []
required: bool = False
position: int = 0
is_builtin: bool = False
# Bei der vererbten Liste (GET /categories/{id}/fields): stammt das Feld von
# einer Oberkategorie? Dann in der Verwaltung dort bearbeiten.
inherited: bool = False
# ---- Locations ----
@@ -207,6 +278,11 @@ class ProductBase(BaseModel):
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
min_stock_unit_id: int | None = None
min_stock_in_packages: bool = False
# Nur für Gegenstände: Bezugsquelle und Onlineshop-Link.
shop_id: int | None = None
product_url: str | None = Field(default=None, max_length=1024)
# Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}.
field_values: dict[int, str | None] | None = None
class ProductCreate(ProductBase):
@@ -228,6 +304,9 @@ class ProductUpdate(BaseModel):
min_stock: float | None = Field(default=None, ge=0)
min_stock_unit_id: int | None = None
min_stock_in_packages: bool | None = None
shop_id: int | None = None
product_url: str | None = Field(default=None, max_length=1024)
field_values: dict[int, str | None] | None = None
class ProductOut(BaseModel):
@@ -249,6 +328,8 @@ class ProductOut(BaseModel):
min_stock_unit_id: int | None = None
min_stock_in_packages: bool = False
source: str
shop_id: int | None = None
product_url: str | None = None
created_at: datetime
# angereichert:
stock: float = 0.0
@@ -261,6 +342,18 @@ class ProductOut(BaseModel):
min_stock_unit_label: str = ""
# Zusätzliche EAN-Codes (neben dem Haupt-Barcode):
barcodes: list[BarcodeOut] = []
# Verwaltungsart aus der Kategorie (food/object), Bezugsquelle und Feldwerte:
tracking: CategoryTracking = CategoryTracking.food
shop_name: str | None = None
field_values: dict[int, str | None] = {}
@field_validator("field_values", mode="before")
@classmethod
def _field_values_from_orm(cls, v):
"""Beim Lesen aus der DB kommt eine Liste ProductFieldValue zu Map machen."""
if isinstance(v, list):
return {pfv.field_definition_id: pfv.value for pfv in v}
return v
class LookupResult(BaseModel):
@@ -358,6 +451,53 @@ class CheckOutResponse(BaseModel):
product_stock: float
# ---- Gegenstände: Umlagern und Entfernen mit Grund ----
class RelocateRequest(BaseModel):
"""Menge eines Gegenstands von einem Lagerort zu einem anderen umbuchen (ohne Grund)."""
product_id: int | None = None
barcode: str | None = None
quantity: float = Field(gt=0)
from_location_id: int | None = None
to_location_id: int | None = None
note: str | None = None
class RemoveRequest(BaseModel):
"""Menge eines Gegenstands aus dem Bestand entfernen Grund ist Pflicht."""
product_id: int | None = None
barcode: str | None = None
quantity: float = Field(gt=0)
location_id: int | None = None
reason: RemovalReason
note: str | None = None
class StockActionResponse(BaseModel):
product_stock: float
class RemovalStat(BaseModel):
"""Summe der Entnahmen je Grund (für die kleine Statistik am Artikel)."""
reason: RemovalReason
quantity: float
count: int
class RemovalHistoryItem(BaseModel):
reason: RemovalReason
quantity: float
location_id: int | None = None
location_name: str | None = None
note: str | None = None
username: str | None = None
created_at: datetime
class RemovalSummary(BaseModel):
stats: list[RemovalStat] = []
history: list[RemovalHistoryItem] = []
# ---- Views ----
class ShoppingItem(BaseModel):
product_id: int

View File

@@ -1,10 +1,13 @@
"""Startup-Seeds: erster Admin-Benutzer und die eingebauten Einheiten."""
import json
from sqlalchemy.orm import Session
from .config import get_settings
from .models import Category, PackageType, Role, Unit, UnitKind, User
from .models import Category, CategoryTracking, FieldDefinition, PackageType, Role, Unit, UnitKind, User
from .security import hash_password
from .services.fields import slugify
# (Name, Art, Faktor zur kanonischen Basiseinheit)
BUILTIN_UNITS: list[tuple[str, UnitKind, float]] = [
@@ -87,12 +90,99 @@ def ensure_builtin_categories(db: Session) -> None:
"""
if db.query(Category).first() is not None:
return
food = CategoryTracking.food.value
for name, children in BUILTIN_CATEGORIES:
parent = Category(name=name, is_builtin=True)
parent = Category(name=name, is_builtin=True, tracking=food)
db.add(parent)
db.flush()
for child in children:
db.add(Category(name=child, parent_id=parent.id, is_builtin=True))
db.add(Category(name=child, parent_id=parent.id, is_builtin=True, tracking=food))
db.commit()
# Beispiel-Kategorien für Gegenstände (Non-Food) mit ein paar sinnvollen Feldern.
# Frei änderbar; wer sie löscht, bekommt sie nicht zurück (siehe Guard unten).
OBJECT_EXAMPLES: list[dict] = [
{
"name": "Kleidung",
"fields": [
{"label": "Größe", "type": "select",
"options": ["XS", "S", "M", "L", "XL", "XXL"]},
{"label": "Farbe", "type": "text"},
],
"children": [
{"name": "Schuhe",
"fields": [{"label": "Schuhgröße", "type": "number", "unit": "EU"}]},
],
},
{
"name": "Elektronik",
"fields": [
{"label": "Kaufdatum", "type": "date"},
{"label": "Garantie bis", "type": "date"},
],
"children": [
{"name": "Powerbank",
"fields": [{"label": "Kapazität", "type": "number", "unit": "mAh"}]},
{"name": "Kabel", "fields": [
{"label": "Länge", "type": "number", "unit": "cm"},
{"label": "Anschluss", "type": "text"},
]},
],
},
{"name": "Werkzeug", "fields": []},
{"name": "Bücher & Medien", "fields": []},
]
def _add_fields(db: Session, category_id: int, felder: list[dict]) -> None:
for pos, f in enumerate(felder):
optionen = f.get("options")
db.add(
FieldDefinition(
category_id=category_id,
label=f["label"],
key=slugify(f["label"]),
field_type=f.get("type", "text"),
unit=f.get("unit"),
options=json.dumps(optionen) if optionen else None,
required=bool(f.get("required")),
position=pos,
is_builtin=True,
)
)
def ensure_example_object_categories(db: Session) -> None:
"""Legt Beispiel-Gegenstandskategorien mit Feldern an nur, wenn es noch keine gibt.
So sieht man das Gegenstands-Feature gleich in Aktion; wer die Beispiele
bewusst entfernt, bekommt sie beim nächsten Start nicht zurück.
"""
if (
db.query(Category)
.filter(Category.tracking == CategoryTracking.object.value)
.first()
is not None
):
return
for top in OBJECT_EXAMPLES:
parent = Category(
name=top["name"], tracking=CategoryTracking.object.value, is_builtin=True
)
db.add(parent)
db.flush()
_add_fields(db, parent.id, top.get("fields", []))
for child in top.get("children", []):
node = Category(
name=child["name"],
parent_id=parent.id,
tracking=CategoryTracking.object.value,
is_builtin=True,
)
db.add(node)
db.flush()
_add_fields(db, node.id, child.get("fields", []))
db.commit()

View File

@@ -0,0 +1,139 @@
"""Selbst definierte Felder je Kategorie: Vererbung, Validierung, Werte.
Felder hängen an einer Kategorie und gelten über den Kategorie-Baum auch für
deren Unterkategorien. Der Wert je Artikel steht in ProductFieldValue (immer als
Text; typgerecht geprüft wird hier).
"""
from __future__ import annotations
import json
import re
import unicodedata
from datetime import date
from sqlalchemy.orm import Session
from ..models import Category, FieldDefinition, FieldType, ProductFieldValue
class FieldError(ValueError):
"""Ungültige Feld-Definition oder ein Wert, der nicht zum Feldtyp passt."""
def slugify(label: str) -> str:
"""Maschinenlesbaren Schlüssel aus einem Label ableiten (z.B. „Kapazität“ → „kapazitaet“)."""
text = unicodedata.normalize("NFKD", label)
text = text.encode("ascii", "ignore").decode("ascii").lower()
text = re.sub(r"[^a-z0-9]+", "_", text).strip("_")
return text or "feld"
def options_list(fd: FieldDefinition) -> list[str]:
"""Auswahlmöglichkeiten eines select-Felds als Liste (leer, wenn keine)."""
if not fd.options:
return []
try:
data = json.loads(fd.options)
except (ValueError, TypeError):
return []
return [str(o) for o in data] if isinstance(data, list) else []
def _ancestry(db: Session, category_id: int) -> list[Category]:
"""Kategorien von der Wurzel bis zur angegebenen (einschließlich)."""
chain: list[Category] = []
seen: set[int] = set()
cid: int | None = category_id
while cid is not None and cid not in seen:
seen.add(cid)
cat = db.get(Category, cid)
if cat is None:
break
chain.append(cat)
cid = cat.parent_id
chain.reverse() # Wurzel zuerst, damit deren Felder oben stehen
return chain
def effective_field_definitions(
db: Session, category_id: int | None
) -> list[tuple[FieldDefinition, bool]]:
"""Alle für eine Kategorie geltenden Felder inkl. der geerbten.
Rückgabe: Liste aus (Felddefinition, geerbt?) geerbt=True, wenn das Feld
von einer Oberkategorie stammt. Reihenfolge: Oberkategorien zuerst.
"""
if category_id is None:
return []
result: list[tuple[FieldDefinition, bool]] = []
for cat in _ancestry(db, category_id):
defs = (
db.query(FieldDefinition)
.filter(FieldDefinition.category_id == cat.id)
.order_by(FieldDefinition.position, FieldDefinition.id)
.all()
)
for d in defs:
result.append((d, cat.id != category_id))
return result
def coerce_value(fd: FieldDefinition, raw) -> str | None:
"""Einen Rohwert typgerecht prüfen und als Text zurückgeben (oder None = leer)."""
if raw is None:
return None
text = str(raw).strip()
if text == "":
return None
if fd.field_type == FieldType.number.value:
try:
float(text.replace(",", "."))
except ValueError as exc:
raise FieldError(f"{fd.label}“ erwartet eine Zahl.") from exc
return text
if fd.field_type == FieldType.date.value:
try:
date.fromisoformat(text)
except ValueError as exc:
raise FieldError(f"{fd.label}“ erwartet ein Datum (JJJJ-MM-TT).") from exc
return text
if fd.field_type == FieldType.boolean.value:
return "true" if text.lower() in ("1", "true", "ja", "yes", "on") else "false"
if fd.field_type == FieldType.select.value:
opts = options_list(fd)
if opts and text not in opts:
raise FieldError(f"{text}“ ist keine gültige Auswahl für „{fd.label}“.")
return text
return text # text / textarea
def apply_field_values(
db: Session, product, values: dict[int, str | None] | None
) -> None:
"""Feldwerte eines Artikels setzen/ändern/löschen (nur die übergebenen Felder)."""
if not values:
return
existing = {
pfv.field_definition_id: pfv
for pfv in db.query(ProductFieldValue)
.filter(ProductFieldValue.product_id == product.id)
.all()
}
for fid, raw in values.items():
fd = db.get(FieldDefinition, fid)
if fd is None:
raise FieldError(f"Feld {fid} existiert nicht.")
val = coerce_value(fd, raw)
if fid in existing:
if val is None:
db.delete(existing[fid])
else:
existing[fid].value = val
elif val is not None:
db.add(
ProductFieldValue(
product_id=product.id, field_definition_id=fid, value=val
)
)

View File

@@ -16,6 +16,181 @@ class StockError(ValueError):
"""Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand)."""
# ---------------------------------------------------------------------------
# Gegenstände (Non-Food): Menge je Lagerort statt Chargen mit MHD.
#
# Technisch wird dieselbe Lot-Tabelle genutzt je (Produkt, Lagerort) genau
# eine Zeile mit ``best_before = NULL``. So laufen Bestands-Summe, Bewegungslog
# und Export unverändert weiter; nur MHD/FEFO entfällt. Die Lebensmittel-Logik
# oben (check_in/check_out) bleibt davon unberührt.
# ---------------------------------------------------------------------------
def _object_lot(db: Session, product_id: int, location_id: int | None) -> Lot | None:
"""Die (einzige) Bestandszeile eines Gegenstands an einem Lagerort."""
query = db.query(Lot).filter(
Lot.product_id == product_id, Lot.best_before.is_(None)
)
if location_id is None:
query = query.filter(Lot.location_id.is_(None))
else:
query = query.filter(Lot.location_id == location_id)
return query.first()
def object_add(
db: Session,
product: Product,
quantity: float,
location_id: int | None,
user: User | None,
note: str | None = None,
) -> Lot:
"""Erhöht die Menge eines Gegenstands an einem Lagerort."""
lot = _object_lot(db, product.id, location_id)
if lot is None:
lot = Lot(
product_id=product.id,
quantity=0.0,
best_before=None,
best_before_precision=DatePrecision.day.value,
location_id=location_id,
)
db.add(lot)
db.flush()
lot.quantity += quantity
db.add(
Movement(
product_id=product.id,
lot_id=lot.id,
user_id=user.id if user else None,
type=MovementType.in_,
quantity=quantity,
unit_used=product.base_unit.value,
note=note,
location_id=location_id,
)
)
return lot
def object_remove(
db: Session,
product: Product,
quantity: float,
location_id: int | None,
reason: str,
user: User | None,
note: str | None = None,
) -> None:
"""Entfernt eine Menge mit Grund (verloren/kaputt/…) aus dem Bestand."""
lot = _object_lot(db, product.id, location_id)
have = lot.quantity if lot else 0.0
if quantity > have + 1e-9:
raise StockError(
f"Am Lagerort sind nur {have:g} {product.base_unit.value} vorhanden "
f"(benötigt {quantity:g})."
)
lot.quantity -= quantity
db.add(
Movement(
product_id=product.id,
lot_id=lot.id,
user_id=user.id if user else None,
type=MovementType.out,
quantity=quantity,
unit_used=product.base_unit.value,
note=note,
location_id=location_id,
reason=reason,
)
)
if lot.quantity <= 1e-9:
db.delete(lot)
def object_relocate(
db: Session,
product: Product,
quantity: float,
from_location_id: int | None,
to_location_id: int | None,
user: User | None,
note: str | None = None,
) -> None:
"""Bucht eine Menge von einem Lagerort zum anderen um (ohne Grund)."""
if from_location_id == to_location_id:
raise StockError("Quell- und Ziel-Lagerort sind identisch.")
src = _object_lot(db, product.id, from_location_id)
have = src.quantity if src else 0.0
if quantity > have + 1e-9:
raise StockError(
f"Am Quell-Lagerort sind nur {have:g} {product.base_unit.value} "
f"vorhanden (benötigt {quantity:g})."
)
beleg = note or "Umlagerung"
src.quantity -= quantity
# Als neutrale Korrektur (adjust) protokollieren, damit Umlagerungen die
# Ein-/Auslager-Statistiken nicht verfälschen.
db.add(
Movement(
product_id=product.id,
lot_id=src.id,
user_id=user.id if user else None,
type=MovementType.adjust,
quantity=-quantity,
unit_used=product.base_unit.value,
note=beleg,
location_id=from_location_id,
)
)
if src.quantity <= 1e-9:
db.delete(src)
dest = _object_lot(db, product.id, to_location_id)
if dest is None:
dest = Lot(
product_id=product.id,
quantity=0.0,
best_before=None,
best_before_precision=DatePrecision.day.value,
location_id=to_location_id,
)
db.add(dest)
db.flush()
dest.quantity += quantity
db.add(
Movement(
product_id=product.id,
lot_id=dest.id,
user_id=user.id if user else None,
type=MovementType.adjust,
quantity=quantity,
unit_used=product.base_unit.value,
note=beleg,
location_id=to_location_id,
)
)
def removal_stats(db: Session, product_id: int) -> dict[str, dict]:
"""Entnahmen je Grund summieren: {reason: {quantity, count}}."""
rows = (
db.query(Movement)
.filter(
Movement.product_id == product_id,
Movement.type == MovementType.out,
Movement.reason.isnot(None),
)
.all()
)
stats: dict[str, dict] = {}
for m in rows:
eintrag = stats.setdefault(m.reason, {"quantity": 0.0, "count": 0})
eintrag["quantity"] += m.quantity
eintrag["count"] += 1
return stats
def current_stock(db: Session, product_id: int) -> float:
"""Summe der Lot-Mengen eines Produkts (in Basiseinheiten)."""
total = (