diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index 726d235..443523c 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -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, diff --git a/backend/app/services/master_data.py b/backend/app/services/master_data.py new file mode 100644 index 0000000..9905e7c --- /dev/null +++ b/backend/app/services/master_data.py @@ -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/`) 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() diff --git a/backend/tests/test_master_data.py b/backend/tests/test_master_data.py new file mode 100644 index 0000000..0fc3ed7 --- /dev/null +++ b/backend/tests/test_master_data.py @@ -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 diff --git a/web/src/api.js b/web/src/api.js index 53510cc..6f9e43b 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -197,6 +197,11 @@ export const api = { setGroupLocationMinStock: (id, list) => request(`/groups/${id}/location-min-stock`, { method: "PUT", body: list }), + // Stammdaten sichern/wiederherstellen (Kategorien, Lagerorte, Einheiten, Gebinde) + exportMasterData: () => request("/export/master-data"), + importMasterData: (data, mode = "skip") => + request(`/import/master-data?mode=${mode}`, { method: "POST", body: data }), + // Views shoppingList: () => request("/shopping-list"), groupShoppingList: () => request("/shopping-list/groups"), diff --git a/web/src/pages/Locations.jsx b/web/src/pages/Locations.jsx index 990f369..22adc73 100644 --- a/web/src/pages/Locations.jsx +++ b/web/src/pages/Locations.jsx @@ -32,7 +32,8 @@ export default function Locations() { try { await api.createLocation({ name, parent_id: parentId === "" ? null : Number(parentId) }); setName(""); - setParentId(""); + // Übergeordneten Ort absichtlich stehen lassen – beim Anlegen vieler Orte + // unter demselben Elternteil spart das jedes Mal die Neuauswahl. load(); } catch (err) { setError(err.message); diff --git a/web/src/pages/Transfer.jsx b/web/src/pages/Transfer.jsx index 5b33e4d..9fb49ba 100644 --- a/web/src/pages/Transfer.jsx +++ b/web/src/pages/Transfer.jsx @@ -75,6 +75,11 @@ export default function Transfer() { const [locations, setLocations] = useState([]); const [locBusy, setLocBusy] = useState(false); const [locInfo, setLocInfo] = useState(null); + // Stammdaten-Sicherung (Kategorien/Lagerorte/Einheiten/Gebinde als JSON). + const mdFileRef = useRef(null); + const [mdMode, setMdMode] = useState("skip"); + const [mdBusy, setMdBusy] = useState(false); + const [mdInfo, setMdInfo] = useState(null); useEffect(() => { api.listCategories().then(setCategories).catch(() => {}); @@ -93,6 +98,33 @@ export default function Transfer() { URL.revokeObjectURL(url); } + async function exportMasterData() { + setError(null); setMdInfo(null); setMdBusy(true); + try { + const data = await api.exportMasterData(); + downloadCsv(JSON.stringify(data, null, 2), "vorrania-stammdaten.json"); + setMdInfo("Stammdaten exportiert."); + } catch (err) { setError(err.message); } finally { setMdBusy(false); } + } + + async function importMasterData(e) { + e.preventDefault(); + const f = mdFileRef.current?.files?.[0]; + if (!f) return; + setError(null); setMdInfo(null); setMdBusy(true); + try { + const data = JSON.parse(await f.text()); + const rep = await api.importMasterData(data, mdMode); + const summe = (o) => Object.values(o || {}).reduce((a, b) => a + b, 0); + setMdInfo(`Import fertig: ${summe(rep.created)} angelegt, ${summe(rep.updated)} aktualisiert, ${summe(rep.skipped)} übersprungen.`); + if (mdFileRef.current) mdFileRef.current.value = ""; + api.listLocations().then(setLocations).catch(() => {}); + api.listCategories().then(setCategories).catch(() => {}); + } catch (err) { + setError(err instanceof SyntaxError ? "Datei ist kein gültiges JSON." : err.message); + } finally { setMdBusy(false); } + } + function exportLocationLabels() { setError(null); setLocInfo(null); @@ -294,6 +326,41 @@ export default function Transfer() { +
+

Stammdaten sichern & wiederherstellen (JSON)

+

+ Kategorien (inkl. eigener Felder), Lagerorte, Einheiten und Gebinde als eine + JSON-Datei. Die IDs kommen mit – so passen gedruckte + Lagerort-QR-Codes nach dem Wiederherstellen noch und Verweise bleiben gültig. Gut für + Umzug auf einen neuen Server oder als Sicherung. (Artikel/Bestände: siehe „Backup als + JSON" oben.) +

+
+
+ +
+
+ + + +
+
+ {mdInfo &&

{mdInfo}

} +
+

QR-Etiketten-Export (P-touch & Co.)