Zweiteinheit am Artikel: Bruecke zwischen Stueck, Gramm und Milliliter

Die drei Einheiten-Arten waren bisher strikt getrennt: BASE_OF_KIND bildet
count/weight/volume 1:1 auf Stueck/Gramm/Milliliter ab, ohne jeden Faktor
dazwischen. Zwei Stellen setzten das durch - to_base lehnte artfremde Einheiten
beim Ein-/Auslagern ab, und group_min_context filterte stueckweise gefuehrte
Artikel aus einer Kilogramm-Gruppe stillschweigend heraus. Letzteres war der
Anlass: eine Gruppe "Wurst" in kg sah Bratwuerste in Stueck gar nicht.

Ein Artikel darf jetzt eine Zweiteinheit tragen: "3 Stueck ≙ 250 g". Gespeichert
wird das eingegebene PAAR, nicht der Faktor - wer 3 und 250 eintippt, sieht beim
naechsten Oeffnen genau das wieder. Das hat auch einen rechnerischen Grund:
250 * 3 / 250 ist exakt 3, der Umweg ueber 250/3 ergibt 3,0000000000000004 und
liefe damit gegen die Bestandspruefung beim Auslagern.

Der Artikel bleibt in seiner Basiseinheit gefuehrt; die Bruecke ist reine
Rechnung. Gruppen zaehlen artfremde Artikel jetzt mit ihrem Faktor mit
(GroupMinContext.faktoren), Bestandssummen laufen dafuer je Artikel gewichtet -
weiterhin zwei Abfragen, nur mit GROUP BY. Ein-/Auslagern in der Fremdeinheit
geht, krumme Mengen werden bewusst gebucht statt gerundet: 100 g sind 1,2 Stueck,
und Runden wuerde stumm etwas anderes buchen als angegeben.

WICHTIGE KORREKTUR am urspruenglichen Plan: die Teilmengen-Bedingung in
_gruppen_bedarfe konnte NICHT bleiben. Sie war bisher zugleich ein
Einheiten-Schutz, weil Artikel verschiedener Arten zwangslaeufig disjunkt waren.
Mit der Bruecke gilt sie ploetzlich auch zwischen einer Stueck- und einer
Gramm-Gruppe - und _netted_topups haette einen Bedarf in Stueck von einem in
Gramm abgezogen. Jetzt wird nur noch zwischen Gruppen derselben Basiseinheit
verrechnet.

Open Food Facts: "3 x 80 g" verlor bisher den Multiplikator, weil der Regex den
ersten Zahl-Einheit-Treffer nahm. parse_gebinde liefert jetzt Gesamtmenge UND
Stueckzahl und belegt die Zweiteinheit vor; parse_quantity behaelt seinen
schmalen Vertrag.

18 neue Tests. Dass test_wrong_kind_rejected und
test_einheitenfilter_gilt_auch_fuer_untergruppen unveraendert gruen bleiben, ist
selbst der Beleg: ohne Bruecke aendert sich nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-16 00:38:13 +02:00
parent f33054c534
commit 9f3225113e
14 changed files with 675 additions and 92 deletions

View File

