Stammdaten-Export/Import (JSON) + Lagerort-Eltern bleibt beim Anlegen

- Backend: services/master_data.py exportiert/importiert Kategorien (inkl.
  eigener Felder), Lagerorte, Einheiten und Gebinde als JSON – MIT IDs, damit
  gedruckte QR-Codes und Verweise nach dem Wiederherstellen passen. Import-Modus
  skip (Vorhandenes lassen) oder overwrite (per ID aktualisieren); Namens-
  Konflikte werden uebersprungen, nicht abgebrochen. Postgres-Sequenzen werden
  danach angehoben. Endpunkte GET /export/master-data, POST /import/master-data.
  5 neue Tests, Suite 162 gruen.
- Web: neue Karte "Stammdaten sichern & wiederherstellen (JSON)" in Import/Export.
- Web: beim Lagerort-Anlegen bleibt der uebergeordnete Ort in der Auswahl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 11:17:14 +02:00
parent 636982f6c7
commit 6e48cd0a7a
6 changed files with 371 additions and 2 deletions

View File

@@ -11,12 +11,13 @@ import io
import json
from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session
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.group_codes import sync as sync_group_code
from ..models import (
@@ -252,6 +253,30 @@ def export_backup_json(
)
@router.get("/export/master-data")
def export_masterdata(
db: Session = Depends(get_db), _: User = Depends(require_admin)
) -> dict:
"""Stammdaten (Kategorien, Felder, Lagerorte, Einheiten, Gebinde) als JSON
mit IDs, für Sicherung/Umzug."""
return export_master_data(db)
@router.post("/import/master-data")
def import_masterdata(
payload: dict = Body(...),
mode: str = "skip",
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> dict:
"""Stammdaten aus JSON einspielen. mode=skip (Vorhandenes lassen) oder
overwrite (per ID überschreiben). IDs werden aus der Datei übernommen."""
try:
return import_master_data(db, payload, mode=mode)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
@router.get("/export/labels")
def export_labels(
category_ids: str | None = None,

View File

@@ -0,0 +1,180 @@
"""Export/Import der Stammdaten als JSON mit ID-Erhalt für Sicherung/Umzug.
Umfasst Kategorien (inkl. eigener Felder), Lagerorte, Einheiten und Gebinde. Die
IDs werden bewusst mitgeführt: nur so passen z.B. gedruckte Lagerort-QR-Codes
(`/l/<id>`) nach einem Wiederherstellen noch, und Verweise (parent_id,
category_id) bleiben gültig.
Beim Import entscheidet der Modus, was bei bereits vergebener ID passiert:
- ``skip`` vorhandene Einträge unangetastet lassen, nur neue anlegen.
- ``overwrite`` vorhandene per ID überschreiben, fehlende anlegen.
Einzelne Zeilen, die an einer Eindeutigkeit scheitern (z.B. gleicher Name unter
anderer ID), werden per Savepoint übersprungen statt den ganzen Import
abzubrechen.
"""
from __future__ import annotations
import json
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..models import Category, FieldDefinition, Location, PackageType, Unit, UnitKind
from .fields import options_list
FORMAT_KEY = "vorrania_master_data"
FORMAT_VERSION = 1
_TABLES = ("units", "package_types", "categories", "field_definitions", "locations")
def export_master_data(db: Session) -> dict:
return {
FORMAT_KEY: FORMAT_VERSION,
"categories": [
{"id": c.id, "name": c.name, "parent_id": c.parent_id, "tracking": c.tracking}
for c in db.query(Category).order_by(Category.id).all()
],
"field_definitions": [
{
"id": f.id, "category_id": f.category_id, "label": f.label, "key": f.key,
"field_type": f.field_type, "unit": f.unit, "options": options_list(f),
"required": f.required, "position": f.position, "is_builtin": f.is_builtin,
}
for f in db.query(FieldDefinition).order_by(FieldDefinition.id).all()
],
"locations": [
{"id": l.id, "name": l.name, "parent_id": l.parent_id}
for l in db.query(Location).order_by(Location.id).all()
],
"units": [
{"id": u.id, "name": u.name, "kind": u.kind.value, "factor": u.factor,
"is_builtin": u.is_builtin}
for u in db.query(Unit).order_by(Unit.id).all()
],
"package_types": [
{"id": p.id, "singular": p.singular, "plural": p.plural, "is_builtin": p.is_builtin}
for p in db.query(PackageType).order_by(PackageType.id).all()
],
}
class _Report:
def __init__(self) -> None:
self.created: dict[str, int] = {}
self.updated: dict[str, int] = {}
self.skipped: dict[str, int] = {}
def add(self, bucket: dict[str, int], key: str) -> None:
bucket[key] = bucket.get(key, 0) + 1
def as_dict(self) -> dict:
return {"created": self.created, "updated": self.updated, "skipped": self.skipped}
def _fix_sequence(db: Session, table: str) -> None:
"""Postgres: die id-Sequenz auf MAX(id) heben, sonst kollidieren spätere
Auto-IDs mit importierten. SQLite braucht das nicht."""
if db.get_bind().dialect.name != "postgresql":
return
db.execute(text(
f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), "
f"GREATEST((SELECT COALESCE(MAX(id), 1) FROM {table}), 1))"
))
def import_master_data(db: Session, data: dict, mode: str = "skip") -> dict:
if not isinstance(data, dict) or FORMAT_KEY not in data:
raise ValueError("Das ist keine Vorrania-Stammdaten-Datei.")
overwrite = mode == "overwrite"
rep = _Report()
def upsert(model, row, key, build, *, is_tree=False, conflict=None) -> None:
rid = row.get("id")
existing = db.get(model, rid) if rid is not None else None
if existing is not None and not overwrite:
rep.add(rep.skipped, key)
return
# Vorab-Check statt IntegrityError abzufangen: würde die Zeile eine
# Eindeutigkeit unter ANDERER id verletzen (z.B. gleicher Name), lieber
# überspringen als den Import abbrechen.
if conflict is not None and conflict(row):
rep.add(rep.skipped, key)
return
if existing is not None:
for k, v in build(row).items():
setattr(existing, k, v)
db.flush()
rep.add(rep.updated, key)
return
kwargs = build(row)
if rid is not None:
kwargs["id"] = rid
if is_tree:
kwargs["parent_id"] = None # Eltern erst im 2. Durchgang
db.add(model(**kwargs))
db.flush()
rep.add(rep.created, key)
def set_parents(model, rows) -> None:
for row in rows or []:
rid = row.get("id")
obj = db.get(model, rid) if rid is not None else None
if obj is None:
continue
pid = row.get("parent_id")
ziel = db.get(model, pid) if pid is not None else None
obj.parent_id = ziel.id if ziel is not None else None
db.flush()
def name_clash(model, field, r):
val = r.get(field)
return db.query(model.id).filter(
getattr(model, field) == val, model.id != r.get("id")
).first() is not None
for row in data.get("units") or []:
upsert(Unit, row, "units", lambda r: {
"name": r["name"], "kind": UnitKind(r["kind"]),
"factor": float(r.get("factor", 1.0)),
"is_builtin": bool(r.get("is_builtin", False)),
}, conflict=lambda r: name_clash(Unit, "name", r))
for row in data.get("package_types") or []:
upsert(PackageType, row, "package_types", lambda r: {
"singular": r["singular"], "plural": r.get("plural") or r["singular"],
"is_builtin": bool(r.get("is_builtin", False)),
}, conflict=lambda r: name_clash(PackageType, "singular", r))
cats = data.get("categories") or []
for row in cats:
upsert(Category, row, "categories", lambda r: {
"name": r["name"], "tracking": r.get("tracking") or "food",
}, is_tree=True)
set_parents(Category, cats)
for row in data.get("field_definitions") or []:
if db.get(Category, row.get("category_id")) is None:
rep.add(rep.skipped, "field_definitions")
continue
upsert(FieldDefinition, row, "field_definitions", lambda r: {
"category_id": r["category_id"], "label": r["label"],
"key": r.get("key") or r["label"],
"field_type": r.get("field_type", "text"),
"unit": r.get("unit"),
"options": json.dumps(r.get("options") or []) if r.get("field_type") == "select" else None,
"required": bool(r.get("required", False)),
"position": int(r.get("position", 0)),
"is_builtin": bool(r.get("is_builtin", False)),
})
locs = data.get("locations") or []
for row in locs:
upsert(Location, row, "locations", lambda r: {"name": r["name"]}, is_tree=True)
set_parents(Location, locs)
db.commit()
for table in _TABLES:
_fix_sequence(db, table)
db.commit()
return rep.as_dict()

View File

@@ -0,0 +1,91 @@
"""Stammdaten-Export/Import (JSON) mit ID-Erhalt und Konfliktmodi."""
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models import Category, FieldDefinition, Location, PackageType, Unit
from app.services.master_data import (
FORMAT_KEY,
export_master_data,
import_master_data,
)
def _fresh_db():
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)()
def test_roundtrip_erhaelt_ids_und_baum(db):
ober = Category(name="Elektronik", tracking="object")
db.add(ober)
db.flush()
unter = Category(name="Kabel", parent_id=ober.id, tracking="object")
db.add(unter)
db.flush()
db.add(FieldDefinition(category_id=ober.id, label="Kapazität", key="kapazitaet",
field_type="number", unit="mAh"))
haus = Location(name="Hedingen")
db.add(haus)
db.flush()
keller = Location(name="Keller", parent_id=haus.id)
db.add(keller)
beutel = PackageType(singular="Beutel", plural="Beutel")
db.add(beutel)
db.commit()
beutel_id = beutel.id
data = export_master_data(db)
ziel = _fresh_db()
import_master_data(ziel, data, mode="skip")
assert ziel.get(Category, ober.id).name == "Elektronik"
assert ziel.get(Category, unter.id).parent_id == ober.id # Baum erhalten
assert ziel.get(Location, keller.id).parent_id == haus.id
assert ziel.query(FieldDefinition).filter_by(category_id=ober.id).first().label == "Kapazität"
assert ziel.get(PackageType, beutel_id).singular == "Beutel" # id erhalten
def test_skip_laesst_vorhandenes_unangetastet(db):
a = Location(name="Alt")
db.add(a)
db.commit()
data = {FORMAT_KEY: 1, "locations": [{"id": a.id, "name": "Neu"}]}
rep = import_master_data(db, data, mode="skip")
assert db.get(Location, a.id).name == "Alt"
assert rep["skipped"].get("locations") == 1
def test_overwrite_aktualisiert_per_id(db):
a = Location(name="Alt")
db.add(a)
db.commit()
data = {FORMAT_KEY: 1, "locations": [{"id": a.id, "name": "Neu"}]}
rep = import_master_data(db, data, mode="overwrite")
assert db.get(Location, a.id).name == "Neu"
assert rep["updated"].get("locations") == 1
def test_falsche_datei_wirft():
with pytest.raises(ValueError):
import_master_data(_fresh_db(), {"foo": 1})
def test_namenskonflikt_wird_uebersprungen_nicht_abgebrochen(db):
# Vorhandene Einheit unter id X; Import bringt gleichen Namen unter anderer id.
vorhanden = Unit(name="Spezialeinheit", kind="count", factor=1)
db.add(vorhanden)
db.commit()
data = {FORMAT_KEY: 1, "units": [{"id": vorhanden.id + 999, "name": "Spezialeinheit",
"kind": "count", "factor": 1}]}
rep = import_master_data(db, data, mode="skip")
# Gleicher Name unter neuer id -> Eindeutigkeit verletzt -> uebersprungen.
assert rep["skipped"].get("units") == 1
assert db.query(Unit).filter_by(name="Spezialeinheit").count() == 1