iOS-App angefangen; Import-Modi; MHD-Formatierung; Gebinde in Ablauf-Ansichten
iOS (neu, ios/):
- SwiftUI-App: Login (Server + Keychain-Token), Kamera-Scanner (EAN/UPC/Code128),
Einlagern mit mehreren Chargen und eigenem MHD, Auslagern mit Chargenauswahl
oder FEFO, Artikelsuche, Anlegen mit Open-Food-Facts-Vorbefuellung.
- Home-Screen-Shortcuts: Schnellaktionen (langer Druck) und URL-Schema
projectgood://checkin | ://checkout fuer eigene Symbole via Kurzbefehle.
- project.yml (XcodeGen) + README mit Build-Anleitung. NICHT kompiliert - auf
diesem Rechner ist kein Xcode vorhanden.
Import-Modi (wie besprochen sinnvoll):
- add (Standard, nichts loeschen), replace_listed (Bestaende der in der Datei
genannten Produkte ersetzen - fuer Inventur), replace_all (alles ersetzen).
Geleerte Bestaende werden als Korrektur-Bewegung protokolliert; die Oberflaeche
fragt bei den zerstoerenden Modi nach.
Anzeige:
- "Bald ablaufend"/"Abgelaufen" zeigen jetzt das Gebinde ("1 Glas") mit der
Basiseinheit klein darunter (ExpiringItem liefert die Einheiten mit).
- MHD als Zeitspanne mit korrektem Numerus: "in 3 Tagen", "in 1 Woche",
"vor 2 Wochen", dazu heute/morgen/gestern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -191,6 +191,35 @@ def _parse_date(value) -> date | None:
|
||||
raise ValueError(f"Datum nicht lesbar: {text}")
|
||||
|
||||
|
||||
IMPORT_MODES = {"add", "replace_listed", "replace_all"}
|
||||
|
||||
|
||||
def _clear_lots(db: Session, product: Product, user: User) -> None:
|
||||
"""Entfernt alle Chargen eines Produkts und protokolliert das als Korrektur."""
|
||||
lots = db.query(Lot).filter(Lot.product_id == product.id).all()
|
||||
total = float(sum(lot.quantity for lot in lots))
|
||||
for lot in lots:
|
||||
db.delete(lot)
|
||||
if total > 0:
|
||||
db.add(
|
||||
Movement(
|
||||
product_id=product.id,
|
||||
lot_id=None,
|
||||
user_id=user.id,
|
||||
type=MovementType.adjust,
|
||||
quantity=-total,
|
||||
unit_used="base",
|
||||
note="Import: Bestand ersetzt",
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
|
||||
def _clear_all_lots(db: Session, user: User) -> None:
|
||||
for product in db.query(Product).all():
|
||||
_clear_lots(db, product, user)
|
||||
|
||||
|
||||
def _get_or_create_location(db: Session, name: str | None) -> Location | None:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
@@ -268,7 +297,7 @@ def _quantity_to_base(db: Session, product: Product, amount: float, token: str)
|
||||
return amount * unit.factor
|
||||
|
||||
|
||||
def _import_csv(db: Session, content: bytes, user: User) -> dict:
|
||||
def _import_csv(db: Session, content: bytes, user: User, mode: str) -> dict:
|
||||
text = content.decode("utf-8-sig", errors="replace")
|
||||
sample = text[:2048]
|
||||
delimiter = ";" if sample.count(";") >= sample.count(",") else ","
|
||||
@@ -277,6 +306,10 @@ def _import_csv(db: Session, content: bytes, user: User) -> dict:
|
||||
created_products: list[str] = []
|
||||
lots_added = 0
|
||||
errors: list[str] = []
|
||||
cleared: set[int] = set()
|
||||
|
||||
if mode == "replace_all":
|
||||
_clear_all_lots(db, user)
|
||||
|
||||
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()}
|
||||
@@ -284,6 +317,11 @@ def _import_csv(db: Session, content: bytes, user: User) -> dict:
|
||||
continue
|
||||
try:
|
||||
product = _get_or_create_product(db, row, created_products)
|
||||
# Beim Ersetzen: Bestand des Produkts einmalig leeren, bevor die
|
||||
# Zeilen dieser Datei dazukommen.
|
||||
if mode == "replace_listed" and product.id not in cleared:
|
||||
_clear_lots(db, product, user)
|
||||
cleared.add(product.id)
|
||||
amount = _num(row.get("menge"))
|
||||
if amount is None or amount <= 0:
|
||||
continue # Zeile ohne Bestand: nur Stammdaten
|
||||
@@ -314,18 +352,24 @@ def _import_csv(db: Session, content: bytes, user: User) -> dict:
|
||||
|
||||
return {
|
||||
"format": "csv",
|
||||
"mode": mode,
|
||||
"products_created": len(created_products),
|
||||
"products_cleared": len(cleared),
|
||||
"lots_added": lots_added,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def _import_json(db: Session, content: bytes, user: User) -> dict:
|
||||
def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
|
||||
data = json.loads(content.decode("utf-8-sig", errors="replace"))
|
||||
created_products: list[str] = []
|
||||
lots_added = 0
|
||||
units_created = 0
|
||||
errors: list[str] = []
|
||||
cleared: set[int] = set()
|
||||
|
||||
if mode == "replace_all":
|
||||
_clear_all_lots(db, user)
|
||||
|
||||
for entry in data.get("units", []):
|
||||
try:
|
||||
@@ -372,6 +416,9 @@ def _import_json(db: Session, content: bytes, user: User) -> dict:
|
||||
"mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
|
||||
}
|
||||
product = _get_or_create_product(db, row, created_products)
|
||||
if mode == "replace_listed" and product.id not in cleared:
|
||||
_clear_lots(db, product, user)
|
||||
cleared.add(product.id)
|
||||
for lot_entry in entry.get("lots", []):
|
||||
quantity = float(lot_entry.get("quantity") or 0)
|
||||
if quantity <= 0:
|
||||
@@ -402,8 +449,10 @@ def _import_json(db: Session, content: bytes, user: User) -> dict:
|
||||
|
||||
return {
|
||||
"format": "json",
|
||||
"mode": mode,
|
||||
"units_created": units_created,
|
||||
"products_created": len(created_products),
|
||||
"products_cleared": len(cleared),
|
||||
"lots_added": lots_added,
|
||||
"errors": errors,
|
||||
}
|
||||
@@ -411,11 +460,21 @@ def _import_json(db: Session, content: bytes, user: User) -> dict:
|
||||
|
||||
@router.post("/import/stock")
|
||||
def import_stock(
|
||||
mode: str = "add",
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""Importiert CSV oder JSON. Additiv – es wird nichts gelöscht."""
|
||||
"""Importiert CSV oder JSON.
|
||||
|
||||
mode:
|
||||
add – nur ergänzen (Standard, löscht nie etwas)
|
||||
replace_listed – Bestand der in der Datei genannten Produkte ersetzen
|
||||
replace_all – alle Bestände vorher leeren (vollständige Wiederherstellung)
|
||||
"""
|
||||
if mode not in IMPORT_MODES:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unbekannter Modus: {mode}")
|
||||
|
||||
content = file.file.read()
|
||||
if not content:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Datei ist leer")
|
||||
@@ -425,7 +484,11 @@ def import_stock(
|
||||
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)
|
||||
result = (
|
||||
_import_json(db, content, user, mode)
|
||||
if is_json
|
||||
else _import_csv(db, content, user, mode)
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"JSON nicht lesbar: {exc}") from exc
|
||||
|
||||
Reference in New Issue
Block a user