@@ -9,13 +9,60 @@ from fastapi import HTTPException, status
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session 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 .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.min_stock import UEBERALL_NAME, lies_ueberall
from .services.stock import current_stock, location_subtree_stock_base 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: def product_tracking(db: Session, product: Product) -> str:
"""Verwaltungsart eines Artikels: aus seiner Kategorie abgeleitet. """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. # lesen es unveraendert weiter, es kommt nur aus einer anderen Quelle.
out.min_stock = lies_ueberall(product.location_min_stocks) out.min_stock = lies_ueberall(product.location_min_stocks)
if out.min_stock is not None: if out.min_stock is not None:
if product.min_stock_in_packages and product.package_size: out.min_stock_display, out.min_stock_unit_label = _min_anzeige(
out.min_stock_display = out.min_stock / product.package_size product, out.min_stock, name, factor
out.min_stock_unit_label = "Pkg" )
elif product.min_stock_unit is not None: _zweit_felder(out, product)
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.location_min_stocks = [ out.location_min_stocks = [
LocationMinStockOut( LocationMinStockOut(
@@ -166,15 +208,10 @@ def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOu
o.unit_factor = factor o.unit_factor = factor
o.min_stock = lies_ueberall(product.location_min_stocks) o.min_stock = lies_ueberall(product.location_min_stocks)
if o.min_stock is not None: if o.min_stock is not None:
if product.min_stock_in_packages and product.package_size: o.min_stock_display, o.min_stock_unit_label = _min_anzeige(
o.min_stock_display = o.min_stock / product.package_size product, o.min_stock, name, factor
o.min_stock_unit_label = "Pkg" )
elif product.min_stock_unit is not None: _zweit_felder(o, product)
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.location_min_stocks = [ o.location_min_stocks = [
LocationMinStockOut( LocationMinStockOut(
location_id=e.location_id, location_id=e.location_id,

View File

@@ -204,6 +204,11 @@ def _ensure_schema() -> None:
"ON product_location_min_stock (product_id) WHERE location_id IS NULL", "ON product_location_min_stock (product_id) WHERE location_id IS NULL",
"CREATE UNIQUE INDEX IF NOT EXISTS uq_group_ueberall_min " "CREATE UNIQUE INDEX IF NOT EXISTS uq_group_ueberall_min "
"ON group_location_min_stock (group_id) WHERE location_id IS NULL", "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: with engine.begin() as conn:
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent), # Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),

View File

@@ -272,6 +272,18 @@ class Product(Base):
package_size: Mapped[float | None] = mapped_column(Float, nullable=True) package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
# Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", … # Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", …
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True) 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 # Welche MHD-Genauigkeit bei diesem Produkt sinnvoll ist. Steuert nur die
# Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt). # Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt).
date_precision: Mapped[str] = mapped_column( date_precision: Mapped[str] = mapped_column(

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import re import re
from typing import NamedTuple
import httpx 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) _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<anzahl>\d+)\s*(?:st(?:ü|ue)ck|stk\.?|pcs?|st\.?)?\s*"
r"(?:[x×*]|à|a)\s*"
r"(?P<menge>\d+(?:[.,]\d+)?)\s*(?P<einheit>kg|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]: def parse_quantity(quantity: str | None) -> tuple[str, float | None]:
"""Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'. """Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'.
Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000), Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000),
'1,5 l' -> ('milliliter', 1500), '6 Stück' -> ('piece', None). '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: menge = parse_gebinde(quantity)
return "piece", None return menge.base_unit, menge.package_size
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)
def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None: 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 "" categories = product.get("categories") or ""
category_tags = product.get("categories_tags") or [] category_tags = product.get("categories_tags") or []
base_unit, package_size = parse_quantity(product.get("quantity")) menge = parse_gebinde(product.get("quantity"))
return { return {
"barcode": barcode, "barcode": barcode,
"name": name, "name": name,
"brand": (product.get("brands") or "").strip() or None, "brand": (product.get("brands") or "").strip() or None,
"image_url": product.get("image_front_url") or product.get("image_url") or None, "image_url": product.get("image_front_url") or product.get("image_url") or None,
"base_unit": base_unit, "base_unit": menge.base_unit,
"package_size": package_size, "package_size": menge.package_size,
"quantity_text": product.get("quantity"), "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_suggestion": categories.split(",")[0].strip() if categories else None,
"category_tags": category_tags, "category_tags": category_tags,
"source": source, "source": source,

View File

@@ -18,7 +18,10 @@ from ..schemas import (
from ..services import gruppen as gruppen_graph from ..services import gruppen as gruppen_graph
from ..services.conversion import group_min_context from ..services.conversion import group_min_context
from ..services.min_stock import UEBERALL_NAME, lies_ueberall, schreibe_ueberall 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"]) 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 # Bestand in derselben Einheit, in der auch der Mindestbestand erfasst ist
# (Gruppen-Gebinde ODER verwaltete Einheit) so ist beides vergleichbar. # (Gruppen-Gebinde ODER verwaltete Einheit) so ist beides vergleichbar.
ctx = group_min_context(group) 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.stock = round(stock_base / ctx.divisor, 3)
out.min_stock = None if ueberall is None else round(ueberall / ctx.divisor, 3) out.min_stock = None if ueberall is None else round(ueberall / ctx.divisor, 3)
unit = group.min_stock_unit unit = group.min_stock_unit
@@ -122,7 +126,7 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
total = ( total = (
stock_base stock_base
if loc_id is None 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) return round(total, 3)

View File

@@ -434,6 +434,28 @@ def _store_product_image_bg(product_id: int, url: str) -> None:
db.close() 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) @router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product( def create_product(
payload: ProductCreate, payload: ProductCreate,
@@ -456,6 +478,9 @@ def create_product(
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc 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: 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") 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( product = Product(
barcode=payload.barcode or None, barcode=payload.barcode or None,
name=payload.name, name=payload.name,
@@ -465,6 +490,9 @@ def create_product(
display_unit_id=display_unit_id, display_unit_id=display_unit_id,
package_size=payload.package_size, package_size=payload.package_size,
package_label=payload.package_label or None, 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, date_precision=payload.date_precision.value,
group_id=payload.group_id, group_id=payload.group_id,
category_id=payload.category_id, category_id=payload.category_id,
@@ -537,6 +565,22 @@ def update_product(
# aeltere App-Versionen und das Web-Formular unveraendert weiterlaufen. # aeltere App-Versionen und das Web-Formular unveraendert weiterlaufen.
ueberall_gesetzt = "min_stock" in data ueberall_gesetzt = "min_stock" in data
ueberall_wert = data.pop("min_stock", None) 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: if data.get("min_stock_in_packages") is None:
data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL
if data.get("date_precision") is None: if data.get("date_precision") is None:

View File

@@ -239,6 +239,10 @@ def export_backup_json(
"category": _category_path(db, p.category), "category": _category_path(db, p.category),
"min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None, "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), "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), "min_stocks": _min_stock_liste(p, loc_name),
# Gegenstands-Felder: # Gegenstands-Felder:
"shop": p.shop.name if p.shop else None, "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 # Mindestbestände je Ort (v3). Ältere Sicherungen haben nur den
# Gesamtwert, der oben schon über „mindestbestand" gesetzt wurde. # Gesamtwert, der oben schon über „mindestbestand" gesetzt wurde.
_min_stocks_einspielen(db, product, entry.get("min_stocks")) _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. # Gegenstands-Zusatzfelder nur ergänzend, nichts überschreiben.
shop_name = (entry.get("shop") or "").strip() shop_name = (entry.get("shop") or "").strip()
if shop_name and product.shop_id is None: if shop_name and product.shop_id is None:

View File

@@ -46,8 +46,8 @@ from ..services.stock import (
current_stock, current_stock,
descendant_location_ids, descendant_location_ids,
location_subtree_stock_base, location_subtree_stock_base,
summe_bestand_base, summe_bestand_gewichtet,
summe_bestand_im_subtree_base, summe_bestand_im_subtree_gewichtet,
) )
from .settings import get_expiry_warning_days 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: { gid: {
h.id h.id
for h in gruppen_graph.teilgraph(g) 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() 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) schluessel = (e.group_id, e.location_id)
minima[schluessel] = e.min_stock minima[schluessel] = e.min_stock
bestand[schluessel] = ( bestand[schluessel] = (
summe_bestand_base(db, ctx.matching) summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren)
if e.location_id is None 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} alle_orte = {loc for (_, loc) in minima if loc is not None}

View File

@@ -315,6 +315,12 @@ class ProductBase(BaseModel):
package_size: float | None = Field(default=None, gt=0) package_size: float | None = Field(default=None, gt=0)
# Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …) # Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …)
package_label: str | None = Field(default=None, max_length=32) 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). # Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
date_precision: DatePrecision = DatePrecision.day date_precision: DatePrecision = DatePrecision.day
group_id: int | None = None group_id: int | None = None
@@ -348,6 +354,12 @@ class ProductUpdate(BaseModel):
unit_id: int | None = None unit_id: int | None = None
package_size: float | None = Field(default=None, gt=0) package_size: float | None = Field(default=None, gt=0)
package_label: str | None = Field(default=None, max_length=32) 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 date_precision: DatePrecision | None = None
group_id: int | None = None group_id: int | None = None
category_id: int | None = None category_id: int | None = None
@@ -372,6 +384,13 @@ class ProductOut(BaseModel):
display_unit_id: int | None = None display_unit_id: int | None = None
package_size: float | None package_size: float | None
package_label: str | None = 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 date_precision: DatePrecision = DatePrecision.day
group_id: int | None group_id: int | None
category_id: int | None = None category_id: int | None = None

View File

@@ -54,6 +54,50 @@ def find_unit(db: Session, token: str) -> Unit | None:
return db.query(Unit).filter(func.lower(Unit.name) == t).first() 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: 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.""" """Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um."""
if quantity <= 0: if quantity <= 0:
@@ -71,10 +115,18 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) ->
if unit is None: if unit is None:
raise ConversionError(f"Unbekannte Einheit: {unit_token}") raise ConversionError(f"Unbekannte Einheit: {unit_token}")
if unit.kind != kind_of_product(product): if unit.kind != kind_of_product(product):
# 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( raise ConversionError(
f"Einheit '{unit.name}' passt nicht zur Art des Produkts " f"Einheit '{unit.name}' passt nicht zur Art des Produkts "
f"({BASE_LABEL[product.base_unit]})." 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 return quantity * unit.factor
@@ -119,6 +171,10 @@ class GroupMinContext(NamedTuple):
is_package: bool # zaehlbares Gebinde (aufrunden) vs. Anzeige-/Basiseinheit is_package: bool # zaehlbares Gebinde (aufrunden) vs. Anzeige-/Basiseinheit
base_unit: BaseUnit | None base_unit: BaseUnit | None
matching: list[Product] # Produkte, die zur Gruppen-Einheit zaehlen 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: 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 ``matching`` umfasst die Artikel der Gruppe UND ihrer Untergruppen
(transitiv): „Wurst" zaehlt Wurst, Grillwurst, Salami und alles darunter. (transitiv): „Wurst" zaehlt Wurst, Grillwurst, Salami und alles darunter.
Die Einheiten-Filterung der OBERgruppe gilt dabei fuer den ganzen Die Einheiten-Filterung der OBERgruppe gilt dabei fuer den ganzen
Teilgraphen zaehlt „Wurst" in Kilogramm, bleiben stueckweise gefuehrte Teilgraphen.
Artikel einer Untergruppe aussen vor, genau wie ein direkt zugeordneter.
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 alle = gruppen.produkte(group) # inkl. Untergruppen, ohne Dubletten
unit = group.min_stock_unit unit = group.min_stock_unit
if unit is not None: if unit is not None:
base = BASE_OF_KIND[unit.kind] 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 base_unit: BaseUnit | None = base
else: else:
# Ohne verwaltete Einheit zaehlt die Gruppe roh weiter dann werden
# Gramm und Stueck weiterhin ungefiltert addiert (unveraendert).
matching = alle matching = alle
faktoren = {p.id: 1.0 for p in alle}
base_unit = None base_unit = None
if group.min_stock_in_packages and group.package_size and group.package_size > 0: if group.min_stock_in_packages and group.package_size and group.package_size > 0:
return GroupMinContext( return GroupMinContext(
float(group.package_size), group.package_label or "Packung", divisor=float(group.package_size),
True, base_unit, matching, label=group.package_label or "Packung",
is_package=True, base_unit=base_unit, matching=matching, faktoren=faktoren,
) )
if unit is not None: if unit is not None:
return GroupMinContext(unit.factor or 1.0, unit.name, False, base_unit, matching) return GroupMinContext(
return GroupMinContext(1.0, "", False, None, matching) 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,
)

View File

@@ -216,63 +216,88 @@ def current_stock(db: Session, product_id: int) -> float:
return float(sum(q for (q,) in total)) return float(sum(q for (q,) in total))
def summe_bestand_base(db: Session, produkte: Sequence[Product]) -> float: def _summe_je_artikel(
"""Gesamtbestand mehrerer Artikel (Basiseinheiten) in EINER Abfrage. 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 ``orte`` = None zaehlt alles (auch Chargen ohne Lagerort), sonst nur die
zwei Abfragen ab. Seit eine Gruppe die Artikel ihres ganzen Untergruppen- genannten Orte. Gruppiert wird nach Artikel, weil verschiedene Artikel
Graphen zaehlt, sind das schnell hunderte. unterschiedliche Umrechnungsfaktoren haben koennen (Zweiteinheit).
""" """
if not produkte: if not produkte:
return 0.0 return {}
# Gleiche Fallunterscheidung wie current_stock: Einzelstuecke zaehlen ihre # Gleiche Fallunterscheidung wie current_stock: Einzelstuecke zaehlen ihre
# Items, alle anderen summieren die Lot-Mengen. # Items, alle anderen summieren die Lot-Mengen.
einzel = [p.id for p in produkte if p.individual] einzel = [p.id for p in produkte if p.individual]
lots = [p.id for p in produkte if not p.individual] lots = [p.id for p in produkte if not p.individual]
total = 0.0 je_artikel: dict[int, float] = {}
if lots: if lots:
total += float( abfrage = (
db.query(func.coalesce(func.sum(Lot.quantity), 0.0)) db.query(Lot.product_id, func.coalesce(func.sum(Lot.quantity), 0.0))
.filter(Lot.product_id.in_(lots)) .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: if einzel:
total += float( abfrage = (
db.query(func.count(Item.id)).filter(Item.product_id.in_(einzel)).scalar() or 0 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( def summe_bestand_im_subtree_base(
db: Session, produkte: Sequence[Product], location_id: str db: Session, produkte: Sequence[Product], location_id: str
) -> float: ) -> float:
"""Wie ``summe_bestand_base``, aber nur an einem Lagerort inkl. Unterorten. """Ungewichtete Variante von ``summe_bestand_im_subtree_gewichtet``."""
return summe_bestand_im_subtree_gewichtet(db, produkte, location_id, None)
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
def location_stock_base(db: Session, product: Product, location_id: str) -> float: def location_stock_base(db: Session, product: Product, location_id: str) -> float:

View File

@@ -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. # Eine der beiden Kanten greift, die andere wird als Ring abgewiesen.
assert not (a.parents and b.parents) assert not (a.parents and b.parents)
assert any("Ring" in f for f in ergebnis.get("errors", [])) 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

View File

@@ -1,4 +1,4 @@
from app.off import parse_quantity from app.off import parse_gebinde, parse_quantity
def test_grams(): def test_grams():
@@ -22,3 +22,31 @@ def test_unparsable_is_piece():
assert parse_quantity(None) == ("piece", None) assert parse_quantity(None) == ("piece", None)
assert parse_quantity("6 Stück") == ("piece", None) assert parse_quantity("6 Stück") == ("piece", None)
assert parse_quantity("") == ("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

View File

@@ -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)