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>
300 lines
9.6 KiB
Python
300 lines
9.6 KiB
Python
"""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
|