Gruppen bekommen ein eigenes Richt-Gebinde (package_size/label) plus min_stock_in_packages. Weil die Produkte einer Gruppe unterschiedlich große Packungen haben können (Pesto 99 g vs. 160 g), legt die Gruppe einen gemeinsamen Richtwert fest (1 Glas ≈ X g). - Neuer Helfer group_min_context() zentralisiert, in welcher Einheit der Gruppen-Mindestbestand zaehlt (Gebinde ODER verwaltete Einheit). - Einkaufsliste (gesamt + je Ort), Dashboard-Bedarf und GroupOut nutzen ihn; need.text kommt so als 'x Glaeser (y g)'. - update_group rechnet bestehende Werte beim Umschalten der Einheit um, damit der physische Bedarf gleich bleibt. - Migration: groups.package_size/package_label/min_stock_in_packages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
147 lines
5.5 KiB
Python
147 lines
5.5 KiB
Python
"""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 typing import NamedTuple
|
||
|
||
from sqlalchemy import func
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..models import BaseUnit, Group, 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
|
||
|
||
|
||
def article_unit(product: Product) -> tuple[float, str]:
|
||
"""Faktor und Bezeichnung der *Artikeleinheit* – das zählbare Gebinde.
|
||
|
||
Gibt es eine Packungsgröße, ist eine Einheit ein Gebinde (Glas, Packung …),
|
||
sonst die Anzeigeeinheit des Produkts. Nur diese Größe lässt sich über
|
||
verschiedene Artikel hinweg sinnvoll addieren: 3 Gläser + 2 Packungen sind
|
||
5 Einheiten, während 1000 g + 2 Stück keine sinnvolle Summe ergeben.
|
||
"""
|
||
if product.package_size and product.package_size > 0:
|
||
return product.package_size, (product.package_label or "Packung")
|
||
name, factor = display_unit_info(product)
|
||
return (factor or 1.0), name
|
||
|
||
|
||
def article_units(product: Product, quantity_base: float) -> float:
|
||
"""Rechnet eine Menge in Basiseinheiten in Artikeleinheiten um."""
|
||
factor, _ = article_unit(product)
|
||
return quantity_base / factor if factor else quantity_base
|
||
|
||
|
||
class GroupMinContext(NamedTuple):
|
||
divisor: float # Basiseinheiten je Mindestbestand-Einheit
|
||
label: str # Einzahl-Label ("Glas", "Gramm", …)
|
||
is_package: bool # zaehlbares Gebinde (aufrunden) vs. Anzeige-/Basiseinheit
|
||
base_unit: BaseUnit | None
|
||
matching: list[Product] # Produkte, die zur Gruppen-Einheit zaehlen
|
||
|
||
|
||
def group_min_context(group: Group) -> GroupMinContext:
|
||
"""In welcher Einheit der Mindestbestand einer Gruppe zu lesen ist.
|
||
|
||
Eine Gruppe zaehlt in ihrer verwalteten Einheit (``min_stock_unit``) oder – wenn
|
||
ein Gruppen-Gebinde definiert und aktiv ist – in ganzen Packungen. Die
|
||
Packungsgroesse ist ein **Richtwert der Gruppe** (nicht die der einzelnen
|
||
Produkte), weil deren Packungen unterschiedlich gross sein koennen.
|
||
"""
|
||
unit = group.min_stock_unit
|
||
if unit is not None:
|
||
base = BASE_OF_KIND[unit.kind]
|
||
matching = [p for p in group.products if p.base_unit == base]
|
||
base_unit: BaseUnit | None = base
|
||
else:
|
||
matching = list(group.products)
|
||
base_unit = None
|
||
if group.min_stock_in_packages and group.package_size and group.package_size > 0:
|
||
return GroupMinContext(
|
||
float(group.package_size), group.package_label or "Packung",
|
||
True, base_unit, matching,
|
||
)
|
||
if unit is not None:
|
||
return GroupMinContext(unit.factor or 1.0, unit.name, False, base_unit, matching)
|
||
return GroupMinContext(1.0, "", False, None, matching)
|