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:
@@ -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:
|
||||
|
||||
145
backend/tests/test_backup_gruppen.py
Normal file
145
backend/tests/test_backup_gruppen.py
Normal 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", []))
|
||||
Reference in New Issue
Block a user