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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
143
backend/app/routers/field_definitions.py
Normal file
143
backend/app/routers/field_definitions.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
88
backend/app/routers/shops.py
Normal file
88
backend/app/routers/shops.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
139
backend/app/services/fields.py
Normal file
139
backend/app/services/fields.py
Normal 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
|
||||
)
|
||||
)
|
||||
@@ -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 = (
|
||||
|
||||
238
backend/tests/test_gegenstaende.py
Normal file
238
backend/tests/test_gegenstaende.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Gegenstände (Non-Food): Menge je Lagerort, Umlagern, Entfernen mit Grund,
|
||||
Kategorie-Modus und selbst definierte Felder.
|
||||
|
||||
Die Lebensmittel-Logik (Chargen/MHD/FEFO) bleibt davon unberührt – dafür sorgt
|
||||
test_fefo.py weiterhin.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.crud import product_tracking
|
||||
from app.models import (
|
||||
Category,
|
||||
CategoryTracking,
|
||||
FieldDefinition,
|
||||
Location,
|
||||
Lot,
|
||||
Movement,
|
||||
Product,
|
||||
ProductFieldValue,
|
||||
RemovalReason,
|
||||
)
|
||||
from app.routers.categories import create_category
|
||||
from app.schemas import CategoryCreate
|
||||
from app.services.fields import (
|
||||
FieldError,
|
||||
apply_field_values,
|
||||
coerce_value,
|
||||
effective_field_definitions,
|
||||
)
|
||||
from app.services.stock import (
|
||||
StockError,
|
||||
current_stock,
|
||||
object_add,
|
||||
object_relocate,
|
||||
object_remove,
|
||||
removal_stats,
|
||||
)
|
||||
|
||||
|
||||
def _object_product(db, name="Unterhose"):
|
||||
cat = Category(name="Kleidung", tracking=CategoryTracking.object.value)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
product = Product(name=name, category_id=cat.id)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product, cat
|
||||
|
||||
|
||||
def _loc(db, name):
|
||||
loc = Location(name=name)
|
||||
db.add(loc)
|
||||
db.commit()
|
||||
db.refresh(loc)
|
||||
return loc
|
||||
|
||||
|
||||
# ---- Modus je Kategorie ----
|
||||
|
||||
def test_ohne_kategorie_bleibt_food(db):
|
||||
product = Product(name="Altbestand")
|
||||
db.add(product)
|
||||
db.commit()
|
||||
assert product_tracking(db, product) == "food"
|
||||
|
||||
|
||||
def test_gegenstandskategorie_macht_object(db):
|
||||
product, _ = _object_product(db)
|
||||
assert product_tracking(db, product) == "object"
|
||||
|
||||
|
||||
def test_neue_oberkategorie_ist_gegenstand(db):
|
||||
out = create_category(CategoryCreate(name="Werkzeug"), db, None)
|
||||
assert out.tracking == CategoryTracking.object
|
||||
|
||||
|
||||
def test_unterkategorie_erbt_modus_vom_elternteil(db):
|
||||
ober = create_category(
|
||||
CategoryCreate(name="Lebensmittel", tracking=CategoryTracking.food), db, None
|
||||
)
|
||||
unter = create_category(CategoryCreate(name="Käse", parent_id=ober.id), db, None)
|
||||
assert unter.tracking == CategoryTracking.food
|
||||
|
||||
|
||||
# ---- Menge je Lagerort ----
|
||||
|
||||
def test_add_summiert_je_ort(db):
|
||||
product, _ = _object_product(db)
|
||||
a, b = _loc(db, "Schrank A"), _loc(db, "Schrank B")
|
||||
object_add(db, product, 5, a.id, None)
|
||||
object_add(db, product, 3, b.id, None)
|
||||
db.commit()
|
||||
assert current_stock(db, product.id) == 8
|
||||
verteilung = {
|
||||
lot.location_id: lot.quantity
|
||||
for lot in db.query(Lot).filter(Lot.product_id == product.id).all()
|
||||
}
|
||||
assert verteilung == {a.id: 5, b.id: 3}
|
||||
|
||||
|
||||
def test_add_am_gleichen_ort_erhoeht_dieselbe_zeile(db):
|
||||
product, _ = _object_product(db)
|
||||
a = _loc(db, "Schrank A")
|
||||
object_add(db, product, 2, a.id, None)
|
||||
object_add(db, product, 3, a.id, None)
|
||||
db.commit()
|
||||
lots = db.query(Lot).filter(Lot.product_id == product.id).all()
|
||||
assert len(lots) == 1 and lots[0].quantity == 5
|
||||
|
||||
|
||||
# ---- Umlagern (ohne Grund) ----
|
||||
|
||||
def test_umlagern_verschiebt_menge(db):
|
||||
product, _ = _object_product(db)
|
||||
a, b = _loc(db, "A"), _loc(db, "B")
|
||||
object_add(db, product, 5, a.id, None)
|
||||
db.commit()
|
||||
object_relocate(db, product, 2, a.id, b.id, None)
|
||||
db.commit()
|
||||
verteilung = {
|
||||
lot.location_id: lot.quantity
|
||||
for lot in db.query(Lot).filter(Lot.product_id == product.id).all()
|
||||
}
|
||||
assert verteilung == {a.id: 3, b.id: 2}
|
||||
assert current_stock(db, product.id) == 5
|
||||
# Umlagern erzeugt keine Entnahme mit Grund.
|
||||
assert db.query(Movement).filter(Movement.reason.isnot(None)).count() == 0
|
||||
|
||||
|
||||
def test_umlagern_zu_wenig_bestand(db):
|
||||
product, _ = _object_product(db)
|
||||
a, b = _loc(db, "A"), _loc(db, "B")
|
||||
object_add(db, product, 1, a.id, None)
|
||||
db.commit()
|
||||
with pytest.raises(StockError):
|
||||
object_relocate(db, product, 5, a.id, b.id, None)
|
||||
|
||||
|
||||
def test_umlagern_gleicher_ort_fehler(db):
|
||||
product, _ = _object_product(db)
|
||||
a = _loc(db, "A")
|
||||
object_add(db, product, 3, a.id, None)
|
||||
db.commit()
|
||||
with pytest.raises(StockError):
|
||||
object_relocate(db, product, 1, a.id, a.id, None)
|
||||
|
||||
|
||||
# ---- Entfernen mit Grund + Statistik ----
|
||||
|
||||
def test_entfernen_mit_grund_und_statistik(db):
|
||||
product, _ = _object_product(db)
|
||||
a = _loc(db, "A")
|
||||
object_add(db, product, 5, a.id, None)
|
||||
db.commit()
|
||||
object_remove(db, product, 2, a.id, RemovalReason.broken.value, None)
|
||||
object_remove(db, product, 1, a.id, RemovalReason.broken.value, None)
|
||||
object_remove(db, product, 1, a.id, RemovalReason.lost.value, None)
|
||||
db.commit()
|
||||
assert current_stock(db, product.id) == 1
|
||||
stats = removal_stats(db, product.id)
|
||||
assert stats["broken"] == {"quantity": 3, "count": 2}
|
||||
assert stats["lost"] == {"quantity": 1, "count": 1}
|
||||
|
||||
|
||||
def test_entfernen_zu_viel_bestand(db):
|
||||
product, _ = _object_product(db)
|
||||
a = _loc(db, "A")
|
||||
object_add(db, product, 1, a.id, None)
|
||||
db.commit()
|
||||
with pytest.raises(StockError):
|
||||
object_remove(db, product, 5, a.id, RemovalReason.lost.value, None)
|
||||
|
||||
|
||||
# ---- Selbst definierte Felder ----
|
||||
|
||||
def test_felder_werden_vererbt_oberkategorie_zuerst(db):
|
||||
ober = Category(name="Elektronik", tracking="object")
|
||||
db.add(ober)
|
||||
db.flush()
|
||||
unter = Category(name="Powerbank", parent_id=ober.id, tracking="object")
|
||||
db.add(unter)
|
||||
db.commit()
|
||||
db.add(FieldDefinition(category_id=ober.id, label="Kaufdatum", key="kaufdatum", field_type="date"))
|
||||
db.add(
|
||||
FieldDefinition(
|
||||
category_id=unter.id, label="Kapazität", key="kapazitaet",
|
||||
field_type="number", unit="mAh",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
eff = effective_field_definitions(db, unter.id)
|
||||
assert [fd.label for fd, _ in eff] == ["Kaufdatum", "Kapazität"]
|
||||
geerbt = {fd.label: inh for fd, inh in eff}
|
||||
assert geerbt["Kaufdatum"] is True and geerbt["Kapazität"] is False
|
||||
|
||||
|
||||
def test_zahlfeld_prueft_typ(db):
|
||||
fd = FieldDefinition(category_id=1, label="Kapazität", key="kap", field_type="number")
|
||||
assert coerce_value(fd, "20000") == "20000"
|
||||
assert coerce_value(fd, "3,5") == "3,5"
|
||||
with pytest.raises(FieldError):
|
||||
coerce_value(fd, "abc")
|
||||
|
||||
|
||||
def test_auswahlfeld_prueft_optionen(db):
|
||||
fd = FieldDefinition(
|
||||
category_id=1, label="Größe", key="groesse", field_type="select",
|
||||
options=json.dumps(["S", "M", "L"]),
|
||||
)
|
||||
assert coerce_value(fd, "M") == "M"
|
||||
with pytest.raises(FieldError):
|
||||
coerce_value(fd, "XXL")
|
||||
|
||||
|
||||
def test_feldwert_setzen_und_leeren(db):
|
||||
cat = Category(name="Elektronik", tracking="object")
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
fd = FieldDefinition(category_id=cat.id, label="Notiz", key="notiz", field_type="text")
|
||||
db.add(fd)
|
||||
db.commit()
|
||||
product = Product(name="Gerät", category_id=cat.id)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
|
||||
apply_field_values(db, product, {fd.id: "wichtig"})
|
||||
db.commit()
|
||||
assert (
|
||||
db.query(ProductFieldValue).filter_by(product_id=product.id).one().value
|
||||
== "wichtig"
|
||||
)
|
||||
# Leerer Wert löscht den Eintrag wieder.
|
||||
apply_field_values(db, product, {fd.id: ""})
|
||||
db.commit()
|
||||
assert db.query(ProductFieldValue).filter_by(product_id=product.id).count() == 0
|
||||
Reference in New Issue
Block a user