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>
160 lines
5.4 KiB
Python
160 lines
5.4 KiB
Python
"""Sicherung: Obergruppen und Mindestbestände je Ort überstehen den Umlauf.
|
||
|
||
Ohne diesen Weg gingen genau die Daten verloren, um die es beim Umbau ging –
|
||
die Kanten des Gruppen-Graphen und die „Überall"-Zeilen. Das JSON-Backup führt
|
||
Gruppen über ihren NAMEN, nicht über IDs: die sind zwischen zwei Instanzen
|
||
nicht gleich.
|
||
"""
|
||
|
||
import json
|
||
|
||
import pytest
|
||
|
||
from app.models import (
|
||
BaseUnit,
|
||
Group,
|
||
GroupLocationMinStock,
|
||
Location,
|
||
Product,
|
||
ProductLocationMinStock,
|
||
Role,
|
||
User,
|
||
)
|
||
from app.routers.transfer import _import_json, export_backup_json
|
||
|
||
|
||
@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
|
||
|
||
|
||
def _sicherung(db, user) -> bytes:
|
||
return export_backup_json(db=db, _=user).body
|
||
|
||
|
||
def test_backup_nimmt_obergruppen_und_bedarfe_mit(db, user, monkeypatch):
|
||
keller = Location(name="Keller")
|
||
db.add(keller)
|
||
db.flush()
|
||
|
||
wurst = Group(name="Wurst")
|
||
grillgut = Group(name="Grillgut")
|
||
db.add_all([wurst, grillgut])
|
||
db.flush()
|
||
grillwurst = Group(name="Grillwurst", parents=[wurst, grillgut])
|
||
db.add(grillwurst)
|
||
db.flush()
|
||
|
||
p = Product(name="Bell Grillwurst", base_unit=BaseUnit.gram, group_id=grillwurst.id)
|
||
db.add(p)
|
||
db.flush()
|
||
db.add_all([
|
||
GroupLocationMinStock(group_id=wurst.id, location_id=None, min_stock=5000),
|
||
GroupLocationMinStock(group_id=grillwurst.id, location_id=keller.id, min_stock=2000),
|
||
ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=800),
|
||
])
|
||
db.commit()
|
||
|
||
daten = json.loads(_sicherung(db, user))
|
||
assert daten["version"] == 3
|
||
nach_name = {g["name"]: g for g in daten["groups"]}
|
||
assert sorted(nach_name["Grillwurst"]["parents"]) == ["Grillgut", "Wurst"]
|
||
assert {"location": None, "min_stock": 5000} in nach_name["Wurst"]["min_stocks"]
|
||
assert {"location": "Keller", "min_stock": 2000} in nach_name["Grillwurst"]["min_stocks"]
|
||
artikel = daten["products"][0]
|
||
assert {"location": None, "min_stock": 800} in artikel["min_stocks"]
|
||
|
||
|
||
def test_wiederherstellen_baut_den_graphen_neu_auf(db, user):
|
||
"""Sicherung aus einer Instanz, Einspielen in eine leere zweite."""
|
||
keller = Location(name="Keller")
|
||
db.add(keller)
|
||
db.flush()
|
||
wurst = Group(name="Wurst")
|
||
grillgut = Group(name="Grillgut")
|
||
db.add_all([wurst, grillgut])
|
||
db.flush()
|
||
grillwurst = Group(name="Grillwurst", parents=[wurst, grillgut])
|
||
db.add(grillwurst)
|
||
db.flush()
|
||
p = Product(name="Bell Grillwurst", base_unit=BaseUnit.gram, group_id=grillwurst.id)
|
||
db.add(p)
|
||
db.flush()
|
||
db.add_all([
|
||
GroupLocationMinStock(group_id=wurst.id, location_id=None, min_stock=5000),
|
||
ProductLocationMinStock(product_id=p.id, location_id=keller.id, min_stock=800),
|
||
])
|
||
db.commit()
|
||
inhalt = _sicherung(db, user)
|
||
|
||
# Zweite, leere Instanz.
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import sessionmaker
|
||
from sqlalchemy.pool import StaticPool
|
||
|
||
from app.database import Base
|
||
from app.seed import ensure_builtin_units
|
||
|
||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
|
||
poolclass=StaticPool)
|
||
Base.metadata.create_all(bind=engine)
|
||
zweite = sessionmaker(bind=engine, autoflush=False, autocommit=False)()
|
||
ensure_builtin_units(zweite)
|
||
person = User(username="zwei", password_hash="x", role=Role.admin)
|
||
zweite.add(person)
|
||
zweite.commit()
|
||
|
||
_import_json(zweite, inhalt, person, "add")
|
||
|
||
neu_wurst = zweite.query(Group).filter(Group.name == "Wurst").one()
|
||
neu_grillwurst = zweite.query(Group).filter(Group.name == "Grillwurst").one()
|
||
assert sorted(g.name for g in neu_grillwurst.parents) == ["Grillgut", "Wurst"]
|
||
assert neu_wurst.location_min_stocks[0].location_id is None
|
||
assert neu_wurst.location_min_stocks[0].min_stock == pytest.approx(5000)
|
||
|
||
neu_artikel = zweite.query(Product).filter(Product.name == "Bell Grillwurst").one()
|
||
orte = {
|
||
(e.location.name if e.location else None): e.min_stock
|
||
for e in neu_artikel.location_min_stocks
|
||
}
|
||
assert orte == {"Keller": pytest.approx(800)}
|
||
zweite.close()
|
||
engine.dispose()
|
||
|
||
|
||
def test_ring_aus_der_sicherung_wird_abgewiesen(db, user):
|
||
"""Eine beschädigte Datei darf keinen Ring einschleusen."""
|
||
daten = {
|
||
"version": 3,
|
||
"groups": [
|
||
{"name": "A", "parents": ["B"]},
|
||
{"name": "B", "parents": ["A"]},
|
||
],
|
||
"products": [],
|
||
}
|
||
ergebnis = _import_json(db, json.dumps(daten).encode(), user, "add")
|
||
|
||
a = db.query(Group).filter(Group.name == "A").one()
|
||
b = db.query(Group).filter(Group.name == "B").one()
|
||
# Eine der beiden Kanten greift, die andere wird als Ring abgewiesen.
|
||
assert not (a.parents and b.parents)
|
||
assert any("Ring" in f for f in ergebnis.get("errors", []))
|
||
|
||
|
||
def test_sicherung_nimmt_zweiteinheit_mit(db, user):
|
||
"""Ohne das ginge die Brücke beim Wiederherstellen stillschweigend verloren."""
|
||
p = Product(name="Bratwurst", base_unit=BaseUnit.piece,
|
||
secondary_base="gram", secondary_count=3, secondary_amount=250)
|
||
db.add(p)
|
||
db.commit()
|
||
|
||
daten = json.loads(_sicherung(db, user))
|
||
artikel = daten["products"][0]
|
||
assert artikel["secondary_base"] == "gram"
|
||
assert artikel["secondary_count"] == 3
|
||
assert artikel["secondary_amount"] == 250
|