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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user