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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user