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:
@@ -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,
|
||||
|
||||
180
backend/app/services/master_data.py
Normal file
180
backend/app/services/master_data.py
Normal 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()
|
||||
Reference in New Issue
Block a user