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