Files
Vorrania/backend/app/services/master_data.py
T
ScarriffleandClaude Opus 5 98e2d18142 Verwendungsstatus: Backend fuer "getragen / in Reinigung / in Reparatur"
Einzelstuecke hatten bisher keinen Zustand - der Lagerort sagt, wohin ein
Stueck gehoert, aber nicht, woran es gerade ist. Neue Tabelle item_usages
nach dem Vorbild von item_loans: ein Zeitraum je Zustand, ended_at NULL
heisst "laeuft noch", und ein Teilindex laesst nur einen laufenden zu.

"Verfuegbar" wird bewusst nicht gespeichert - keine offene Zeile IST
verfuegbar, wie bei lots.opened_on und beim Verleih. Damit kostet der
Ruhezustand nichts und die Tabelle fuellt sich nicht mit Rauschen.
Abgeschlossene Zeilen bleiben stehen, sie sind der Verlauf.

Eigener Endpunkt (PUT /items/{id}/verwendung) und ausdruecklich nicht
PATCH /items/{id}: Das ist Admins vorbehalten, und die App schickt dort
immer alle Felder mitsamt null - jedes Speichern wuerde den Status
stillschweigend zuruecksetzen. Umstellen darf deshalb jeder angemeldete
Benutzer, wie Verleihen.

Der Zustand aendert den Bestand nicht und schreibt keine Bewegung: ein
getragenes Hemd ist weiterhin im Haushalt, anders als ein verliehenes.

Sichtbar nur bei Kategorien mit usage_tracking. Ein Haken an einer
Oberkategorie wandert in den Unterbaum, weil die Garderobe als
Kategoriebaum ja schon existiert. Die Markierung fahrt in beiden
Sicherungswegen mit; der Verlauf nicht, weil Einzelstuecke dort
ueberhaupt nicht vorkommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 21:47:02 +02:00

185 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
# Nur Tabellen mit fortlaufender Integer-id brauchen die Sequenz-Korrektur.
# Lagerorte tragen einen zufälligen Code als id – dort gibt es keine Sequenz.
_TABLES = ("units", "package_types", "categories", "field_definitions")
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, "usage_tracking": c.usage_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.name).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",
"usage_tracking": bool(r.get("usage_tracking", False)),
}, 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()