Files
Vorrania/backend/tests/test_anbrechen.py
T
ScarriffleandClaude Opus 5 53a8b9e0b6 Anbrechen und Teilverbrauch bekommen Knoepfe
Das Backend konnte das seit dem letzten Commit, aber ohne Oberflaeche kam
niemand dran. Jetzt traegt jede Charge zwei Knoepfe:

"Anbrechen" spaltet eine Packung ab, gibt ihr ein "zu verbrauchen bis" und
laesst den Rest ungeoeffnet mit seinem eigenen MHD - mit Rueckfrage, weil
sich die abgespaltene Packung danach anders verhaelt.

"Verbrauchen" nimmt die VERBRAUCHTE Menge ("100 ml genommen", "2 von 6
Broetchen"), nicht den Rest - das ist die Zahl, die man kennt. War die
Charge noch zu, wird sie dabei angebrochen; der Haken dafuer ist
abwaehlbar, falls man nur eine Buchung nachtraegt. Die Einheitenauswahl
holt die Hausmasse der Kategorie dazu, deshalb laesst sich "2 EL" buchen
und landet bei Mehl als 20 g im Bestand.

Die Liste zeigt den Zustand jetzt auch: Spalte "Zustand" (angebrochen),
Spalte "Verbrauchen bis", und bei einer offenen Packung die Restmenge in
Basiseinheiten statt im Gebinde - "980 g von 1.000 g" sagt etwas,
"0,98 Packungen" nicht.

Gewarnt wird nach dem FRUEHEREN von MHD und Verbrauchsdatum (units.js::
faelligAm, Spiegelbild von Lot.faellig_am): Eine angebrochene Milch ist
nicht mehr bis zum aufgedruckten Datum gut. Dafuer ein eigenes Farb-Token
--open in ruhigem Blaugrau - angebrochen ist ein Zustand, keine Warnung.

Dazu ein Fehler, der die ganze Anzeige wertlos gemacht haette: In
list_all_lots fehlten opened_on, consume_by und opened_quantity. Die
Liste haette eine offene Packung wie jede andere Charge gezeigt, samt
falscher Ablaufwarnung. Test dagegen.

Von Hand durchgeklickt: 4x1l Milch anbrechen ergibt 1 l offen mit
Verbrauchsdatum 5.10. und 3 l zu mit MHD 15.11.; "2 EL" aus dem Mehl
ergibt 980 g offen und 1 Packung zu. Suite bei 495.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 14:17:20 +02:00

300 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Angebrochene Packungen: Abspalten, Verbrauchsdatum, Teilverbrauch, Frist-Vererbung."""
from datetime import date, timedelta
import pytest
from fastapi import HTTPException
from app.models import (
BaseUnit,
Category,
CategoryTracking,
Lot,
Movement,
MovementType,
Product,
Role,
Setting,
User,
)
from app.routers.stock import consume_lot, list_all_lots, open_lot
from app.schemas import LotConsume, LotOpen
from app.services.stock import check_in, current_stock
from app.services.vererbung import frist_herkunft, oeffnungsfrist
@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
@pytest.fixture()
def milch(db):
"""Milliliter-Artikel mit 1000 ml je Packung, in einer Lebensmittel-Kategorie."""
cat = Category(name="Molkereiprodukte", tracking=CategoryTracking.food.value)
db.add(cat)
db.flush()
product = Product(
name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000,
category_id=cat.id, source="manual",
)
db.add(product)
db.commit()
db.refresh(product)
return product
def _charge(db, product, menge_base, best_before=None):
lot = Lot(product_id=product.id, quantity=menge_base, best_before=best_before)
db.add(lot)
db.commit()
db.refresh(lot)
return lot
# --------------------------------------------------------------------------
# Anbrechen spaltet eine Packung ab
# --------------------------------------------------------------------------
def test_anbrechen_spaltet_eine_packung_ab(db, user, milch):
lot = _charge(db, milch, 4000) # 4 x 1 l in einer Zeile
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
assert offen.quantity == 1000
assert offen.opened_on == date.today()
assert offen.opened_quantity == 1000
# Der Rest bleibt zu – sonst trüge er das kurze Verbrauchsdatum mit.
rest = db.query(Lot).filter(Lot.id != offen.id).one()
assert rest.quantity == 3000
assert rest.opened_on is None
assert rest.consume_by is None
# Der Gesamtbestand ändert sich beim Anbrechen nicht.
assert current_stock(db, milch.id) == 4000
def test_anbrechen_einer_einzelnen_packung_spaltet_nichts(db, user, milch):
lot = _charge(db, milch, 1000)
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
assert offen.id == lot.id
assert db.query(Lot).count() == 1
assert offen.quantity == 1000
def test_anbrechen_nimmt_hoechstens_die_ganze_charge(db, user, milch):
lot = _charge(db, milch, 400) # weniger als eine Packung
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
assert offen.quantity == 400
assert db.query(Lot).count() == 1
def test_anbrechen_mit_eigener_menge(db, user, milch):
lot = _charge(db, milch, 4000)
offen = open_lot(lot.id, LotOpen(quantity=500), db=db, _=user)
assert offen.quantity == 500
assert db.query(Lot).filter(Lot.id != offen.id).one().quantity == 3500
def test_zweimal_anbrechen_aendert_nichts(db, user, milch):
lot = _charge(db, milch, 4000)
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
nochmal = open_lot(offen.id, LotOpen(), db=db, _=user)
assert nochmal.id == offen.id
assert nochmal.quantity == 1000
assert db.query(Lot).count() == 2
def test_verbrauchsdatum_kommt_aus_der_frist(db, user, milch):
db.add(Setting(key="open_days", value="5"))
db.commit()
lot = _charge(db, milch, 1000)
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
assert offen.consume_by == date.today() + timedelta(days=5)
def test_mhd_bleibt_neben_dem_verbrauchsdatum_stehen(db, user, milch):
mhd = date.today() + timedelta(days=20)
lot = _charge(db, milch, 1000, best_before=mhd)
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
assert offen.best_before == mhd # unangetastet
assert offen.consume_by is not None
# Gewarnt wird nach dem früheren der beiden.
frisch = db.get(Lot, offen.id)
assert frisch.faellig_am == frisch.consume_by
def test_frueheres_mhd_schlaegt_das_verbrauchsdatum(db, user, milch):
mhd = date.today() + timedelta(days=1) # läuft vor der Öffnungsfrist ab
lot = _charge(db, milch, 1000, best_before=mhd)
open_lot(lot.id, LotOpen(), db=db, _=user)
frisch = db.query(Lot).one()
assert frisch.faellig_am == mhd
# --------------------------------------------------------------------------
# Verbrauchen
# --------------------------------------------------------------------------
def test_verbrauchen_oeffnet_in_einem_schritt(db, user, milch):
lot = _charge(db, milch, 4000)
ergebnis = consume_lot(lot.id, LotConsume(quantity=100, unit="ml"), db=db, user=user)
assert ergebnis.opened is True
assert ergebnis.lot is not None
assert ergebnis.lot.quantity == 900 # 1000 geöffnet, 100 verbraucht
assert ergebnis.lot.opened_quantity == 1000
assert current_stock(db, milch.id) == 3900
# Der Verbrauch ist eine Bewegung, das Anbrechen allein nicht.
bewegungen = db.query(Movement).all()
assert len(bewegungen) == 1
assert bewegungen[0].type == MovementType.out
assert bewegungen[0].quantity == 100
def test_verbrauchen_ohne_anbrechen_wenn_abgewaehlt(db, user, milch):
lot = _charge(db, milch, 4000)
ergebnis = consume_lot(
lot.id, LotConsume(quantity=100, unit="ml", auto_open=False), db=db, user=user
)
assert ergebnis.opened is False
assert db.query(Lot).count() == 1
assert db.query(Lot).one().opened_on is None
assert current_stock(db, milch.id) == 3900
def test_weiterer_verbrauch_nutzt_die_offene_packung(db, user, milch):
lot = _charge(db, milch, 4000)
consume_lot(lot.id, LotConsume(quantity=100, unit="ml"), db=db, user=user)
offen = db.query(Lot).filter(Lot.opened_on.isnot(None)).one()
consume_lot(offen.id, LotConsume(quantity=200, unit="ml"), db=db, user=user)
assert db.get(Lot, offen.id).quantity == 700
assert db.query(Lot).count() == 2
def test_leere_offene_packung_verschwindet(db, user, milch):
lot = _charge(db, milch, 1000)
open_lot(lot.id, LotOpen(), db=db, _=user)
ergebnis = consume_lot(lot.id, LotConsume(quantity=1000, unit="ml"), db=db, user=user)
assert ergebnis.lot is None
assert db.query(Lot).count() == 0
assert current_stock(db, milch.id) == 0
def test_mehr_verbrauchen_als_drin_ist_scheitert(db, user, milch):
lot = _charge(db, milch, 1000)
with pytest.raises(HTTPException) as fehler:
consume_lot(lot.id, LotConsume(quantity=2000, unit="ml"), db=db, user=user)
assert fehler.value.status_code == 409
def test_unbekannte_einheit_bricht_nichts_an(db, user, milch):
"""Erst rechnen, dann anbrechen – sonst stünde eine Packung grundlos offen."""
lot = _charge(db, milch, 4000)
with pytest.raises(HTTPException) as fehler:
consume_lot(lot.id, LotConsume(quantity=1, unit="furlong"), db=db, user=user)
assert fehler.value.status_code == 400
assert db.query(Lot).one().opened_on is None
def test_verbrauchen_in_packungen(db, user, rice, milch):
"""„1 Packung" muss weiter gehen – die Hausmaße dürfen das nicht verdrängen."""
lot = check_in(db, rice, 3, "package", None, None, None)
db.commit()
consume_lot(lot.id, LotConsume(quantity=1, unit="package"), db=db, user=user)
assert current_stock(db, rice.id) == 1000
# --------------------------------------------------------------------------
# Frist: Artikel > Kategorie > Oberkategorie > global
# --------------------------------------------------------------------------
def test_frist_vererbung_ueber_die_ebenen(db, milch):
# 1. Ohne alles gilt der Standard aus der Konfiguration.
assert oeffnungsfrist(db, milch) == 3
assert frist_herkunft(db, milch)[1] == "Einstellungen"
# 2. Globale Einstellung.
db.add(Setting(key="open_days", value="7"))
db.commit()
assert oeffnungsfrist(db, milch) == 7
# 3. Oberkategorie.
ober = db.get(Category, milch.category_id)
unter = Category(name="Frischmilch", parent_id=ober.id,
tracking=CategoryTracking.food.value)
db.add(unter)
db.flush()
milch.category_id = unter.id
ober.open_days = 4
db.commit()
assert oeffnungsfrist(db, milch) == 4
assert frist_herkunft(db, milch)[1] == "Kategorie: Molkereiprodukte"
# 4. Die nähere Kategorie gewinnt.
unter.open_days = 2
db.commit()
assert oeffnungsfrist(db, milch) == 2
assert frist_herkunft(db, milch)[1] == "Kategorie: Frischmilch"
# 5. Der Artikel schlägt alles.
milch.open_days = 1
db.commit()
assert oeffnungsfrist(db, milch) == 1
assert frist_herkunft(db, milch)[1] == "Artikel"
def test_kaputte_globale_einstellung_faellt_auf_den_standard(db, milch):
db.add(Setting(key="open_days", value="viele"))
db.commit()
assert oeffnungsfrist(db, milch) == 3
def test_die_chargenliste_zeigt_den_offenen_zustand(db, user, milch):
"""Ohne diese Felder saehe die Liste eine offene Packung wie jede andere.
Die Zeile traegt dann auch die falsche Ablaufwarnung, denn gewarnt wird nach
dem fruheren von MHD und Verbrauchsdatum.
"""
check_in(db, milch, 4000, "ml", date.today() + timedelta(days=30), None, None)
db.commit()
[charge] = db.query(Lot).all()
open_lot(charge.id, LotOpen(quantity=None), db=db, _=user)
zeilen = {z.quantity: z for z in list_all_lots(db=db, _=user)}
offen = zeilen[1000]
zu = zeilen[3000]
assert offen.opened_on == date.today()
assert offen.consume_by == date.today() + timedelta(days=3)
assert offen.opened_quantity == 1000
assert zu.opened_on is None
assert zu.consume_by is None