Sicherung: Obergruppen und Mindestbestaende je Ort mitsichern

Das JSON-Backup schrieb bei Gruppen nur Name, Mindestbestand und Einheit -
Gebinde und Ort-Bedarfe fehlten schon vorher, ein Restore verlor sie also
stillschweigend. Mit den Obergruppen waere die komplette Hierarchie dazu
gekommen.

Format v3: Gruppen bringen jetzt parents (als NAMEN, nicht IDs - die sind
zwischen zwei Instanzen nicht gleich), Gebinde-Felder und ihre Mindestbestaende
je Ort mit; Artikel ebenso. Ort null = "Ueberall". Der Import laeuft dafuer
zweiphasig, weil Kanten erst gesetzt werden koennen, wenn alle Gruppen und
Lagerorte existieren, und weist Ringe ab - eine beschaedigte Datei darf keinen
einschleusen, der danach jede Auswertung im Kreis laufen liesse.

v2-Sicherungen bleiben lesbar; ihnen fehlen die neuen Listen einfach. Die
CSV-Spalte "mindestbestand" meint weiterhin den Bedarf ohne Ortsangabe und
landet in der Ueberall-Zeile.

maintenance.py raeumt die n:m-Zeilen jetzt ausdruecklich ab: das Core-DELETE
nimmt sie nicht mit, und SQLite erzwingt keine Fremdschluessel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-15 23:48:47 +02:00
parent eaacfd03e5
commit 54261b98c0
2 changed files with 253 additions and 6 deletions

View File

