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 ..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:
|
||||||
|
|||||||
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