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

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