diff --git a/backend/app/crud.py b/backend/app/crud.py index 406f3a1..731d0a6 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -9,13 +9,60 @@ from fastapi import HTTPException, status from sqlalchemy import func from sqlalchemy.orm import Session -from .models import Barcode, Category, CategoryTracking, Item, Lot, Product, ProductImage +from .models import ( + Barcode, + BaseUnit, + Category, + CategoryTracking, + Item, + Lot, + Product, + ProductImage, +) from .schemas import BarcodeOut, LocationMinStockOut, ProductOut -from .services.conversion import KIND_OF_BASE, display_unit_info +from .services.conversion import ( + BASE_OF_KIND, + KIND_OF_BASE, + display_unit_info, + zweit_faktor, +) from .services.min_stock import UEBERALL_NAME, lies_ueberall from .services.stock import current_stock, location_subtree_stock_base +def _min_anzeige( + product: Product, min_base: float, disp_name: str, disp_factor: float +) -> tuple[float, str]: + """Mindestbestand (Basiseinheiten) in der ERFASSTEN Einheit + deren Name. + + Die Erfassungseinheit darf seit der Zweiteinheit artfremd sein („500 g" an + einem stueckweise gefuehrten Artikel). ``zweit_faktor`` ist 1,0, solange sie + zur eigenen Art gehoert – dieselbe Formel deckt also beide Faelle ab. + """ + if product.min_stock_in_packages and product.package_size: + return min_base / product.package_size, "Pkg" + einheit = product.min_stock_unit + if einheit is not None: + bruecke = zweit_faktor(product, BASE_OF_KIND[einheit.kind]) + if bruecke is not None: + return min_base * bruecke / einheit.factor, einheit.name + # Bruecke nachtraeglich entfernt: lieber die Artikeleinheit zeigen als + # eine Zahl in einer Einheit, die es fuer diesen Artikel nicht mehr gibt. + return min_base / disp_factor, disp_name + + +def _zweit_felder(out, product: Product) -> None: + """Abgeleitete Zweiteinheit-Felder setzen, damit die Oberflaechen nicht + selbst dividieren muessen.""" + if not product.secondary_base: + return + try: + ziel = BaseUnit(product.secondary_base) + except ValueError: + return # unbekannter Altwert – dann eben keine Bruecke + out.secondary_factor = zweit_faktor(product, ziel) + + def product_tracking(db: Session, product: Product) -> str: """Verwaltungsart eines Artikels: aus seiner Kategorie abgeleitet. @@ -74,15 +121,10 @@ def product_to_out(db: Session, product: Product) -> ProductOut: # lesen es unveraendert weiter, es kommt nur aus einer anderen Quelle. out.min_stock = lies_ueberall(product.location_min_stocks) if out.min_stock is not None: - if product.min_stock_in_packages and product.package_size: - out.min_stock_display = out.min_stock / product.package_size - out.min_stock_unit_label = "Pkg" - elif product.min_stock_unit is not None: - out.min_stock_display = out.min_stock / product.min_stock_unit.factor - out.min_stock_unit_label = product.min_stock_unit.name - else: - out.min_stock_display = out.min_stock / factor - out.min_stock_unit_label = name + out.min_stock_display, out.min_stock_unit_label = _min_anzeige( + product, out.min_stock, name, factor + ) + _zweit_felder(out, product) out.location_min_stocks = [ LocationMinStockOut( @@ -166,15 +208,10 @@ def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOu o.unit_factor = factor o.min_stock = lies_ueberall(product.location_min_stocks) if o.min_stock is not None: - if product.min_stock_in_packages and product.package_size: - o.min_stock_display = o.min_stock / product.package_size - o.min_stock_unit_label = "Pkg" - elif product.min_stock_unit is not None: - o.min_stock_display = o.min_stock / product.min_stock_unit.factor - o.min_stock_unit_label = product.min_stock_unit.name - else: - o.min_stock_display = o.min_stock / factor - o.min_stock_unit_label = name + o.min_stock_display, o.min_stock_unit_label = _min_anzeige( + product, o.min_stock, name, factor + ) + _zweit_felder(o, product) o.location_min_stocks = [ LocationMinStockOut( location_id=e.location_id, diff --git a/backend/app/main.py b/backend/app/main.py index 82ed310..7aa64ac 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -204,6 +204,11 @@ def _ensure_schema() -> None: "ON product_location_min_stock (product_id) WHERE location_id IS NULL", "CREATE UNIQUE INDEX IF NOT EXISTS uq_group_ueberall_min " "ON group_location_min_stock (group_id) WHERE location_id IS NULL", + # Zweiteinheit am Artikel („3 Stück ≙ 250 g") – Brücke zwischen den + # Einheiten-Arten. Leer = wie bisher, strikt getrennt. + "ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_base VARCHAR(16)", + "ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_count DOUBLE PRECISION", + "ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_amount DOUBLE PRECISION", ] with engine.begin() as conn: # Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent), diff --git a/backend/app/models.py b/backend/app/models.py index b29ed23..247cf4b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -272,6 +272,18 @@ class Product(Base): package_size: Mapped[float | None] = mapped_column(Float, nullable=True) # Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", … package_label: Mapped[str | None] = mapped_column(String(32), nullable=True) + # Zweiteinheit: Bruecke zwischen den Einheiten-ARTEN fuer DIESEN Artikel. + # „3 Stueck ≙ 250 g" – gespeichert als das eingegebene PAAR, damit beim + # naechsten Oeffnen genau das wieder dasteht und nicht „83,333 g je Stueck". + # ``secondary_count`` zaehlt in der Basiseinheit des Artikels, + # ``secondary_amount`` in ``secondary_base``. NULL = keine Bruecke, dann + # bleibt es bei der strikten Trennung der Arten (services/conversion.py). + # + # String statt Enum wie bei ``Category.tracking``: ein natives Postgres-Enum + # in der handgeschriebenen Migration nachzuziehen waere unnoetig heikel. + secondary_base: Mapped[str | None] = mapped_column(String(16), nullable=True) + secondary_count: Mapped[float | None] = mapped_column(Float, nullable=True) + secondary_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # Welche MHD-Genauigkeit bei diesem Produkt sinnvoll ist. Steuert nur die # Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt). date_precision: Mapped[str] = mapped_column( diff --git a/backend/app/off.py b/backend/app/off.py index de0b9bb..1998aa6 100644 --- a/backend/app/off.py +++ b/backend/app/off.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import re +from typing import NamedTuple import httpx @@ -24,22 +25,67 @@ _UNIT_TO_BASE: dict[str, tuple[str, float]] = { _QUANTITY_RE = re.compile(r"([\d]+(?:[.,]\d+)?)\s*(kg|mg|g|dl|cl|ml|l)\b", re.IGNORECASE) +#: „3 x 80 g", „6 × 1,5 l", „4 Stück à 125 g" – der Multiplikator steht vorn, +#: dazwischen darf ein Zählwort stehen. Bewusst eng gefasst: ein zu weites +#: Muster macht aus „12 Eier à 53 g" schnell Unsinn, und eine nicht erkannte +#: Stückzahl ist besser als eine falsch geratene. +_MULTI_RE = re.compile( + r"(?P\d+)\s*(?:st(?:ü|ue)ck|stk\.?|pcs?|st\.?)?\s*" + r"(?:[x×*]|à|a)\s*" + r"(?P\d+(?:[.,]\d+)?)\s*(?Pkg|mg|g|dl|cl|ml|l)\b", + re.IGNORECASE, +) + + +class OffMenge(NamedTuple): + """Was aus dem OFF-Feld ``quantity`` herauszulesen ist.""" + base_unit: str # "gram" | "milliliter" | "piece" + package_size: float | None # Gesamtfuellmenge des Gebindes, in Basiseinheiten + stueck: float | None # zaehlbare Teile im Gebinde (nur bei „3 x 80 g") + einzelmenge: float | None # Fuellmenge EINES Teils (die 80 g) + + +def parse_gebinde(quantity: str | None) -> OffMenge: + """Gesamtmenge UND einen etwaigen Multiplikator aus ``quantity`` lesen. + + „3 x 80 g" ist nicht dasselbe wie „240 g": die Packung enthaelt drei + zaehlbare Riegel. Dieser Multiplikator ging bisher verloren, weil das alte + Muster den ERSTEN Zahl-Einheit-Treffer nahm – also die 80. + """ + if not quantity: + return OffMenge("piece", None, None, None) + + multi = _MULTI_RE.search(quantity) + if multi is not None: + anzahl = float(multi.group("anzahl")) + menge = float(multi.group("menge").replace(",", ".")) + base_unit, factor = _UNIT_TO_BASE[multi.group("einheit").lower()] + einzel = round(menge * factor, 3) + gesamt = round(anzahl * einzel, 3) + if anzahl > 0 and gesamt > 0: + return OffMenge(base_unit, gesamt, anzahl, einzel) + return OffMenge("piece", None, None, None) + + einfach = _QUANTITY_RE.search(quantity) + if einfach is None: + return OffMenge("piece", None, None, None) + amount = float(einfach.group(1).replace(",", ".")) + base_unit, factor = _UNIT_TO_BASE[einfach.group(2).lower()] + gesamt = round(amount * factor, 3) + return OffMenge(base_unit, gesamt if gesamt > 0 else None, None, None) + def parse_quantity(quantity: str | None) -> tuple[str, float | None]: """Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'. Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000), '1,5 l' -> ('milliliter', 1500), '6 Stück' -> ('piece', None). + + Der schmale Vertrag von frueher; die volle Auskunft (mit Multiplikator) + liefert ``parse_gebinde``. """ - if not quantity: - return "piece", None - match = _QUANTITY_RE.search(quantity) - if not match: - return "piece", None - amount = float(match.group(1).replace(",", ".")) - base_unit, factor = _UNIT_TO_BASE[match.group(2).lower()] - package_size = round(amount * factor, 3) - return base_unit, (package_size if package_size > 0 else None) + menge = parse_gebinde(quantity) + return menge.base_unit, menge.package_size def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None: @@ -85,16 +131,24 @@ def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None: categories = product.get("categories") or "" category_tags = product.get("categories_tags") or [] - base_unit, package_size = parse_quantity(product.get("quantity")) + menge = parse_gebinde(product.get("quantity")) return { "barcode": barcode, "name": name, "brand": (product.get("brands") or "").strip() or None, "image_url": product.get("image_front_url") or product.get("image_url") or None, - "base_unit": base_unit, - "package_size": package_size, + "base_unit": menge.base_unit, + "package_size": menge.package_size, "quantity_text": product.get("quantity"), + # „3 x 80 g": die Packung enthaelt drei zaehlbare Teile. Als VORSCHLAG + # fuer die Zweiteinheit mitgegeben („240 g ≙ 3 Stueck"), nicht gesetzt – + # bestaetigen soll es der Nutzer, OFF-Angaben sind nicht immer sauber. + "secondary_base": "piece" if menge.stueck else None, + "secondary_count": menge.package_size if menge.stueck else None, + "secondary_amount": menge.stueck, + # Nur fuer den Hinweistext „3 × 80 g". + "unit_amount": menge.einzelmenge, "category_suggestion": categories.split(",")[0].strip() if categories else None, "category_tags": category_tags, "source": source, diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index 3802b96..652a7e6 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -18,7 +18,10 @@ from ..schemas import ( from ..services import gruppen as gruppen_graph from ..services.conversion import group_min_context from ..services.min_stock import UEBERALL_NAME, lies_ueberall, schreibe_ueberall -from ..services.stock import summe_bestand_base, summe_bestand_im_subtree_base +from ..services.stock import ( + summe_bestand_gewichtet, + summe_bestand_im_subtree_gewichtet, +) router = APIRouter(prefix="/groups", tags=["groups"]) @@ -104,7 +107,8 @@ def _group_to_out(db: Session, group: Group) -> GroupOut: # Bestand in derselben Einheit, in der auch der Mindestbestand erfasst ist # (Gruppen-Gebinde ODER verwaltete Einheit) – so ist beides vergleichbar. ctx = group_min_context(group) - stock_base = summe_bestand_base(db, ctx.matching) + # Gewichtet: artfremde Artikel zaehlen ueber ihre Zweiteinheit mit. + stock_base = summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren) out.stock = round(stock_base / ctx.divisor, 3) out.min_stock = None if ueberall is None else round(ueberall / ctx.divisor, 3) unit = group.min_stock_unit @@ -122,7 +126,7 @@ def _group_to_out(db: Session, group: Group) -> GroupOut: total = ( stock_base if loc_id is None - else summe_bestand_im_subtree_base(db, ctx.matching, loc_id) + else summe_bestand_im_subtree_gewichtet(db, ctx.matching, loc_id, ctx.faktoren) ) return round(total, 3) diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 1790700..d16a03f 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -434,6 +434,28 @@ def _store_product_image_bg(product_id: int, url: str) -> None: db.close() +def _pruefe_zweiteinheit(base_unit, basis, anzahl, menge) -> None: + """Die Zweiteinheit muss vollstaendig und auf eine ANDERE Art zeigen. + + Eine halbe Bruecke waere schlimmer als keine: sie saehe im Formular richtig + aus, wuerde aber nirgends greifen (``zweit_faktor`` liefert dann None). + """ + gesetzt = [x for x in (basis, anzahl, menge) if x is not None] + if not gesetzt: + return + if len(gesetzt) != 3: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Zweiteinheit unvollständig: Art, Anzahl und Menge gehören zusammen.", + ) + if basis == base_unit: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Die Zweiteinheit muss eine ANDERE Art sein als die Einheit des " + "Artikels – sonst gibt es nichts umzurechnen.", + ) + + @router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED) def create_product( payload: ProductCreate, @@ -456,6 +478,9 @@ def create_product( raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc if payload.shop_id is not None and db.get(Shop, payload.shop_id) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden") + _pruefe_zweiteinheit( + base_unit, payload.secondary_base, payload.secondary_count, payload.secondary_amount + ) product = Product( barcode=payload.barcode or None, name=payload.name, @@ -465,6 +490,9 @@ def create_product( display_unit_id=display_unit_id, package_size=payload.package_size, package_label=payload.package_label or None, + secondary_base=payload.secondary_base.value if payload.secondary_base else None, + secondary_count=payload.secondary_count, + secondary_amount=payload.secondary_amount, date_precision=payload.date_precision.value, group_id=payload.group_id, category_id=payload.category_id, @@ -537,6 +565,22 @@ def update_product( # aeltere App-Versionen und das Web-Formular unveraendert weiterlaufen. ueberall_gesetzt = "min_stock" in data ueberall_wert = data.pop("min_stock", None) + # Zweiteinheit: Enum -> String, und nur vollstaendig oder gar nicht. + if "secondary_base" in data: + _pruefe_zweiteinheit( + data.get("base_unit") or product.base_unit, + data["secondary_base"], + data.get("secondary_count", product.secondary_count), + data.get("secondary_amount", product.secondary_amount), + ) + data["secondary_base"] = ( + data["secondary_base"].value if data["secondary_base"] else None + ) + # Bruecke entfernt: die beiden Zahlen muessen mit, sonst bliebe eine + # halbe Angabe stehen, die nirgends greift. + if data["secondary_base"] is None: + data["secondary_count"] = None + data["secondary_amount"] = None if data.get("min_stock_in_packages") is None: data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL if data.get("date_precision") is None: diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index 8bd83b3..3c0ab37 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -239,6 +239,10 @@ def export_backup_json( "category": _category_path(db, p.category), "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), + # Zweiteinheit-Brücke („3 Stück ≙ 250 g"), in Basiseinheiten. + "secondary_base": p.secondary_base, + "secondary_count": p.secondary_count, + "secondary_amount": p.secondary_amount, "min_stocks": _min_stock_liste(p, loc_name), # Gegenstands-Felder: "shop": p.shop.name if p.shop else None, @@ -835,6 +839,12 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict: # Mindestbestände je Ort (v3). Ältere Sicherungen haben nur den # Gesamtwert, der oben schon über „mindestbestand" gesetzt wurde. _min_stocks_einspielen(db, product, entry.get("min_stocks")) + # Zweiteinheit nur nachtragen, wenn der Artikel noch keine hat – + # der Import ueberschreibt grundsaetzlich nichts Vorhandenes. + if not product.secondary_base and entry.get("secondary_base"): + product.secondary_base = entry["secondary_base"] + product.secondary_count = entry.get("secondary_count") + product.secondary_amount = entry.get("secondary_amount") # Gegenstands-Zusatzfelder – nur ergänzend, nichts überschreiben. shop_name = (entry.get("shop") or "").strip() if shop_name and product.shop_id is None: diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index 855b375..34089bf 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -46,8 +46,8 @@ from ..services.stock import ( current_stock, descendant_location_ids, location_subtree_stock_base, - summe_bestand_base, - summe_bestand_im_subtree_base, + summe_bestand_gewichtet, + summe_bestand_im_subtree_gewichtet, ) from .settings import get_expiry_warning_days @@ -227,7 +227,17 @@ def _gruppen_bedarfe(db: Session, ort_desc: dict[str, set[str]]) -> tuple[dict, gid: { h.id for h in gruppen_graph.teilgraph(g) - if h.id != gid and h.id in artikel and artikel[h.id] <= artikel[gid] + if h.id != gid + and h.id in artikel + # Nur zwischen Gruppen verrechnen, die in DERSELBEN Basiseinheit + # zaehlen. Frueher folgte das nebenbei aus der Teilmengen-Bedingung: + # Artikel verschiedener Arten waren zwangslaeufig disjunkt. Seit die + # Zweiteinheit einen Stueck-Artikel auch in einer Gramm-Gruppe + # mitzaehlen laesst, gilt das nicht mehr – und ein Bedarf in Stueck + # duerfte keinen Bedarf in Gramm decken (siehe _netted_topups: + # „Alle Mengen muessen in DERSELBEN Einheit vorliegen"). + and ctxs[h.id].base_unit == ctxs[gid].base_unit + and artikel[h.id] <= artikel[gid] } for gid, g in gruppen.items() } @@ -241,9 +251,10 @@ def _gruppen_bedarfe(db: Session, ort_desc: dict[str, set[str]]) -> tuple[dict, schluessel = (e.group_id, e.location_id) minima[schluessel] = e.min_stock bestand[schluessel] = ( - summe_bestand_base(db, ctx.matching) + summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren) if e.location_id is None - else summe_bestand_im_subtree_base(db, ctx.matching, e.location_id) + else summe_bestand_im_subtree_gewichtet( + db, ctx.matching, e.location_id, ctx.faktoren) ) alle_orte = {loc for (_, loc) in minima if loc is not None} diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a3d1fa0..29d9467 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -315,6 +315,12 @@ class ProductBase(BaseModel): package_size: float | None = Field(default=None, gt=0) # Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …) package_label: str | None = Field(default=None, max_length=32) + # Zweiteinheit: Bruecke zwischen den Einheiten-Arten („3 Stück ≙ 250 g"). + # ``secondary_count`` zaehlt in der Basiseinheit des Artikels, + # ``secondary_amount`` in ``secondary_base``. Alle drei oder keines. + secondary_base: BaseUnit | None = None + secondary_count: float | None = Field(default=None, gt=0) + secondary_amount: float | None = Field(default=None, gt=0) # Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat). date_precision: DatePrecision = DatePrecision.day group_id: int | None = None @@ -348,6 +354,12 @@ class ProductUpdate(BaseModel): unit_id: int | None = None package_size: float | None = Field(default=None, gt=0) package_label: str | None = Field(default=None, max_length=32) + # Zweiteinheit: Bruecke zwischen den Einheiten-Arten („3 Stück ≙ 250 g"). + # ``secondary_count`` zaehlt in der Basiseinheit des Artikels, + # ``secondary_amount`` in ``secondary_base``. Alle drei oder keines. + secondary_base: BaseUnit | None = None + secondary_count: float | None = Field(default=None, gt=0) + secondary_amount: float | None = Field(default=None, gt=0) date_precision: DatePrecision | None = None group_id: int | None = None category_id: int | None = None @@ -372,6 +384,13 @@ class ProductOut(BaseModel): display_unit_id: int | None = None package_size: float | None package_label: str | None = None + # Zweiteinheit-Brücke, so wie eingegeben („3 Stück ≙ 250 g"). + secondary_base: BaseUnit | None = None + secondary_count: float | None = None + secondary_amount: float | None = None + # Abgeleitet: Zweit-Basiseinheiten je EINER Basiseinheit des Artikels – + # damit die Oberflächen nicht selbst dividieren müssen. + secondary_factor: float | None = None date_precision: DatePrecision = DatePrecision.day group_id: int | None category_id: int | None = None diff --git a/backend/app/services/conversion.py b/backend/app/services/conversion.py index 9a32f03..6f8ec8e 100644 --- a/backend/app/services/conversion.py +++ b/backend/app/services/conversion.py @@ -54,6 +54,50 @@ def find_unit(db: Session, token: str) -> Unit | None: return db.query(Unit).filter(func.lower(Unit.name) == t).first() +def zweit_faktor(product: Product, ziel: BaseUnit) -> float | None: + """Wie viele ``ziel``-Basiseinheiten EINE Basiseinheit des Artikels wert ist. + + Die Zweiteinheit am Artikel („3 Stück ≙ 250 g") ist die einzige Brücke + zwischen den sonst strikt getrennten Arten. Sie gilt in BEIDE Richtungen: + derselbe Eintrag macht aus einem Stück-Artikel 83,333 g je Stück und aus + einem Gramm-Artikel 0,012 Stück je Gramm. + + Gleiche Art → 1.0. Ohne (oder mit unbrauchbarer) Brücke → ``None``; dann + bleibt es bei der strikten Trennung, und der Aufrufer entscheidet, was das + heisst – ablehnen beim Einlagern, aussortieren beim Gruppenbestand. + """ + if product.base_unit == ziel: + return 1.0 + if product.secondary_base != ziel.value: + return None # keine Brücke in DIESE Art + anzahl = product.secondary_count # in der Basiseinheit des Artikels + menge = product.secondary_amount # in secondary_base + if not anzahl or not menge or anzahl <= 0 or menge <= 0: + return None + # „3 Stück ≙ 250 g" -> 1 Stück = 250/3 g. Bei einem Gramm-Artikel steht + # dieselbe Zeile als „250 g ≙ 3 Stück" da und liefert 3/250 Stück je Gramm. + return menge / anzahl + + +def in_artikel_basis(product: Product, menge: float, quell_base: BaseUnit) -> float | None: + """Gegenrichtung: eine Menge in ``quell_base`` in die Artikel-Basiseinheit. + + Bewusst erst multiplizieren, dann teilen. ``250 * 3 / 250`` ist exakt 3; + der Umweg ueber den Faktor (``250 / (250/3)``) ergibt 3,0000000000000004 – + und daran scheitert sonst „den ganzen Bestand auslagern" an der Pruefung in + ``check_out``. + """ + if quell_base == product.base_unit: + return menge + if product.secondary_base != quell_base.value: + return None + anzahl = product.secondary_count + zweit = product.secondary_amount + if not anzahl or not zweit or anzahl <= 0 or zweit <= 0: + return None + return menge * anzahl / zweit + + 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: @@ -71,10 +115,18 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> 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]})." - ) + # Artfremde Einheit: geht nur über die Zweiteinheit des Artikels. + # „250 g" bei einem in Stück geführten Artikel wird zu 3 Stück. + # Bewusst OHNE Runden – krumme Mengen sind erlaubt, sonst driftete + # der gebuchte Bestand von der tatsächlichen Entnahme weg. + ergebnis = in_artikel_basis(product, quantity * unit.factor, BASE_OF_KIND[unit.kind]) + if ergebnis is None: + raise ConversionError( + f"Einheit '{unit.name}' passt nicht zur Art des Produkts " + f"({BASE_LABEL[product.base_unit]}). Mit einer Zweiteinheit am " + f"Artikel (z.B. „3 Stück ≙ 250 g\") ginge es." + ) + return ergebnis return quantity * unit.factor @@ -119,6 +171,10 @@ class GroupMinContext(NamedTuple): is_package: bool # zaehlbares Gebinde (aufrunden) vs. Anzeige-/Basiseinheit base_unit: BaseUnit | None matching: list[Product] # Produkte, die zur Gruppen-Einheit zaehlen + # Artikel-ID -> Gruppen-Basiseinheiten je EINER Basiseinheit des Artikels. + # Fuer gleichartige Artikel 1,0; fuer artfremde der Zweiteinheit-Faktor. + # Bestandssummen muessen damit GEWICHTET werden (services/stock.py). + faktoren: dict[int, float] def group_min_context(group: Group) -> GroupMinContext: @@ -132,23 +188,43 @@ def group_min_context(group: Group) -> GroupMinContext: ``matching`` umfasst die Artikel der Gruppe UND ihrer Untergruppen (transitiv): „Wurst" zaehlt Wurst, Grillwurst, Salami und alles darunter. Die Einheiten-Filterung der OBERgruppe gilt dabei fuer den ganzen - Teilgraphen – zaehlt „Wurst" in Kilogramm, bleiben stueckweise gefuehrte - Artikel einer Untergruppe aussen vor, genau wie ein direkt zugeordneter. + Teilgraphen. + + Artfremde Artikel zaehlen mit, WENN sie eine Zweiteinheit in die Art der + Gruppe tragen: ein in Stueck gefuehrter Artikel mit „3 Stueck ≙ 250 g" + zaehlt in einer Kilogramm-Gruppe mit 83,333 g je Stueck. Ohne Bruecke bleibt + er wie bisher aussen vor. Die Umrechnung steht in ``faktoren``. """ alle = gruppen.produkte(group) # inkl. Untergruppen, ohne Dubletten unit = group.min_stock_unit if unit is not None: base = BASE_OF_KIND[unit.kind] - matching = [p for p in alle if p.base_unit == base] + matching = [] + faktoren: dict[int, float] = {} + for p in alle: + f = zweit_faktor(p, base) + if f is not None: + matching.append(p) + faktoren[p.id] = f base_unit: BaseUnit | None = base else: + # Ohne verwaltete Einheit zaehlt die Gruppe roh weiter – dann werden + # Gramm und Stueck weiterhin ungefiltert addiert (unveraendert). matching = alle + faktoren = {p.id: 1.0 for p in alle} 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, + divisor=float(group.package_size), + label=group.package_label or "Packung", + is_package=True, base_unit=base_unit, matching=matching, faktoren=faktoren, ) 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) + return GroupMinContext( + divisor=unit.factor or 1.0, label=unit.name, is_package=False, + base_unit=base_unit, matching=matching, faktoren=faktoren, + ) + return GroupMinContext( + divisor=1.0, label="", is_package=False, + base_unit=None, matching=matching, faktoren=faktoren, + ) diff --git a/backend/app/services/stock.py b/backend/app/services/stock.py index 6e9ba0b..38c2bde 100644 --- a/backend/app/services/stock.py +++ b/backend/app/services/stock.py @@ -216,63 +216,88 @@ def current_stock(db: Session, product_id: int) -> float: return float(sum(q for (q,) in total)) -def summe_bestand_base(db: Session, produkte: Sequence[Product]) -> float: - """Gesamtbestand mehrerer Artikel (Basiseinheiten) in EINER Abfrage. +def _summe_je_artikel( + db: Session, produkte: Sequence[Product], orte: set[str] | None +) -> dict[int, float]: + """Bestand JE ARTIKEL (Basiseinheiten) in zwei Abfragen. - Ersetzt ``sum(current_stock(db, p.id) for p in …)``: das setzte je Artikel - zwei Abfragen ab. Seit eine Gruppe die Artikel ihres ganzen Untergruppen- - Graphen zaehlt, sind das schnell hunderte. + ``orte`` = None zaehlt alles (auch Chargen ohne Lagerort), sonst nur die + genannten Orte. Gruppiert wird nach Artikel, weil verschiedene Artikel + unterschiedliche Umrechnungsfaktoren haben koennen (Zweiteinheit). """ if not produkte: - return 0.0 + return {} # Gleiche Fallunterscheidung wie current_stock: Einzelstuecke zaehlen ihre # Items, alle anderen summieren die Lot-Mengen. einzel = [p.id for p in produkte if p.individual] lots = [p.id for p in produkte if not p.individual] - total = 0.0 + je_artikel: dict[int, float] = {} if lots: - total += float( - db.query(func.coalesce(func.sum(Lot.quantity), 0.0)) + abfrage = ( + db.query(Lot.product_id, func.coalesce(func.sum(Lot.quantity), 0.0)) .filter(Lot.product_id.in_(lots)) - .scalar() - or 0.0 ) + if orte is not None: + abfrage = abfrage.filter(Lot.location_id.in_(orte)) + for pid, menge in abfrage.group_by(Lot.product_id).all(): + je_artikel[pid] = je_artikel.get(pid, 0.0) + float(menge or 0.0) if einzel: - total += float( - db.query(func.count(Item.id)).filter(Item.product_id.in_(einzel)).scalar() or 0 + abfrage = ( + db.query(Item.product_id, func.count(Item.id)) + .filter(Item.product_id.in_(einzel)) ) - return total + if orte is not None: + abfrage = abfrage.filter(Item.location_id.in_(orte)) + for pid, anzahl in abfrage.group_by(Item.product_id).all(): + je_artikel[pid] = je_artikel.get(pid, 0.0) + float(anzahl or 0) + return je_artikel + + +def summe_bestand_gewichtet( + db: Session, + produkte: Sequence[Product], + faktoren: dict[int, float] | None = None, +) -> float: + """Gesamtbestand mehrerer Artikel, je Artikel mit eigenem Faktor. + + Der Faktor kommt aus der Zweiteinheit (``GroupMinContext.faktoren``): eine + Gruppe in Kilogramm rechnet einen in Stueck gefuehrten Artikel ueber + 83,333 g je Stueck mit. Ohne Faktoren-Tabelle wird ungewichtet addiert. + """ + je_artikel = _summe_je_artikel(db, produkte, None) + if faktoren is None: + return float(sum(je_artikel.values())) + return float(sum(m * faktoren.get(pid, 1.0) for pid, m in je_artikel.items())) + + +def summe_bestand_base(db: Session, produkte: Sequence[Product]) -> float: + """Gesamtbestand mehrerer Artikel (Basiseinheiten), ungewichtet.""" + return summe_bestand_gewichtet(db, produkte, None) + + +def summe_bestand_im_subtree_gewichtet( + db: Session, + produkte: Sequence[Product], + location_id: str, + faktoren: dict[int, float] | None = None, +) -> float: + """Wie ``summe_bestand_gewichtet``, aber nur an einem Ort inkl. Unterorten. + + Chargen ohne Lagerort liegen in keinem Subtree und zaehlen hier bewusst + nicht mit – im Gesamtbestand („Ueberall") dagegen schon. + """ + ids = {location_id} | descendant_location_ids(db, location_id) + je_artikel = _summe_je_artikel(db, produkte, ids) + if faktoren is None: + return float(sum(je_artikel.values())) + return float(sum(m * faktoren.get(pid, 1.0) for pid, m in je_artikel.items())) def summe_bestand_im_subtree_base( db: Session, produkte: Sequence[Product], location_id: str ) -> float: - """Wie ``summe_bestand_base``, aber nur an einem Lagerort inkl. Unterorten. - - Chargen ohne Lagerort liegen in keinem Subtree und zaehlen hier bewusst - nicht mit – im Gesamtbestand („Ueberall") dagegen schon. - """ - if not produkte: - return 0.0 - ids = {location_id} | descendant_location_ids(db, location_id) - einzel = [p.id for p in produkte if p.individual] - lots = [p.id for p in produkte if not p.individual] - total = 0.0 - if lots: - total += float( - db.query(func.coalesce(func.sum(Lot.quantity), 0.0)) - .filter(Lot.product_id.in_(lots), Lot.location_id.in_(ids)) - .scalar() - or 0.0 - ) - if einzel: - total += float( - db.query(func.count(Item.id)) - .filter(Item.product_id.in_(einzel), Item.location_id.in_(ids)) - .scalar() - or 0 - ) - return total + """Ungewichtete Variante von ``summe_bestand_im_subtree_gewichtet``.""" + return summe_bestand_im_subtree_gewichtet(db, produkte, location_id, None) def location_stock_base(db: Session, product: Product, location_id: str) -> float: diff --git a/backend/tests/test_backup_gruppen.py b/backend/tests/test_backup_gruppen.py index a086c19..241fc75 100644 --- a/backend/tests/test_backup_gruppen.py +++ b/backend/tests/test_backup_gruppen.py @@ -143,3 +143,17 @@ def test_ring_aus_der_sicherung_wird_abgewiesen(db, user): # Eine der beiden Kanten greift, die andere wird als Ring abgewiesen. assert not (a.parents and b.parents) assert any("Ring" in f for f in ergebnis.get("errors", [])) + + +def test_sicherung_nimmt_zweiteinheit_mit(db, user): + """Ohne das ginge die Brücke beim Wiederherstellen stillschweigend verloren.""" + p = Product(name="Bratwurst", base_unit=BaseUnit.piece, + secondary_base="gram", secondary_count=3, secondary_amount=250) + db.add(p) + db.commit() + + daten = json.loads(_sicherung(db, user)) + artikel = daten["products"][0] + assert artikel["secondary_base"] == "gram" + assert artikel["secondary_count"] == 3 + assert artikel["secondary_amount"] == 250 diff --git a/backend/tests/test_off.py b/backend/tests/test_off.py index a831af0..c45bcac 100644 --- a/backend/tests/test_off.py +++ b/backend/tests/test_off.py @@ -1,4 +1,4 @@ -from app.off import parse_quantity +from app.off import parse_gebinde, parse_quantity def test_grams(): @@ -22,3 +22,31 @@ def test_unparsable_is_piece(): assert parse_quantity(None) == ("piece", None) assert parse_quantity("6 Stück") == ("piece", None) assert parse_quantity("") == ("piece", None) + + +def test_multiplikator_wird_erkannt(): + """„3 x 80 g" ist nicht dasselbe wie „240 g": drei zaehlbare Teile.""" + assert parse_gebinde("3 x 80 g") == ("gram", 240.0, 3.0, 80.0) + + +def test_multiplikator_liter(): + assert parse_gebinde("6 × 1,5 l") == ("milliliter", 9000.0, 6.0, 1500.0) + + +def test_multiplikator_mit_a_akzent(): + assert parse_gebinde("4 Stück à 125 g") == ("gram", 500.0, 4.0, 125.0) + + +def test_ohne_multiplikator_unveraendert(): + assert parse_gebinde("500 g") == ("gram", 500.0, None, None) + assert parse_quantity("500 g") == ("gram", 500) + + +def test_zaehlwort_ohne_muster_bleibt_grenze(): + """Bekannte Grenze: „12 Eier à 53 g" faellt auf die Einzelmenge zurueck. + + Das Muster kennt nur eine kurze Liste von Zaehlwoertern. Ein generisches + Wort wuerde es zu weit oeffnen – lieber eine nicht erkannte Stueckzahl als + eine falsch geratene. + """ + assert parse_gebinde("12 Eier à 53 g").stueck is None diff --git a/backend/tests/test_zweiteinheit.py b/backend/tests/test_zweiteinheit.py new file mode 100644 index 0000000..ceca7e6 --- /dev/null +++ b/backend/tests/test_zweiteinheit.py @@ -0,0 +1,244 @@ +"""Zweiteinheit am Artikel: die Brücke zwischen den Einheiten-Arten. + +Der Artikel bleibt in seiner Basiseinheit geführt (Stück) und trägt zusätzlich +eine Äquivalenz zur anderen Art („3 Stück ≙ 250 g"). Erst dadurch zählt er in +einer Gramm-Gruppe mit und lässt sich in Gramm ein- und auslagern. Das Gebinde +bleibt davon unberührt. + +Ohne Brücke bleibt alles beim Alten – das halten +``test_conversion.py::test_wrong_kind_rejected`` und +``test_gruppen_hierarchie.py::test_einheitenfilter_gilt_auch_fuer_untergruppen`` +fest, die beide unverändert grün bleiben. +""" + +import pytest + +from app.models import BaseUnit, Group, Location, Lot, Product, Role, Unit, User +from app.routers.groups import _group_to_out +from app.services.conversion import ( + ConversionError, + group_min_context, + in_artikel_basis, + to_base, + zweit_faktor, +) +from app.services.stock import ( + check_out, + current_stock, + summe_bestand_gewichtet, + summe_bestand_im_subtree_gewichtet, +) + + +@pytest.fixture() +def user(db): + person = User(username="tester", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +def _gramm(db) -> Unit: + return db.query(Unit).filter(Unit.name == "Gramm").one() + + +def _kilo(db) -> Unit: + return db.query(Unit).filter(Unit.name == "Kilogramm").one() + + +def _riegel(db, *, bruecke: bool = True, bestand: float = 0.0, ort=None) -> Product: + """Ein in STÜCK geführter Artikel, wahlweise mit „3 Stück ≙ 250 g".""" + p = Product( + name="Bratwurst", + base_unit=BaseUnit.piece, + secondary_base="gram" if bruecke else None, + secondary_count=3 if bruecke else None, + secondary_amount=250 if bruecke else None, + ) + db.add(p) + db.flush() + if bestand: + db.add(Lot(product_id=p.id, quantity=bestand, + location_id=ort.id if ort is not None else None)) + db.commit() + return p + + +# ---- Die Brücke selbst ---- + +def test_bruecke_beide_richtungen(db): + p = _riegel(db) + assert zweit_faktor(p, BaseUnit.gram) == pytest.approx(250 / 3) + assert zweit_faktor(p, BaseUnit.piece) == 1.0 + # In eine DRITTE Art gibt es keine Antwort. + assert zweit_faktor(p, BaseUnit.milliliter) is None + # Gegenrichtung: 250 g sind exakt 3 Stück. + assert in_artikel_basis(p, 250, BaseUnit.gram) == pytest.approx(3) + + +def test_ohne_bruecke_kein_faktor(db): + p = _riegel(db, bruecke=False) + assert zweit_faktor(p, BaseUnit.gram) is None + assert in_artikel_basis(p, 250, BaseUnit.gram) is None + + +def test_unvollstaendige_bruecke_zaehlt_nicht(db): + """Halbe oder widersprüchliche Angaben sind keine Brücke – und kein Absturz.""" + p = _riegel(db) + p.secondary_count = 0 + db.commit() + assert zweit_faktor(p, BaseUnit.gram) is None # keine Division durch Null + + p.secondary_count = 3 + p.secondary_amount = None + db.commit() + assert zweit_faktor(p, BaseUnit.gram) is None + + # Brücke auf die EIGENE Art bringt nichts zu übersetzen. + p.secondary_base = "piece" + p.secondary_amount = 5 + db.commit() + assert zweit_faktor(p, BaseUnit.gram) is None + + +# ---- Ein- und Auslagern ---- + +def test_einlagern_in_der_fremdeinheit(db): + p = _riegel(db) + assert to_base(db, p, 250, "Gramm") == pytest.approx(3) + assert to_base(db, p, 0.25, "kg") == pytest.approx(3) + # Die eigene Art bleibt unberührt. + assert to_base(db, p, 2, "Stück") == pytest.approx(2) + + +def test_ohne_bruecke_bleibt_gesperrt(db): + p = _riegel(db, bruecke=False) + with pytest.raises(ConversionError): + to_base(db, p, 250, "Gramm") + + +def test_krumme_menge_wird_gebucht_nicht_gerundet(db): + """100 g bei 83,33 g je Stück sind 1,2 Stück – genau das wird gebucht.""" + p = _riegel(db) + assert to_base(db, p, 100, "Gramm") == pytest.approx(1.2) + + +def test_auslagern_trifft_den_bestand_genau(db, user): + """Der ganze Bestand muss sich in der Fremdeinheit auslagern lassen. + + Sichert das gespeicherte PAAR gegen einen gerundeten Faktor ab: der Umweg + ueber 250/3 ergaebe 3,0000000000000004 und liefe gegen die Bestandspruefung. + """ + p = _riegel(db, bestand=3) + check_out(db, p, 250, "Gramm", user) # darf nicht an „zu wenig Bestand" scheitern + db.commit() + assert current_stock(db, p.id) == pytest.approx(0) + + +# ---- Gruppen ---- + +def _wurst_gruppe(db) -> Group: + g = Group(name="Wurst", min_stock_unit=_kilo(db)) + db.add(g) + db.commit() + db.refresh(g) + return g + + +def test_gruppe_in_kilogramm_zaehlt_stueck_artikel_mit(db): + """6 Stück à 83,33 g sind 500 g – zusammen mit 500 g Salami genau 1 kg.""" + gruppe = _wurst_gruppe(db) + riegel = _riegel(db, bestand=6) + riegel.group_id = gruppe.id + salami = Product(name="Salami", base_unit=BaseUnit.gram, group_id=gruppe.id) + db.add(salami) + db.flush() + db.add(Lot(product_id=salami.id, quantity=500)) + db.commit() + + assert _group_to_out(db, gruppe).stock == pytest.approx(1.0) # in Kilogramm + + +def test_gruppe_filtert_artikel_ohne_bruecke_weiter(db): + """Ohne Zweiteinheit bleibt der Stück-Artikel aussen vor – wie bisher.""" + gruppe = _wurst_gruppe(db) + riegel = _riegel(db, bruecke=False, bestand=6) + riegel.group_id = gruppe.id + salami = Product(name="Salami", base_unit=BaseUnit.gram, group_id=gruppe.id) + db.add(salami) + db.flush() + db.add(Lot(product_id=salami.id, quantity=500)) + db.commit() + + assert _group_to_out(db, gruppe).stock == pytest.approx(0.5) + # ``product_count`` zaehlt weiterhin ALLE Artikel im Untergraphen; wer + # mitrechnet, steht in ``ctx.matching``. + assert [p.name for p in group_min_context(gruppe).matching] == ["Salami"] + + +def test_faktoren_stehen_je_artikel_im_kontext(db): + gruppe = _wurst_gruppe(db) + riegel = _riegel(db, bestand=6) + riegel.group_id = gruppe.id + salami = Product(name="Salami", base_unit=BaseUnit.gram, group_id=gruppe.id) + db.add(salami) + db.commit() + + ctx = group_min_context(gruppe) + assert ctx.faktoren[riegel.id] == pytest.approx(250 / 3) + assert ctx.faktoren[salami.id] == 1.0 + + +# ---- Gewichtete Summen ---- + +def test_gewichtete_summe_nutzt_je_artikel_eigenen_faktor(db): + """Zwei Artikel, zwei verschiedene Faktoren – kein gemeinsamer.""" + a = _riegel(db, bestand=6) # 6 Stück + b = Product(name="Salami", base_unit=BaseUnit.gram) + db.add(b) + db.flush() + db.add(Lot(product_id=b.id, quantity=500)) + db.commit() + + faktoren = {a.id: 250 / 3, b.id: 1.0} + assert summe_bestand_gewichtet(db, [a, b], faktoren) == pytest.approx(1000) + # Ohne Faktoren wird roh addiert (6 + 500). + assert summe_bestand_gewichtet(db, [a, b], None) == pytest.approx(506) + + +def test_gewichtete_summe_im_subtree(db): + haus = Location(name="Haus") + db.add(haus) + db.flush() + kueche = Location(name="Küche", parent_id=haus.id) + db.add(kueche) + db.commit() + + a = _riegel(db, bestand=6, ort=kueche) + # Zweite Charge ohne Ort: zaehlt im Gesamtbestand, aber in keinem Subtree. + db.add(Lot(product_id=a.id, quantity=3, location_id=None)) + db.commit() + + faktoren = {a.id: 250 / 3} + assert summe_bestand_im_subtree_gewichtet(db, [a], haus.id, faktoren) == pytest.approx(500) + assert summe_bestand_gewichtet(db, [a], faktoren) == pytest.approx(750) + + +# ---- Mindestbestand in der Zweiteinheit ---- + +def test_mindestbestand_in_der_zweiteinheit_anzeigen(db): + """„Mindestens 250 g" an einem stückweise geführten Artikel.""" + from app.crud import product_to_out + from app.services.min_stock import schreibe_ueberall + + p = _riegel(db) + p.min_stock_unit_id = _gramm(db).id + schreibe_ueberall(db, p, 3) # 3 Stück, in Basiseinheiten gespeichert + db.commit() + + out = product_to_out(db, p) + assert out.min_stock == pytest.approx(3) # Speicherung unveraendert + assert out.min_stock_display == pytest.approx(250) # Anzeige in Gramm + assert out.min_stock_unit_label == "Gramm" + assert out.secondary_factor == pytest.approx(250 / 3)