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

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