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
|
import json
|
||||||
from datetime import date, datetime, timezone
|
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 fastapi.responses import Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
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.dates import MONTH, clean_precision, normalize_best_before
|
||||||
from ..services.group_codes import sync as sync_group_code
|
from ..services.group_codes import sync as sync_group_code
|
||||||
from ..models import (
|
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")
|
@router.get("/export/labels")
|
||||||
def export_labels(
|
def export_labels(
|
||||||
category_ids: str | None = None,
|
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()
|
||||||
91
backend/tests/test_master_data.py
Normal file
91
backend/tests/test_master_data.py
Normal 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
|
||||||
@@ -197,6 +197,11 @@ export const api = {
|
|||||||
setGroupLocationMinStock: (id, list) =>
|
setGroupLocationMinStock: (id, list) =>
|
||||||
request(`/groups/${id}/location-min-stock`, { method: "PUT", body: 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
|
// Views
|
||||||
shoppingList: () => request("/shopping-list"),
|
shoppingList: () => request("/shopping-list"),
|
||||||
groupShoppingList: () => request("/shopping-list/groups"),
|
groupShoppingList: () => request("/shopping-list/groups"),
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export default function Locations() {
|
|||||||
try {
|
try {
|
||||||
await api.createLocation({ name, parent_id: parentId === "" ? null : Number(parentId) });
|
await api.createLocation({ name, parent_id: parentId === "" ? null : Number(parentId) });
|
||||||
setName("");
|
setName("");
|
||||||
setParentId("");
|
// Übergeordneten Ort absichtlich stehen lassen – beim Anlegen vieler Orte
|
||||||
|
// unter demselben Elternteil spart das jedes Mal die Neuauswahl.
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ export default function Transfer() {
|
|||||||
const [locations, setLocations] = useState([]);
|
const [locations, setLocations] = useState([]);
|
||||||
const [locBusy, setLocBusy] = useState(false);
|
const [locBusy, setLocBusy] = useState(false);
|
||||||
const [locInfo, setLocInfo] = useState(null);
|
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(() => {
|
useEffect(() => {
|
||||||
api.listCategories().then(setCategories).catch(() => {});
|
api.listCategories().then(setCategories).catch(() => {});
|
||||||
@@ -93,6 +98,33 @@ export default function Transfer() {
|
|||||||
URL.revokeObjectURL(url);
|
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() {
|
function exportLocationLabels() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLocInfo(null);
|
setLocInfo(null);
|
||||||
@@ -294,6 +326,41 @@ export default function Transfer() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="card-head"><Icon name="box" /><h2>Stammdaten sichern & wiederherstellen (JSON)</h2></div>
|
||||||
|
<p className="muted small mt-0">
|
||||||
|
Kategorien (inkl. eigener Felder), Lagerorte, Einheiten und Gebinde als <strong>eine
|
||||||
|
JSON-Datei</strong>. Die <strong>IDs kommen mit</strong> – 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.)
|
||||||
|
</p>
|
||||||
|
<div className="grid-2">
|
||||||
|
<div>
|
||||||
|
<button className="btn primary" disabled={mdBusy} onClick={exportMasterData}>
|
||||||
|
<Icon name="download" size={16} />Stammdaten exportieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={importMasterData}>
|
||||||
|
<label>
|
||||||
|
Datei (JSON)
|
||||||
|
<input ref={mdFileRef} type="file" accept="application/json,.json" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Bei bereits vergebener ID
|
||||||
|
<select value={mdMode} onChange={(e) => setMdMode(e.target.value)}>
|
||||||
|
<option value="skip">Vorhandenes behalten (nur Neues anlegen)</option>
|
||||||
|
<option value="overwrite">Überschreiben (per ID aktualisieren)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button className="btn" disabled={mdBusy}>
|
||||||
|
<Icon name="checkin" size={16} />{mdBusy ? "Arbeite…" : "Stammdaten importieren"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{mdInfo && <p className="muted small">{mdInfo}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="card-head"><Icon name="tag" /><h2>QR-Etiketten-Export (P-touch & Co.)</h2></div>
|
<div className="card-head"><Icon name="tag" /><h2>QR-Etiketten-Export (P-touch & Co.)</h2></div>
|
||||||
<p className="muted small mt-0">
|
<p className="muted small mt-0">
|
||||||
|
|||||||
Reference in New Issue
Block a user