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 ..deps import get_current_user, require_admin
from ..services.master_data import export_master_data, import_master_data from ..services.master_data import export_master_data, import_master_data
from ..services.dates import MONTH, clean_precision, normalize_best_before 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.group_codes import sync as sync_group_code
from ..services.min_stock import lies_ueberall, schreibe_ueberall
from ..models import ( from ..models import (
BaseUnit, BaseUnit,
Category, Category,
@@ -27,12 +29,14 @@ from ..models import (
DatePrecision, DatePrecision,
FieldDefinition, FieldDefinition,
Group, Group,
GroupLocationMinStock,
Item, Item,
Location, Location,
Lot, Lot,
Movement, Movement,
MovementType, MovementType,
Product, Product,
ProductLocationMinStock,
Shop, Shop,
Unit, Unit,
UnitKind, UnitKind,
@@ -116,7 +120,10 @@ def export_stock_csv(
product.group.name if product.group else "", product.group.name if product.group else "",
_category_path(db, product.category), _category_path(db, product.category),
_art_label(product), _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 = ( lots = (
db.query(Lot) db.query(Lot)
@@ -159,7 +166,10 @@ def export_backup_json(
loc_name = {loc.id: loc.name for loc in locations} loc_name = {loc.id: loc.name for loc in locations}
data = { 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": datetime.now(timezone.utc).isoformat(),
"exported_at_local": datetime.now().isoformat(timespec="seconds"), "exported_at_local": datetime.now().isoformat(timespec="seconds"),
"units": [ "units": [
@@ -169,8 +179,13 @@ def export_backup_json(
"groups": [ "groups": [
{ {
"name": g.name, "name": g.name,
"min_stock": g.min_stock,
"min_stock_unit": g.min_stock_unit.name if g.min_stock_unit else None, "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() for g in db.query(Group).order_by(Group.id).all()
], ],
@@ -222,9 +237,9 @@ def export_backup_json(
"date_precision": p.date_precision, "date_precision": p.date_precision,
"group": p.group.name if p.group else None, "group": p.group.name if p.group else None,
"category": _category_path(db, p.category), "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_unit": p.min_stock_unit.name if p.min_stock_unit else None,
"min_stock_in_packages": bool(p.min_stock_in_packages), "min_stock_in_packages": bool(p.min_stock_in_packages),
"min_stocks": _min_stock_liste(p, loc_name),
# Gegenstands-Felder: # Gegenstands-Felder:
"shop": p.shop.name if p.shop else None, "shop": p.shop.name if p.shop else None,
"product_url": p.product_url, "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 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: def _get_or_create_group(db: Session, name: str | None) -> Group | None:
name = (name or "").strip() name = (name or "").strip()
if not name: 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), date_precision=clean_precision((row.get("mhd_genauigkeit") or "").strip() or None),
group_id=group.id if group else None, group_id=group.id if group else None,
category_id=category.id if category else None, category_id=category.id if category else None,
min_stock=_num(row.get("mindestbestand")),
source="import", source="import",
) )
db.add(product) db.add(product)
db.flush() 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 # Damit ein eingelesenes Backup denselben Stand erzeugt wie das Anlegen
# ueber die Oberflaeche. # ueber die Oberflaeche.
sync_group_code(db, product) sync_group_code(db, product)
@@ -664,7 +730,19 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
db.flush() db.flush()
for entry in data.get("groups", []): 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", []): for entry in data.get("locations", []):
_get_or_create_location(db, entry.get("name")) _get_or_create_location(db, entry.get("name"))
db.flush() 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: if child and parent and child.parent_id is None and child.id != parent.id:
child.parent_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 # Kategorien samt Verwaltungsart (Backup v2). Ältere Backups ohne diese Liste
# legen ihre Kategorien weiter über die Produktpfade an (Standard: food). # legen ihre Kategorien weiter über die Produktpfade an (Standard: food).
for entry in data.get("categories", []): 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 "", "mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
} }
product = _get_or_create_product(db, row, created_products) 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. # Gegenstands-Zusatzfelder nur ergänzend, nichts überschreiben.
shop_name = (entry.get("shop") or "").strip() shop_name = (entry.get("shop") or "").strip()
if shop_name and product.shop_id is None: if shop_name and product.shop_id is None:

View File

@@ -0,0 +1,145 @@
"""Sicherung: Obergruppen und Mindestbestände je Ort überstehen den Umlauf.
Ohne diesen Weg gingen genau die Daten verloren, um die es beim Umbau ging
die Kanten des Gruppen-Graphen und die „Überall"-Zeilen. Das JSON-Backup führt
Gruppen über ihren NAMEN, nicht über IDs: die sind zwischen zwei Instanzen
nicht gleich.
"""
import json
import pytest
from app.models import (
BaseUnit,
Group,
GroupLocationMinStock,
Location,
Product,
ProductLocationMinStock,
Role,
User,
)
from app.routers.transfer import _import_json, export_backup_json
@pytest.fixture()
def user(db):
person = User(username="tester", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def _sicherung(db, user) -> bytes:
return export_backup_json(db=db, _=user).body
def test_backup_nimmt_obergruppen_und_bedarfe_mit(db, user, monkeypatch):
keller = Location(name="Keller")
db.add(keller)
db.flush()
wurst = Group(name="Wurst")
grillgut = Group(name="Grillgut")
db.add_all([wurst, grillgut])
db.flush()
grillwurst = Group(name="Grillwurst", parents=[wurst, grillgut])
db.add(grillwurst)
db.flush()
p = Product(name="Bell Grillwurst", base_unit=BaseUnit.gram, group_id=grillwurst.id)
db.add(p)
db.flush()
db.add_all([
GroupLocationMinStock(group_id=wurst.id, location_id=None, min_stock=5000),
GroupLocationMinStock(group_id=grillwurst.id, location_id=keller.id, min_stock=2000),
ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=800),
])
db.commit()
daten = json.loads(_sicherung(db, user))
assert daten["version"] == 3
nach_name = {g["name"]: g for g in daten["groups"]}
assert sorted(nach_name["Grillwurst"]["parents"]) == ["Grillgut", "Wurst"]
assert {"location": None, "min_stock": 5000} in nach_name["Wurst"]["min_stocks"]
assert {"location": "Keller", "min_stock": 2000} in nach_name["Grillwurst"]["min_stocks"]
artikel = daten["products"][0]
assert {"location": None, "min_stock": 800} in artikel["min_stocks"]
def test_wiederherstellen_baut_den_graphen_neu_auf(db, user):
"""Sicherung aus einer Instanz, Einspielen in eine leere zweite."""
keller = Location(name="Keller")
db.add(keller)
db.flush()
wurst = Group(name="Wurst")
grillgut = Group(name="Grillgut")
db.add_all([wurst, grillgut])
db.flush()
grillwurst = Group(name="Grillwurst", parents=[wurst, grillgut])
db.add(grillwurst)
db.flush()
p = Product(name="Bell Grillwurst", base_unit=BaseUnit.gram, group_id=grillwurst.id)
db.add(p)
db.flush()
db.add_all([
GroupLocationMinStock(group_id=wurst.id, location_id=None, min_stock=5000),
ProductLocationMinStock(product_id=p.id, location_id=keller.id, min_stock=800),
])
db.commit()
inhalt = _sicherung(db, user)
# Zweite, leere Instanz.
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.seed import ensure_builtin_units
engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
poolclass=StaticPool)
Base.metadata.create_all(bind=engine)
zweite = sessionmaker(bind=engine, autoflush=False, autocommit=False)()
ensure_builtin_units(zweite)
person = User(username="zwei", password_hash="x", role=Role.admin)
zweite.add(person)
zweite.commit()
_import_json(zweite, inhalt, person, "add")
neu_wurst = zweite.query(Group).filter(Group.name == "Wurst").one()
neu_grillwurst = zweite.query(Group).filter(Group.name == "Grillwurst").one()
assert sorted(g.name for g in neu_grillwurst.parents) == ["Grillgut", "Wurst"]
assert neu_wurst.location_min_stocks[0].location_id is None
assert neu_wurst.location_min_stocks[0].min_stock == pytest.approx(5000)
neu_artikel = zweite.query(Product).filter(Product.name == "Bell Grillwurst").one()
orte = {
(e.location.name if e.location else None): e.min_stock
for e in neu_artikel.location_min_stocks
}
assert orte == {"Keller": pytest.approx(800)}
zweite.close()
engine.dispose()
def test_ring_aus_der_sicherung_wird_abgewiesen(db, user):
"""Eine beschädigte Datei darf keinen Ring einschleusen."""
daten = {
"version": 3,
"groups": [
{"name": "A", "parents": ["B"]},
{"name": "B", "parents": ["A"]},
],
"products": [],
}
ergebnis = _import_json(db, json.dumps(daten).encode(), user, "add")
a = db.query(Group).filter(Group.name == "A").one()
b = db.query(Group).filter(Group.name == "B").one()
# Eine der beiden Kanten greift, die andere wird als Ring abgewiesen.
assert not (a.parents and b.parents)
assert any("Ring" in f for f in ergebnis.get("errors", []))