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

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