@@ -19,7 +19,9 @@ from ..database import get_db
from ..deps import get_current_user, require_admin
from ..services.master_data import export_master_data, import_master_data
from ..services.dates import MONTH, clean_precision, normalize_best_before
from ..services import gruppen as gruppen_graph
from ..services.group_codes import sync as sync_group_code
from ..services.min_stock import lies_ueberall, schreibe_ueberall
from ..models import (
BaseUnit,
Category,
@@ -27,12 +29,14 @@ from ..models import (
DatePrecision,
FieldDefinition,
Group,
GroupLocationMinStock,
Item,
Location,
Lot,
Movement,
MovementType,
Product,
ProductLocationMinStock,
Shop,
Unit,
UnitKind,
@@ -116,7 +120,10 @@ def export_stock_csv(
product.group.name if product.group else "",
_category_path(db, product.category),
_art_label(product),
product.min_stock if product.min_stock is not None else "",
# „Ueberall"-Bedarf (frueher der Gesamt-Mindestbestand am Artikel).
# Die CSV kennt nur diese eine Spalte; Ort-Bedarfe stehen im
# JSON-Backup.
_ueberall_csv(product),
]
lots = (
db.query(Lot)
@@ -159,7 +166,10 @@ def export_backup_json(
loc_name = {loc.id: loc.name for loc in locations}
data = {
"version": 2,
# v3: Gruppen mit Obergruppen und Gebinde, Mindestbestaende je Ort
# (Ort null = „Ueberall") in Basiseinheiten. v2-Sicherungen bleiben
# lesbar ihnen fehlen diese Listen einfach.
"version": 3,
"exported_at": datetime.now(timezone.utc).isoformat(),
"exported_at_local": datetime.now().isoformat(timespec="seconds"),
"units": [
@@ -169,8 +179,13 @@ def export_backup_json(
"groups": [
{
"name": g.name,
"min_stock": g.min_stock,
"min_stock_unit": g.min_stock_unit.name if g.min_stock_unit else None,
"package_size": g.package_size,
"package_label": g.package_label,
"min_stock_in_packages": g.min_stock_in_packages,
# Namen statt IDs: die sind zwischen zwei Instanzen nicht gleich.
"parents": sorted(p.name for p in g.parents),
"min_stocks": _min_stock_liste(g, loc_name),
}
for g in db.query(Group).order_by(Group.id).all()
],
@@ -222,9 +237,9 @@ def export_backup_json(
"date_precision": p.date_precision,
"group": p.group.name if p.group else None,
"category": _category_path(db, p.category),
"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),
"min_stocks": _min_stock_liste(p, loc_name),
# Gegenstands-Felder:
"shop": p.shop.name if p.shop else None,
"product_url": p.product_url,
@@ -479,6 +494,55 @@ def _get_or_create_shop(db: Session, name: str | None, website: str | None = Non
return shop
def _ueberall_csv(besitzer) -> float | str:
"""Der „Ueberall"-Mindestbestand fuer die CSV-Spalte (leer, wenn keiner)."""
wert = lies_ueberall(besitzer.location_min_stocks)
return "" if wert is None else wert
def _min_stock_liste(besitzer, loc_name: dict[str, str]) -> list[dict]:
"""Mindestbestaende eines Artikels/einer Gruppe je Ort, fuer die Sicherung.
Mengen in Basiseinheiten, Orte als Name; ``location = null`` ist „Ueberall".
"""
return [
{
"location": None if e.location_id is None else loc_name.get(e.location_id),
"min_stock": e.min_stock,
}
for e in besitzer.location_min_stocks
]
def _min_stocks_einspielen(db: Session, besitzer, eintraege: list[dict] | None) -> None:
"""Mindestbestaende aus der Sicherung setzen nur, wenn noch keine da sind.
Wie der ganze Import additiv: Vorhandenes wird nie ueberschrieben.
"""
if not eintraege or besitzer.location_min_stocks:
return
for eintrag in eintraege:
menge = eintrag.get("min_stock")
if menge is None or float(menge) <= 0:
continue
ort_name = eintrag.get("location")
ort = None
if ort_name:
ort = db.query(Location).filter(Location.name == ort_name).first()
if ort is None:
continue # Ort fehlt in dieser Instanz Eintrag entfaellt
# Beziehung mitsetzen, nicht nur die ID: sonst liefert ``.location`` bis
# zum naechsten Commit None, obwohl der Ort feststeht.
if isinstance(besitzer, Product):
besitzer.location_min_stocks.append(ProductLocationMinStock(
product_id=besitzer.id, location=ort,
location_id=ort.id if ort else None, min_stock=float(menge)))
else:
besitzer.location_min_stocks.append(GroupLocationMinStock(
group_id=besitzer.id, location=ort,
location_id=ort.id if ort else None, min_stock=float(menge)))
def _get_or_create_group(db: Session, name: str | None) -> Group | None:
name = (name or "").strip()
if not name:
@@ -542,11 +606,13 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
date_precision=clean_precision((row.get("mhd_genauigkeit") or "").strip() or None),
group_id=group.id if group else None,
category_id=category.id if category else None,
min_stock=_num(row.get("mindestbestand")),
source="import",
)
db.add(product)
db.flush()
# Die CSV-Spalte „mindestbestand" (und das v2-Backup) meinen den Bedarf
# ohne Ortsangabe das ist jetzt die „Ueberall"-Zeile. Basiseinheiten.
schreibe_ueberall(db, product, _num(row.get("mindestbestand")))
# Damit ein eingelesenes Backup denselben Stand erzeugt wie das Anlegen
# ueber die Oberflaeche.
sync_group_code(db, product)
@@ -664,7 +730,19 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
db.flush()
for entry in data.get("groups", []):
_get_or_create_group(db, entry.get("name"))
gruppe = _get_or_create_group(db, entry.get("name"))
if gruppe is None:
continue
# Gebinde und Einheit nur nachtragen, wenn die Gruppe noch nackt ist
# der Import ueberschreibt grundsaetzlich nichts Vorhandenes.
if gruppe.package_size is None and entry.get("package_size"):
gruppe.package_size = float(entry["package_size"])
gruppe.package_label = entry.get("package_label")
gruppe.min_stock_in_packages = bool(entry.get("min_stock_in_packages"))
if gruppe.min_stock_unit_id is None and entry.get("min_stock_unit"):
einheit = db.query(Unit).filter(Unit.name == entry["min_stock_unit"]).first()
if einheit is not None:
gruppe.min_stock_unit_id = einheit.id
for entry in data.get("locations", []):
_get_or_create_location(db, entry.get("name"))
db.flush()
@@ -678,6 +756,27 @@ 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
# Zweiter Durchgang für die Gruppen: Ober-/Untergruppen und Mindestbestände
# lassen sich erst setzen, wenn alle Gruppen und Lagerorte existieren.
for entry in data.get("groups", []):
gruppe = db.query(Group).filter(Group.name == entry.get("name")).first()
if gruppe is None:
continue
if not gruppe.parents:
for eltern_name in entry.get("parents") or []:
eltern = db.query(Group).filter(Group.name == eltern_name).first()
# Selbstkante und Ringe abweisen: eine beschädigte oder
# manipulierte Datei darf keinen einschleusen, der danach jede
# Auswertung im Kreis laufen liesse.
if eltern is None or eltern.id == gruppe.id:
continue
if eltern.id in gruppen_graph.nachfahren_ids(gruppe):
errors.append(f"Gruppe {gruppe.name!r}: {eltern_name!r} wäre ein Ring")
continue
gruppe.parents.append(eltern)
_min_stocks_einspielen(db, gruppe, entry.get("min_stocks"))
db.flush()
# 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", []):
@@ -733,6 +832,9 @@ 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)
# Mindestbestände je Ort (v3). Ältere Sicherungen haben nur den
# Gesamtwert, der oben schon über „mindestbestand" gesetzt wurde.
_min_stocks_einspielen(db, product, entry.get("min_stocks"))
# Gegenstands-Zusatzfelder nur ergänzend, nichts überschreiben.
shop_name = (entry.get("shop") or "").strip()
if shop_name and product.shop_id is None: