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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<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]:
|
||||
"""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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
# 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]})."
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
244
backend/tests/test_zweiteinheit.py
Normal file
244
backend/tests/test_zweiteinheit.py
Normal 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)
|
||||
Reference in New Issue
Block a user