diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index 443523c..8bd83b3 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -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: diff --git a/backend/tests/test_backup_gruppen.py b/backend/tests/test_backup_gruppen.py new file mode 100644 index 0000000..a086c19 --- /dev/null +++ b/backend/tests/test_backup_gruppen.py @@ -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", []))