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.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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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()
def zweit_faktor(product: Product, ziel: BaseUnit) -> float | None:
"""Wie viele ``ziel``-Basiseinheiten EINE Basiseinheit des Artikels wert ist.
Die Zweiteinheit am Artikel („3 Stück ≙ 250 g") ist die einzige Brücke
zwischen den sonst strikt getrennten Arten. Sie gilt in BEIDE Richtungen:
derselbe Eintrag macht aus einem Stück-Artikel 83,333 g je Stück und aus
einem Gramm-Artikel 0,012 Stück je Gramm.
Gleiche Art → 1.0. Ohne (oder mit unbrauchbarer) Brücke → ``None``; dann
bleibt es bei der strikten Trennung, und der Aufrufer entscheidet, was das
heisst ablehnen beim Einlagern, aussortieren beim Gruppenbestand.
"""
if product.base_unit == ziel:
return 1.0
if product.secondary_base != ziel.value:
return None # keine Brücke in DIESE Art
anzahl = product.secondary_count # in der Basiseinheit des Artikels
menge = product.secondary_amount # in secondary_base
if not anzahl or not menge or anzahl <= 0 or menge <= 0:
return None
# „3 Stück ≙ 250 g" -> 1 Stück = 250/3 g. Bei einem Gramm-Artikel steht
# dieselbe Zeile als „250 g ≙ 3 Stück" da und liefert 3/250 Stück je Gramm.
return menge / anzahl
def in_artikel_basis(product: Product, menge: float, quell_base: BaseUnit) -> float | None:
"""Gegenrichtung: eine Menge in ``quell_base`` in die Artikel-Basiseinheit.
Bewusst erst multiplizieren, dann teilen. ``250 * 3 / 250`` ist exakt 3;
der Umweg ueber den Faktor (``250 / (250/3)``) ergibt 3,0000000000000004
und daran scheitert sonst „den ganzen Bestand auslagern" an der Pruefung in
``check_out``.
"""
if quell_base == product.base_unit:
return menge
if product.secondary_base != quell_base.value:
return None
anzahl = product.secondary_count
zweit = product.secondary_amount
if not anzahl or not zweit or anzahl <= 0 or zweit <= 0:
return None
return menge * anzahl / zweit
def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> float:
"""Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um."""
if quantity <= 0:
@@ -71,10 +115,18 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) ->
if unit is None:
raise ConversionError(f"Unbekannte Einheit: {unit_token}")
if unit.kind != kind_of_product(product):
raise ConversionError(
f"Einheit '{unit.name}' passt nicht zur Art des Produkts "
f"({BASE_LABEL[product.base_unit]})."
)
# Artfremde Einheit: geht nur über die Zweiteinheit des Artikels.
# „250 g" bei einem in Stück geführten Artikel wird zu 3 Stück.
# Bewusst OHNE Runden krumme Mengen sind erlaubt, sonst driftete
# der gebuchte Bestand von der tatsächlichen Entnahme weg.
ergebnis = in_artikel_basis(product, quantity * unit.factor, BASE_OF_KIND[unit.kind])
if ergebnis is None:
raise ConversionError(
f"Einheit '{unit.name}' passt nicht zur Art des Produkts "
f"({BASE_LABEL[product.base_unit]}). Mit einer Zweiteinheit am "
f"Artikel (z.B. „3 Stück ≙ 250 g\") ginge es."
)
return ergebnis
return quantity * unit.factor
@@ -119,6 +171,10 @@ class GroupMinContext(NamedTuple):
is_package: bool # zaehlbares Gebinde (aufrunden) vs. Anzeige-/Basiseinheit
base_unit: BaseUnit | None
matching: list[Product] # Produkte, die zur Gruppen-Einheit zaehlen
# Artikel-ID -> Gruppen-Basiseinheiten je EINER Basiseinheit des Artikels.
# Fuer gleichartige Artikel 1,0; fuer artfremde der Zweiteinheit-Faktor.
# Bestandssummen muessen damit GEWICHTET werden (services/stock.py).
faktoren: dict[int, float]
def group_min_context(group: Group) -> GroupMinContext:
@@ -132,23 +188,43 @@ def group_min_context(group: Group) -> GroupMinContext:
``matching`` umfasst die Artikel der Gruppe UND ihrer Untergruppen
(transitiv): „Wurst" zaehlt Wurst, Grillwurst, Salami und alles darunter.
Die Einheiten-Filterung der OBERgruppe gilt dabei fuer den ganzen
Teilgraphen zaehlt „Wurst" in Kilogramm, bleiben stueckweise gefuehrte
Artikel einer Untergruppe aussen vor, genau wie ein direkt zugeordneter.
Teilgraphen.
Artfremde Artikel zaehlen mit, WENN sie eine Zweiteinheit in die Art der
Gruppe tragen: ein in Stueck gefuehrter Artikel mit „3 Stueck ≙ 250 g"
zaehlt in einer Kilogramm-Gruppe mit 83,333 g je Stueck. Ohne Bruecke bleibt
er wie bisher aussen vor. Die Umrechnung steht in ``faktoren``.
"""
alle = gruppen.produkte(group) # inkl. Untergruppen, ohne Dubletten
unit = group.min_stock_unit
if unit is not None:
base = BASE_OF_KIND[unit.kind]
matching = [p for p in alle if p.base_unit == base]
matching = []
faktoren: dict[int, float] = {}
for p in alle:
f = zweit_faktor(p, base)
if f is not None:
matching.append(p)
faktoren[p.id] = f
base_unit: BaseUnit | None = base
else:
# Ohne verwaltete Einheit zaehlt die Gruppe roh weiter dann werden
# Gramm und Stueck weiterhin ungefiltert addiert (unveraendert).
matching = alle
faktoren = {p.id: 1.0 for p in alle}
base_unit = None
if group.min_stock_in_packages and group.package_size and group.package_size > 0:
return GroupMinContext(
float(group.package_size), group.package_label or "Packung",
True, base_unit, matching,
divisor=float(group.package_size),
label=group.package_label or "Packung",
is_package=True, base_unit=base_unit, matching=matching, faktoren=faktoren,
)
if unit is not None:
return GroupMinContext(unit.factor or 1.0, unit.name, False, base_unit, matching)
return GroupMinContext(1.0, "", False, None, matching)
return GroupMinContext(
divisor=unit.factor or 1.0, label=unit.name, is_package=False,
base_unit=base_unit, matching=matching, faktoren=faktoren,
)
return GroupMinContext(
divisor=1.0, label="", is_package=False,
base_unit=None, matching=matching, faktoren=faktoren,
)

View File

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