Verwaltbare Einheiten mit Umrechnung + Chargen bearbeiten + gezieltes Auslagern

Einheiten (neu):
- Tabelle units (name, kind=count|weight|volume, factor, is_builtin); eingebaut
  Stueck/Gramm/Kilogramm/Milliliter/Liter, Admin kann eigene anlegen (z.B. Pfund=500g).
- Bestaende bleiben intern in kanonischer Basis (Stueck/Gramm/Milliliter);
  Product.display_unit_id und Group.min_stock_unit_id als nullable FKs.
- Neuer Umrechnungs-Service (services/conversion.py) ersetzt die feste Einheitenlogik;
  Ein-/Auslagern und Gruppen-Mindestbestand rechnen ueber den Faktor.
- Gruppen-Mindestbestand mit Einheit; Gruppenbestand summiert nur Produkte
  passender Art. Neue Verwaltungsseite "Einheiten" (Admin).
- Schonende Migration beim Start: ADD COLUMN IF NOT EXISTS (Postgres), damit
  bestehende Installationen ihre Daten behalten.

Chargen:
- PATCH /lots/{id} und DELETE /lots/{id}: Menge/MHD korrigieren, Charge loeschen
  (wird als Korrektur-Bewegung protokolliert). Bearbeitung im Produktdetail.

Auslagern:
- Optionales lot_id: gezielt aus einer bestimmten Charge/MHD abbuchen statt FEFO;
  Auswahl-Dropdown in der Auslagern-Seite.

Sonstiges: Roadmap aktualisiert, Tests fuer die Umrechnung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 12:33:14 +02:00
parent d4e7ff8e3d
commit 0310cdfbd6
26 changed files with 1015 additions and 177 deletions

View File

@@ -0,0 +1,90 @@
"""Einheiten-Umrechnung auf Basis der verwaltbaren Unit-Tabelle.
Bestände werden immer in der kanonischen Basiseinheit der jeweiligen Art
gespeichert: Stück (count), Gramm (weight), Milliliter (volume). Verwaltete
Einheiten (z.B. Kilogramm, Liter, Pfund) rechnen über ihren Faktor dorthin um.
"""
from __future__ import annotations
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..models import BaseUnit, Product, Unit, UnitKind
BASE_OF_KIND: dict[UnitKind, BaseUnit] = {
UnitKind.count: BaseUnit.piece,
UnitKind.weight: BaseUnit.gram,
UnitKind.volume: BaseUnit.milliliter,
}
KIND_OF_BASE: dict[BaseUnit, UnitKind] = {v: k for k, v in BASE_OF_KIND.items()}
BASE_LABEL: dict[BaseUnit, str] = {
BaseUnit.piece: "Stück",
BaseUnit.gram: "Gramm",
BaseUnit.milliliter: "Milliliter",
}
PACKAGE_TOKENS = {"package", "packung", "pkg", "pack"}
# Kürzel/Aliase, die direkt auf eingebaute Einheitennamen zeigen.
_ALIAS = {
"g": "gramm", "gram": "gramm", "gramm": "gramm",
"kg": "kilogramm", "kilogramm": "kilogramm",
"ml": "milliliter", "milliliter": "milliliter",
"l": "liter", "liter": "liter",
"stück": "stück", "stueck": "stück", "st": "stück", "stk": "stück", "piece": "stück",
}
class ConversionError(ValueError):
"""Fachlicher Fehler bei der Einheiten-Umrechnung."""
def kind_of_product(product: Product) -> UnitKind:
return KIND_OF_BASE[product.base_unit]
def find_unit(db: Session, token: str) -> Unit | None:
t = token.strip().lower()
t = _ALIAS.get(t, t)
return db.query(Unit).filter(func.lower(Unit.name) == t).first()
def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> float:
"""Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um."""
if quantity <= 0:
raise ConversionError("Menge muss größer als 0 sein")
t = (unit_token or "").strip().lower()
if t in PACKAGE_TOKENS:
if not product.package_size or product.package_size <= 0:
raise ConversionError(
"Für dieses Produkt ist keine Packungsgröße hinterlegt."
)
return quantity * product.package_size
unit = find_unit(db, t)
if unit is None:
raise ConversionError(f"Unbekannte Einheit: {unit_token}")
if unit.kind != kind_of_product(product):
raise ConversionError(
f"Einheit '{unit.name}' passt nicht zur Art des Produkts "
f"({BASE_LABEL[product.base_unit]})."
)
return quantity * unit.factor
def resolve_product_unit(db: Session, unit_id: int) -> tuple[BaseUnit, int]:
"""Ermittelt zu einer gewählten Einheit die kanonische Basiseinheit + Anzeige-ID."""
unit = db.get(Unit, unit_id)
if unit is None:
raise ConversionError("Einheit nicht gefunden")
return BASE_OF_KIND[unit.kind], unit.id
def display_unit_info(product: Product) -> tuple[str, float]:
"""Name und Faktor der Anzeigeeinheit des Produkts (Fallback: Basiseinheit)."""
if product.display_unit is not None:
return product.display_unit.name, product.display_unit.factor
return BASE_LABEL[product.base_unit], 1.0

View File

@@ -8,7 +8,7 @@ from sqlalchemy import asc
from sqlalchemy.orm import Session
from ..models import Lot, Movement, MovementType, Product, User
from .units import to_base_quantity
from .conversion import to_base
class StockError(ValueError):
@@ -34,7 +34,7 @@ def check_in(
note: str | None = None,
) -> Lot:
"""Legt eine neue Charge an und protokolliert die Bewegung."""
quantity_base = to_base_quantity(product, quantity, unit)
quantity_base = to_base(db, product, quantity, unit)
lot = Lot(
product_id=product.id,
@@ -59,6 +59,41 @@ def check_in(
return lot
def check_out_lot(
db: Session,
product: Product,
lot: Lot,
quantity: float,
unit: str,
user: User | None,
note: str | None = None,
) -> list[dict]:
"""Bucht gezielt von EINER Charge ab (manuelle Auswahl statt FEFO)."""
needed = to_base(db, product, quantity, unit)
if needed > lot.quantity + 1e-9:
raise StockError(
f"Diese Charge hat nur {lot.quantity:g} {product.base_unit.value} "
f"(benötigt {needed:g})."
)
lot.quantity -= needed
db.add(
Movement(
product_id=product.id,
lot_id=lot.id,
user_id=user.id if user else None,
type=MovementType.out,
quantity=needed,
unit_used=unit,
note=note,
)
)
affected = [{"lot_id": lot.id, "quantity": needed}]
if lot.quantity <= 1e-9:
db.delete(lot)
return affected
def _fefo_lots(db: Session, product_id: int) -> list[Lot]:
"""Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter."""
lots = (
@@ -88,7 +123,7 @@ def check_out(
Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück.
Wirft StockError, wenn der Gesamtbestand nicht ausreicht.
"""
needed = to_base_quantity(product, quantity, unit)
needed = to_base(db, product, quantity, unit)
available = current_stock(db, product.id)
if needed > available + 1e-9:
raise StockError(