Import/Export von Bestaenden (CSV + JSON), rein additiv
Export:
- GET /export/stock.csv - eine Zeile je Charge, Semikolon-getrennt mit BOM,
direkt in Excel/LibreOffice bearbeitbar; Mengen in der Artikeleinheit.
- GET /export/backup.json - vollstaendiges Backup (Einheiten, Gruppen,
Lagerorte, Produkte, Chargen), Referenzen ueber Namen statt IDs.
Import:
- POST /import/stock (Admin, Datei-Upload). Erkennt CSV oder JSON automatisch.
Arbeitet ausschliesslich additiv: unbekannte Produkte/Gruppen/Lagerorte/
Einheiten werden angelegt, Chargen ergaenzt - nichts wird geloescht.
Mengen koennen in Gebinde ("Glas") oder Einheiten ("Gramm") angegeben werden,
Datum als YYYY-MM-DD oder TT.MM.JJJJ. Fehlerhafte Zeilen werden uebersprungen
und im Ergebnis gemeldet; jeder Import wird als Bewegung protokolliert.
Frontend: neue Seite "Import / Export" (Verwaltung) mit Download-Buttons,
Datei-Upload, Ergebniszusammenfassung und einer Beschreibung der CSV-Spalten.
api.js kann jetzt Datei-Downloads mit Auth-Header und FormData-Uploads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ from .routers import (
|
|||||||
products,
|
products,
|
||||||
settings as settings_router,
|
settings as settings_router,
|
||||||
stock,
|
stock,
|
||||||
|
transfer,
|
||||||
units,
|
units,
|
||||||
users,
|
users,
|
||||||
views,
|
views,
|
||||||
@@ -89,4 +90,5 @@ app.include_router(locations.router)
|
|||||||
app.include_router(groups.router)
|
app.include_router(groups.router)
|
||||||
app.include_router(units.router)
|
app.include_router(units.router)
|
||||||
app.include_router(views.router)
|
app.include_router(views.router)
|
||||||
|
app.include_router(transfer.router)
|
||||||
app.include_router(settings_router.router)
|
app.include_router(settings_router.router)
|
||||||
|
|||||||
437
backend/app/routers/transfer.py
Normal file
437
backend/app/routers/transfer.py
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
"""Export und Import von Beständen (CSV für Tabellen, JSON für Backups).
|
||||||
|
|
||||||
|
Der Import arbeitet ausschließlich additiv: Unbekanntes wird angelegt, Chargen
|
||||||
|
werden ergänzt – es wird nie etwas gelöscht oder überschrieben.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, 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 ..models import (
|
||||||
|
BaseUnit,
|
||||||
|
Group,
|
||||||
|
Location,
|
||||||
|
Lot,
|
||||||
|
Movement,
|
||||||
|
MovementType,
|
||||||
|
Product,
|
||||||
|
Unit,
|
||||||
|
UnitKind,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from ..services.conversion import BASE_OF_KIND, display_unit_info, find_unit
|
||||||
|
|
||||||
|
router = APIRouter(tags=["transfer"])
|
||||||
|
|
||||||
|
CSV_FIELDS = [
|
||||||
|
"barcode", "name", "marke", "einheit", "packungsgroesse", "gebinde",
|
||||||
|
"gruppe", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort",
|
||||||
|
]
|
||||||
|
PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Export
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def _article_unit(product: Product) -> tuple[float, str]:
|
||||||
|
"""Faktor und Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit)."""
|
||||||
|
unit_name, unit_factor = display_unit_info(product)
|
||||||
|
if product.package_size and product.package_size > 0:
|
||||||
|
return product.package_size, (product.package_label or "Packung")
|
||||||
|
return unit_factor, unit_name
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/stock.csv")
|
||||||
|
def export_stock_csv(
|
||||||
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||||
|
) -> Response:
|
||||||
|
"""Eine Zeile je Charge – direkt in Excel/LibreOffice bearbeitbar."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf, delimiter=";", lineterminator="\r\n")
|
||||||
|
writer.writerow(CSV_FIELDS)
|
||||||
|
|
||||||
|
for product in db.query(Product).order_by(Product.name).all():
|
||||||
|
unit_name, _ = display_unit_info(product)
|
||||||
|
factor, amount_label = _article_unit(product)
|
||||||
|
base = [
|
||||||
|
product.barcode or "",
|
||||||
|
product.name,
|
||||||
|
product.brand or "",
|
||||||
|
unit_name,
|
||||||
|
product.package_size if product.package_size else "",
|
||||||
|
product.package_label or "",
|
||||||
|
product.group.name if product.group else "",
|
||||||
|
product.min_stock if product.min_stock is not None else "",
|
||||||
|
]
|
||||||
|
lots = (
|
||||||
|
db.query(Lot)
|
||||||
|
.filter(Lot.product_id == product.id)
|
||||||
|
.order_by(Lot.best_before.is_(None), Lot.best_before)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not lots:
|
||||||
|
# Produkt ohne Bestand: Stammdaten trotzdem mitnehmen.
|
||||||
|
writer.writerow(base + ["", "", "", ""])
|
||||||
|
continue
|
||||||
|
for lot in lots:
|
||||||
|
location = db.get(Location, lot.location_id) if lot.location_id else None
|
||||||
|
writer.writerow(
|
||||||
|
base
|
||||||
|
+ [
|
||||||
|
round(lot.quantity / factor, 6),
|
||||||
|
amount_label,
|
||||||
|
lot.best_before.isoformat() if lot.best_before else "",
|
||||||
|
location.name if location else "",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# BOM, damit Excel die Umlaute korrekt erkennt.
|
||||||
|
return Response(
|
||||||
|
content="" + buf.getvalue(),
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="bestand.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/backup.json")
|
||||||
|
def export_backup_json(
|
||||||
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||||
|
) -> Response:
|
||||||
|
"""Vollständiges Backup: Einheiten, Gruppen, Lagerorte, Produkte, Chargen."""
|
||||||
|
locations = db.query(Location).order_by(Location.id).all()
|
||||||
|
loc_name = {loc.id: loc.name for loc in locations}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"units": [
|
||||||
|
{"name": u.name, "kind": u.kind.value, "factor": u.factor}
|
||||||
|
for u in db.query(Unit).order_by(Unit.id).all()
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"name": g.name,
|
||||||
|
"min_stock": g.min_stock,
|
||||||
|
"min_stock_unit": g.min_stock_unit.name if g.min_stock_unit else None,
|
||||||
|
}
|
||||||
|
for g in db.query(Group).order_by(Group.id).all()
|
||||||
|
],
|
||||||
|
"locations": [
|
||||||
|
{"name": loc.name, "parent": loc_name.get(loc.parent_id)}
|
||||||
|
for loc in locations
|
||||||
|
],
|
||||||
|
"products": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for p in db.query(Product).order_by(Product.id).all():
|
||||||
|
unit_name, _ = display_unit_info(p)
|
||||||
|
lots = db.query(Lot).filter(Lot.product_id == p.id).all()
|
||||||
|
data["products"].append(
|
||||||
|
{
|
||||||
|
"barcode": p.barcode,
|
||||||
|
"name": p.name,
|
||||||
|
"brand": p.brand,
|
||||||
|
"image_url": p.image_url,
|
||||||
|
"unit": unit_name,
|
||||||
|
"package_size": p.package_size,
|
||||||
|
"package_label": p.package_label,
|
||||||
|
"group": p.group.name if p.group else None,
|
||||||
|
"min_stock": p.min_stock,
|
||||||
|
"min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None,
|
||||||
|
"min_stock_in_packages": bool(p.min_stock_in_packages),
|
||||||
|
"lots": [
|
||||||
|
{
|
||||||
|
"quantity": lot.quantity,
|
||||||
|
"best_before": lot.best_before.isoformat() if lot.best_before else None,
|
||||||
|
"location": loc_name.get(lot.location_id),
|
||||||
|
}
|
||||||
|
for lot in lots
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=json.dumps(data, ensure_ascii=False, indent=2),
|
||||||
|
media_type="application/json; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="project-good-backup.json"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Import (nur additiv)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def _num(value) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip().replace(",", ".")
|
||||||
|
if text == "":
|
||||||
|
return None
|
||||||
|
return float(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value) -> date | None:
|
||||||
|
text = (str(value) if value is not None else "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d.%m.%y"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise ValueError(f"Datum nicht lesbar: {text}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_location(db: Session, name: str | None) -> Location | None:
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
loc = db.query(Location).filter(Location.name == name).first()
|
||||||
|
if loc is None:
|
||||||
|
loc = Location(name=name)
|
||||||
|
db.add(loc)
|
||||||
|
db.flush()
|
||||||
|
return loc
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_group(db: Session, name: str | None) -> Group | None:
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
group = db.query(Group).filter(Group.name == name).first()
|
||||||
|
if group is None:
|
||||||
|
group = Group(name=name)
|
||||||
|
db.add(group)
|
||||||
|
db.flush()
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Product:
|
||||||
|
"""Sucht per Barcode, sonst per Name; legt das Produkt sonst an."""
|
||||||
|
barcode = (row.get("barcode") or "").strip() or None
|
||||||
|
name = (row.get("name") or "").strip()
|
||||||
|
if barcode:
|
||||||
|
product = db.query(Product).filter(Product.barcode == barcode).first()
|
||||||
|
if product:
|
||||||
|
return product
|
||||||
|
if name:
|
||||||
|
product = db.query(Product).filter(Product.name == name).first()
|
||||||
|
if product:
|
||||||
|
return product
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Produktname fehlt")
|
||||||
|
|
||||||
|
unit = find_unit(db, (row.get("einheit") or "Stück"))
|
||||||
|
if unit is None:
|
||||||
|
raise ValueError(f"Unbekannte Einheit: {row.get('einheit')}")
|
||||||
|
group = _get_or_create_group(db, row.get("gruppe"))
|
||||||
|
product = Product(
|
||||||
|
barcode=barcode,
|
||||||
|
name=name,
|
||||||
|
brand=(row.get("marke") or "").strip() or None,
|
||||||
|
base_unit=BASE_OF_KIND[unit.kind],
|
||||||
|
display_unit_id=unit.id,
|
||||||
|
package_size=_num(row.get("packungsgroesse")),
|
||||||
|
package_label=(row.get("gebinde") or "").strip() or None,
|
||||||
|
group_id=group.id if group else None,
|
||||||
|
min_stock=_num(row.get("mindestbestand")),
|
||||||
|
source="import",
|
||||||
|
)
|
||||||
|
db.add(product)
|
||||||
|
db.flush()
|
||||||
|
created.append(product.name)
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def _quantity_to_base(db: Session, product: Product, amount: float, token: str) -> float:
|
||||||
|
"""Rechnet eine Importmenge in Basiseinheiten um (kennt auch das Gebinde)."""
|
||||||
|
text = (token or "").strip().lower()
|
||||||
|
package_label = (product.package_label or "Packung").strip().lower()
|
||||||
|
if text in PACKAGE_TOKENS or (text and text == package_label):
|
||||||
|
if not product.package_size:
|
||||||
|
raise ValueError(f"'{token}': keine Packungsgröße für {product.name} hinterlegt")
|
||||||
|
return amount * product.package_size
|
||||||
|
if not text:
|
||||||
|
return amount # ohne Angabe: Basiseinheiten
|
||||||
|
unit = find_unit(db, text)
|
||||||
|
if unit is None:
|
||||||
|
raise ValueError(f"Unbekannte Einheit: {token}")
|
||||||
|
return amount * unit.factor
|
||||||
|
|
||||||
|
|
||||||
|
def _import_csv(db: Session, content: bytes, user: User) -> dict:
|
||||||
|
text = content.decode("utf-8-sig", errors="replace")
|
||||||
|
sample = text[:2048]
|
||||||
|
delimiter = ";" if sample.count(";") >= sample.count(",") else ","
|
||||||
|
reader = csv.DictReader(io.StringIO(text), delimiter=delimiter)
|
||||||
|
|
||||||
|
created_products: list[str] = []
|
||||||
|
lots_added = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for index, raw in enumerate(reader, start=2): # Zeile 1 = Kopfzeile
|
||||||
|
row = {(k or "").strip().lower(): (v if v is not None else "") for k, v in raw.items()}
|
||||||
|
if not any(str(v).strip() for v in row.values()):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
product = _get_or_create_product(db, row, created_products)
|
||||||
|
amount = _num(row.get("menge"))
|
||||||
|
if amount is None or amount <= 0:
|
||||||
|
continue # Zeile ohne Bestand: nur Stammdaten
|
||||||
|
quantity = _quantity_to_base(db, product, amount, row.get("menge_einheit", ""))
|
||||||
|
location = _get_or_create_location(db, row.get("lagerort"))
|
||||||
|
lot = Lot(
|
||||||
|
product_id=product.id,
|
||||||
|
quantity=quantity,
|
||||||
|
best_before=_parse_date(row.get("mhd")),
|
||||||
|
location_id=location.id if location else None,
|
||||||
|
)
|
||||||
|
db.add(lot)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
Movement(
|
||||||
|
product_id=product.id,
|
||||||
|
lot_id=lot.id,
|
||||||
|
user_id=user.id,
|
||||||
|
type=MovementType.in_,
|
||||||
|
quantity=quantity,
|
||||||
|
unit_used=row.get("menge_einheit") or "base",
|
||||||
|
note="Import",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lots_added += 1
|
||||||
|
except Exception as exc: # eine fehlerhafte Zeile darf den Rest nicht stoppen
|
||||||
|
errors.append(f"Zeile {index}: {exc}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"format": "csv",
|
||||||
|
"products_created": len(created_products),
|
||||||
|
"lots_added": lots_added,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _import_json(db: Session, content: bytes, user: User) -> dict:
|
||||||
|
data = json.loads(content.decode("utf-8-sig", errors="replace"))
|
||||||
|
created_products: list[str] = []
|
||||||
|
lots_added = 0
|
||||||
|
units_created = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for entry in data.get("units", []):
|
||||||
|
try:
|
||||||
|
if db.query(Unit).filter(Unit.name == entry["name"]).first():
|
||||||
|
continue
|
||||||
|
db.add(
|
||||||
|
Unit(
|
||||||
|
name=entry["name"],
|
||||||
|
kind=UnitKind(entry.get("kind", "count")),
|
||||||
|
factor=float(entry.get("factor", 1)),
|
||||||
|
is_builtin=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
units_created += 1
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"Einheit {entry!r}: {exc}")
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
for entry in data.get("groups", []):
|
||||||
|
_get_or_create_group(db, entry.get("name"))
|
||||||
|
for entry in data.get("locations", []):
|
||||||
|
_get_or_create_location(db, entry.get("name"))
|
||||||
|
db.flush()
|
||||||
|
# Übergeordnete Lagerorte nachziehen
|
||||||
|
for entry in data.get("locations", []):
|
||||||
|
parent_name = entry.get("parent")
|
||||||
|
if not parent_name:
|
||||||
|
continue
|
||||||
|
child = db.query(Location).filter(Location.name == entry.get("name")).first()
|
||||||
|
parent = db.query(Location).filter(Location.name == parent_name).first()
|
||||||
|
if child and parent and child.parent_id is None and child.id != parent.id:
|
||||||
|
child.parent_id = parent.id
|
||||||
|
|
||||||
|
for entry in data.get("products", []):
|
||||||
|
try:
|
||||||
|
row = {
|
||||||
|
"barcode": entry.get("barcode") or "",
|
||||||
|
"name": entry.get("name") or "",
|
||||||
|
"marke": entry.get("brand") or "",
|
||||||
|
"einheit": entry.get("unit") or "Stück",
|
||||||
|
"packungsgroesse": entry.get("package_size") or "",
|
||||||
|
"gebinde": entry.get("package_label") or "",
|
||||||
|
"gruppe": entry.get("group") or "",
|
||||||
|
"mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
|
||||||
|
}
|
||||||
|
product = _get_or_create_product(db, row, created_products)
|
||||||
|
for lot_entry in entry.get("lots", []):
|
||||||
|
quantity = float(lot_entry.get("quantity") or 0)
|
||||||
|
if quantity <= 0:
|
||||||
|
continue
|
||||||
|
location = _get_or_create_location(db, lot_entry.get("location"))
|
||||||
|
lot = Lot(
|
||||||
|
product_id=product.id,
|
||||||
|
quantity=quantity,
|
||||||
|
best_before=_parse_date(lot_entry.get("best_before")),
|
||||||
|
location_id=location.id if location else None,
|
||||||
|
)
|
||||||
|
db.add(lot)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
Movement(
|
||||||
|
product_id=product.id,
|
||||||
|
lot_id=lot.id,
|
||||||
|
user_id=user.id,
|
||||||
|
type=MovementType.in_,
|
||||||
|
quantity=quantity,
|
||||||
|
unit_used="base",
|
||||||
|
note="Import",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lots_added += 1
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"Produkt {entry.get('name')!r}: {exc}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"format": "json",
|
||||||
|
"units_created": units_created,
|
||||||
|
"products_created": len(created_products),
|
||||||
|
"lots_added": lots_added,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/stock")
|
||||||
|
def import_stock(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
"""Importiert CSV oder JSON. Additiv – es wird nichts gelöscht."""
|
||||||
|
content = file.file.read()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Datei ist leer")
|
||||||
|
|
||||||
|
filename = (file.filename or "").lower()
|
||||||
|
stripped = content.lstrip()[:1]
|
||||||
|
is_json = filename.endswith(".json") or stripped in (b"{", b"[")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _import_json(db, content, user) if is_json else _import_csv(db, content, user)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"JSON nicht lesbar: {exc}") from exc
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Import fehlgeschlagen: {exc}") from exc
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return result
|
||||||
@@ -32,6 +32,10 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten.
|
|||||||
- **Lagerorte:** beliebig tief verschachtelbar (Schrank → Fach → Kiste → …).
|
- **Lagerorte:** beliebig tief verschachtelbar (Schrank → Fach → Kiste → …).
|
||||||
- **Mindestbestände:** je Produkt wahlweise in der Produkteinheit oder in Packungen.
|
- **Mindestbestände:** je Produkt wahlweise in der Produkteinheit oder in Packungen.
|
||||||
- **Verlauf:** Bewegungsprotokoll (wer/was/wann), **Einstellungen:** Ablauf-Warnfrist.
|
- **Verlauf:** Bewegungsprotokoll (wer/was/wann), **Einstellungen:** Ablauf-Warnfrist.
|
||||||
|
- **Import / Export:** CSV (eine Zeile je Charge, in Excel bearbeitbar) und JSON
|
||||||
|
(vollständiges Backup inkl. Einheiten, Gruppen, Lagerorte). Der Import ist rein
|
||||||
|
additiv – unbekannte Produkte/Gruppen/Lagerorte werden angelegt, Chargen ergänzt,
|
||||||
|
nichts gelöscht oder überschrieben.
|
||||||
- **Migration:** schonendes Nachziehen neuer Spalten beim Start
|
- **Migration:** schonendes Nachziehen neuer Spalten beim Start
|
||||||
(`ADD COLUMN IF NOT EXISTS`), damit bestehende Installationen ihre Daten behalten.
|
(`ADD COLUMN IF NOT EXISTS`), damit bestehende Installationen ihre Daten behalten.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Units from "./pages/Units";
|
|||||||
import Users from "./pages/Users";
|
import Users from "./pages/Users";
|
||||||
import ShoppingList from "./pages/ShoppingList";
|
import ShoppingList from "./pages/ShoppingList";
|
||||||
import History from "./pages/History";
|
import History from "./pages/History";
|
||||||
|
import Transfer from "./pages/Transfer";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
|
|
||||||
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
|
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
|
||||||
@@ -55,6 +56,7 @@ function Sidebar() {
|
|||||||
<NavItem to="/locations" icon="location" label="Lagerorte" />
|
<NavItem to="/locations" icon="location" label="Lagerorte" />
|
||||||
<NavItem to="/units" icon="box" label="Einheiten" />
|
<NavItem to="/units" icon="box" label="Einheiten" />
|
||||||
<NavItem to="/users" icon="users" label="Benutzer" />
|
<NavItem to="/users" icon="users" label="Benutzer" />
|
||||||
|
<NavItem to="/transfer" icon="history" label="Import / Export" />
|
||||||
<NavItem to="/settings" icon="settings" label="Einstellungen" />
|
<NavItem to="/settings" icon="settings" label="Einstellungen" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -108,6 +110,7 @@ export default function App() {
|
|||||||
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
|
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
|
||||||
<Route path="/units" element={<Protected adminOnly><Units /></Protected>} />
|
<Route path="/units" element={<Protected adminOnly><Units /></Protected>} />
|
||||||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||||
|
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
|
||||||
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
|
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -20,13 +20,16 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(path, { method = "GET", body, form } = {}) {
|
async function request(path, { method = "GET", body, form, formData } = {}) {
|
||||||
const headers = {};
|
const headers = {};
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
|
||||||
let payload;
|
let payload;
|
||||||
if (form) {
|
if (formData) {
|
||||||
|
// Content-Type (inkl. boundary) setzt der Browser selbst.
|
||||||
|
payload = formData;
|
||||||
|
} else if (form) {
|
||||||
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||||
payload = new URLSearchParams(form).toString();
|
payload = new URLSearchParams(form).toString();
|
||||||
} else if (body !== undefined) {
|
} else if (body !== undefined) {
|
||||||
@@ -69,6 +72,26 @@ async function request(path, { method = "GET", body, form } = {}) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Datei mit Auth-Header laden und im Browser als Download anbieten.
|
||||||
|
export async function downloadFile(path, filename) {
|
||||||
|
const token = getToken();
|
||||||
|
const resp = await fetch(`${API_BASE}${path}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new ApiError(`Download fehlgeschlagen (${resp.status})`, resp.status);
|
||||||
|
}
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
login: (username, password) =>
|
login: (username, password) =>
|
||||||
request("/auth/login", { method: "POST", form: { username, password } }),
|
request("/auth/login", { method: "POST", form: { username, password } }),
|
||||||
@@ -107,6 +130,15 @@ export const api = {
|
|||||||
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
|
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
|
||||||
deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }),
|
deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Export / Import
|
||||||
|
exportCsv: () => downloadFile("/export/stock.csv", "bestand.csv"),
|
||||||
|
exportJson: () => downloadFile("/export/backup.json", "project-good-backup.json"),
|
||||||
|
importStock: (file) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
return request("/import/stock", { method: "POST", formData: fd });
|
||||||
|
},
|
||||||
|
|
||||||
listUnits: () => request("/units"),
|
listUnits: () => request("/units"),
|
||||||
createUnit: (body) => request("/units", { method: "POST", body }),
|
createUnit: (body) => request("/units", { method: "POST", body }),
|
||||||
deleteUnit: (id) => request(`/units/${id}`, { method: "DELETE" }),
|
deleteUnit: (id) => request(`/units/${id}`, { method: "DELETE" }),
|
||||||
|
|||||||
139
web/src/pages/Transfer.jsx
Normal file
139
web/src/pages/Transfer.jsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
import Icon from "../components/Icon";
|
||||||
|
|
||||||
|
export default function Transfer() {
|
||||||
|
const fileRef = useRef(null);
|
||||||
|
const [file, setFile] = useState(null);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function download(kind) {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (kind === "csv") await api.exportCsv();
|
||||||
|
else await api.exportJson();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doImport(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!file) return;
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setResult(await api.importStock(file));
|
||||||
|
setFile(null);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Import / Export</h1>
|
||||||
|
<div className="sub">Bestände samt Chargen und MHD sichern oder einspielen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||||
|
|
||||||
|
<div className="grid-2">
|
||||||
|
<section className="card">
|
||||||
|
<div className="card-head"><Icon name="checkout" /><h2>Export</h2></div>
|
||||||
|
<p className="muted small mt-0">
|
||||||
|
<strong>CSV</strong> enthält eine Zeile je Charge und lässt sich in Excel oder
|
||||||
|
LibreOffice bearbeiten. <strong>JSON</strong> ist ein vollständiges Backup inklusive
|
||||||
|
Einheiten, Gruppen und Lagerorten.
|
||||||
|
</p>
|
||||||
|
<div className="field-inline">
|
||||||
|
<button className="btn primary" onClick={() => download("csv")}>
|
||||||
|
<Icon name="package" size={16} />Bestand als CSV
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={() => download("json")}>
|
||||||
|
<Icon name="box" size={16} />Backup als JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="card-head"><Icon name="checkin" /><h2>Import</h2></div>
|
||||||
|
<p className="muted small mt-0">
|
||||||
|
CSV oder JSON auswählen. Der Import <strong>ergänzt nur</strong>: unbekannte Produkte,
|
||||||
|
Gruppen und Lagerorte werden angelegt, Chargen hinzugefügt. Es wird nichts gelöscht
|
||||||
|
oder überschrieben.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={doImport}>
|
||||||
|
<label>
|
||||||
|
Datei
|
||||||
|
<input ref={fileRef} type="file" accept=".csv,.json,text/csv,application/json"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] || null)} />
|
||||||
|
</label>
|
||||||
|
<button className="btn primary" disabled={!file || busy}>
|
||||||
|
{busy ? "Importiere…" : "Importieren"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="alert ok" style={{ marginTop: "var(--sp-4)" }}>
|
||||||
|
<Icon name="check" size={16} />
|
||||||
|
<span>
|
||||||
|
Import abgeschlossen ({result.format?.toUpperCase()}):{" "}
|
||||||
|
{result.products_created} Produkt(e) angelegt, {result.lots_added} Charge(n) ergänzt
|
||||||
|
{result.units_created ? `, ${result.units_created} Einheit(en) angelegt` : ""}.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result?.errors?.length > 0 && (
|
||||||
|
<div className="alert warn">
|
||||||
|
<Icon name="alert" size={16} />
|
||||||
|
<span>
|
||||||
|
{result.errors.length} Zeile(n) übersprungen:
|
||||||
|
<ul style={{ margin: "6px 0 0", paddingLeft: 18 }}>
|
||||||
|
{result.errors.slice(0, 10).map((e, i) => <li key={i}>{e}</li>)}
|
||||||
|
</ul>
|
||||||
|
{result.errors.length > 10 && <div>… und {result.errors.length - 10} weitere.</div>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="card-head"><Icon name="package" /><h2>CSV-Aufbau</h2></div>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Spalte</th><th>Bedeutung</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td className="strong">barcode</td><td>optional; dient zum Wiedererkennen vorhandener Produkte</td></tr>
|
||||||
|
<tr><td className="strong">name</td><td>Produktname (Pflicht, wenn kein Barcode passt)</td></tr>
|
||||||
|
<tr><td className="strong">marke</td><td>optional</td></tr>
|
||||||
|
<tr><td className="strong">einheit</td><td>Produkteinheit, z.B. Gramm, Kilogramm, Stück</td></tr>
|
||||||
|
<tr><td className="strong">packungsgroesse</td><td>Basiseinheiten je Gebinde, z.B. 195</td></tr>
|
||||||
|
<tr><td className="strong">gebinde</td><td>Bezeichnung, z.B. Glas, Tüte, Flasche</td></tr>
|
||||||
|
<tr><td className="strong">gruppe</td><td>optional; wird bei Bedarf angelegt</td></tr>
|
||||||
|
<tr><td className="strong">mindestbestand</td><td>optional, in Basiseinheiten</td></tr>
|
||||||
|
<tr><td className="strong">menge</td><td>Menge dieser Charge</td></tr>
|
||||||
|
<tr><td className="strong">menge_einheit</td><td>Einheit der Menge: Gebinde-Bezeichnung oder z.B. Gramm</td></tr>
|
||||||
|
<tr><td className="strong">mhd</td><td>Datum, z.B. 2026-12-23 oder 23.12.2026 – leer erlaubt</td></tr>
|
||||||
|
<tr><td className="strong">lagerort</td><td>optional; wird bei Bedarf angelegt</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="muted small">
|
||||||
|
Am einfachsten: einmal exportieren, die Datei als Vorlage nehmen und ergänzen.
|
||||||
|
Trennzeichen ist ein Semikolon; Komma wird ebenfalls erkannt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user