diff --git a/backend/app/main.py b/backend/app/main.py index 2796e3e..b35a46b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ from .routers import ( products, settings as settings_router, stock, + transfer, units, users, views, @@ -89,4 +90,5 @@ app.include_router(locations.router) app.include_router(groups.router) app.include_router(units.router) app.include_router(views.router) +app.include_router(transfer.router) app.include_router(settings_router.router) diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py new file mode 100644 index 0000000..1909318 --- /dev/null +++ b/backend/app/routers/transfer.py @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 21b033b..f55dc41 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -32,6 +32,10 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten. - **Lagerorte:** beliebig tief verschachtelbar (Schrank → Fach → Kiste → …). - **Mindestbestände:** je Produkt wahlweise in der Produkteinheit oder in Packungen. - **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 (`ADD COLUMN IF NOT EXISTS`), damit bestehende Installationen ihre Daten behalten. diff --git a/web/src/App.jsx b/web/src/App.jsx index 4fc2722..25e1152 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -13,6 +13,7 @@ import Units from "./pages/Units"; import Users from "./pages/Users"; import ShoppingList from "./pages/ShoppingList"; import History from "./pages/History"; +import Transfer from "./pages/Transfer"; import Settings from "./pages/Settings"; const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link"); @@ -55,6 +56,7 @@ function Sidebar() { + )} @@ -108,6 +110,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index bb91f24..fd061cd 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -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 token = getToken(); if (token) headers["Authorization"] = `Bearer ${token}`; 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"; payload = new URLSearchParams(form).toString(); } else if (body !== undefined) { @@ -69,6 +72,26 @@ async function request(path, { method = "GET", body, form } = {}) { 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 = { login: (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 }), 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"), createUnit: (body) => request("/units", { method: "POST", body }), deleteUnit: (id) => request(`/units/${id}`, { method: "DELETE" }), diff --git a/web/src/pages/Transfer.jsx b/web/src/pages/Transfer.jsx new file mode 100644 index 0000000..4c6230d --- /dev/null +++ b/web/src/pages/Transfer.jsx @@ -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 ( +
+
+
+

Import / Export

+
Bestände samt Chargen und MHD sichern oder einspielen
+
+
+ + {error &&
{error}
} + +
+
+

Export

+

+ CSV enthält eine Zeile je Charge und lässt sich in Excel oder + LibreOffice bearbeiten. JSON ist ein vollständiges Backup inklusive + Einheiten, Gruppen und Lagerorten. +

+
+ + +
+
+ +
+

Import

+

+ CSV oder JSON auswählen. Der Import ergänzt nur: unbekannte Produkte, + Gruppen und Lagerorte werden angelegt, Chargen hinzugefügt. Es wird nichts gelöscht + oder überschrieben. +

+
+ + +
+ + {result && ( +
+ + + 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` : ""}. + +
+ )} + {result?.errors?.length > 0 && ( +
+ + + {result.errors.length} Zeile(n) übersprungen: +
    + {result.errors.slice(0, 10).map((e, i) =>
  • {e}
  • )} +
+ {result.errors.length > 10 &&
… und {result.errors.length - 10} weitere.
} +
+
+ )} +
+
+ +
+

CSV-Aufbau

+
+ + + + + + + + + + + + + + + + +
SpalteBedeutung
barcodeoptional; dient zum Wiedererkennen vorhandener Produkte
nameProduktname (Pflicht, wenn kein Barcode passt)
markeoptional
einheitProdukteinheit, z.B. Gramm, Kilogramm, Stück
packungsgroesseBasiseinheiten je Gebinde, z.B. 195
gebindeBezeichnung, z.B. Glas, Tüte, Flasche
gruppeoptional; wird bei Bedarf angelegt
mindestbestandoptional, in Basiseinheiten
mengeMenge dieser Charge
menge_einheitEinheit der Menge: Gebinde-Bezeichnung oder z.B. Gramm
mhdDatum, z.B. 2026-12-23 oder 23.12.2026 – leer erlaubt
lagerortoptional; wird bei Bedarf angelegt
+
+

+ Am einfachsten: einmal exportieren, die Datei als Vorlage nehmen und ergänzen. + Trennzeichen ist ein Semikolon; Komma wird ebenfalls erkannt. +

+
+
+ ); +}