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:
|
||||
|
||||
Reference in New Issue
Block a user