Zwei zusammenhaengende Umbauten, weil sie dieselben Stellen betreffen.
Obergruppen: Gruppen bilden jetzt einen gerichteten azyklischen Graphen statt
einer flachen Liste. Eine Gruppe darf unter MEHREREN Obergruppen haengen -
"Grillwurst" unter "Wurst" UND unter "Grillgut"; mit einem einzelnen parent_id
waere genau das nicht abbildbar. Bestand und Mindestbestand einer Gruppe zaehlen
den gesamten Untergraphen, wobei eine ueber zwei Wege erreichbare Untergruppe
nur einmal zaehlt (services/gruppen.py arbeitet durchgaengig mit Mengen).
Product.group_id bleibt unveraendert - ein Artikel haengt weiter an genau einer
Gruppe.
Mindestbestaende: der separate Gesamt-Mindestbestand entfaellt. Er wird zur
Zeile mit location_id NULL ("Ueberall") und ist damit die Wurzel ueber allen
Lagerorten - dieselbe Verrechnung wie bei verschachtelten Orten greift jetzt
auch zwischen Ueberall und Kueche, wodurch derselbe Artikel nicht mehr doppelt
in der Einkaufsliste steht. Alle Werte liegen einheitlich in Basiseinheiten
statt in drei verschiedenen Einheiten nebeneinander; das Umrechnen beim
Umschalten der Erfassungseinheit entfaellt dadurch ersatzlos.
_netted_topups nimmt die Hierarchie jetzt als Parameter und faltet damit
Lagerort-Baum und Gruppen-Graph. Verrechnet wird zwischen zwei Gruppen nur,
wenn die zaehlenden Artikel der Untergruppe eine Teilmenge der Obergruppe sind -
zaehlt die Obergruppe in Kilogramm und die Untergruppe in Stueck, kommt ein Kauf
dort oben nicht an.
Die vierfach kopierte Bestandssumme wandert in Sammelabfragen
(summe_bestand_base), sonst vervielfacht der transitive Teilgraph die Abfragen.
Einmalige Datenwanderung beim Start (Merker in den Einstellungen), 18 neue
Tests - darunter Doppelzaehlung ueber zwei Wege, Ringschutz und die bewusst
offene Grenze bei zwei Obergruppen mit gemeinsamer Untergruppe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
385 lines
15 KiB
Python
385 lines
15 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..database import get_db
|
||
from ..deps import get_current_user, require_admin
|
||
from ..models import Barcode, Group, GroupLocationMinStock, Location, Product, User
|
||
from ..schemas import (
|
||
BarcodeCreate,
|
||
BarcodeNoteUpdate,
|
||
BarcodeOut,
|
||
GroupCreate,
|
||
GroupOut,
|
||
GroupUpdate,
|
||
LocationMinStockIn,
|
||
LocationMinStockOut,
|
||
ProductBarcodeOut,
|
||
)
|
||
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
|
||
|
||
router = APIRouter(prefix="/groups", tags=["groups"])
|
||
|
||
|
||
def _obergruppen_setzen(db: Session, group: Group, parent_ids: list[int]) -> None:
|
||
"""Obergruppen einer Gruppe ersetzen – mit Ringschutz.
|
||
|
||
Ohne die Pruefung entstuende ein Ring, und jede Bestands- oder
|
||
Bedarfsrechnung liefe im Kreis. Gleiche Absicherung wie beim Umhaengen einer
|
||
Kategorie (routers/categories.py), nur ueber eine Nachfahren-MENGE, weil
|
||
Gruppen ein Graph und kein Baum sind.
|
||
"""
|
||
# Die Nachfahren aendern sich durch das Setzen von OBERgruppen nicht –
|
||
# deshalb einmal vor der Schleife bestimmen.
|
||
verboten = gruppen_graph.nachfahren_ids(group) | {group.id}
|
||
gewuenscht: list[Group] = []
|
||
for pid in dict.fromkeys(parent_ids): # Reihenfolge halten, Dubletten raus
|
||
if pid in verboten:
|
||
raise HTTPException(
|
||
status.HTTP_409_CONFLICT,
|
||
"Eine Gruppe kann nicht sich selbst oder einer ihrer "
|
||
"Untergruppen untergeordnet werden.",
|
||
)
|
||
eltern = db.get(Group, pid)
|
||
if eltern is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Obergruppe nicht gefunden")
|
||
gewuenscht.append(eltern)
|
||
group.parents = gewuenscht
|
||
|
||
|
||
def _group_to_out(db: Session, group: Group) -> GroupOut:
|
||
out = GroupOut.model_validate(group)
|
||
# ``min_stock`` bleibt nach aussen in der Erfassungseinheit der Gruppe,
|
||
# gespeichert ist die „Ueberall"-Zeile aber in Basiseinheiten.
|
||
ueberall = lies_ueberall(group.location_min_stocks)
|
||
products = db.query(Product).filter(Product.group_id == group.id).all()
|
||
out.parent_ids = [p.id for p in group.parents]
|
||
out.child_ids = sorted(c.id for c in group.children)
|
||
out.direct_product_count = len(products)
|
||
out.product_count = len(gruppen_graph.produkte(group))
|
||
# Zu welchem Artikel gehoert ein Code? Der Gruppen-Code entsteht beim
|
||
# Zuordnen automatisch; die Herkunft soll trotzdem sichtbar bleiben.
|
||
artikel_zu_code: dict[str, Product] = {}
|
||
for product in products:
|
||
if product.barcode:
|
||
artikel_zu_code[product.barcode] = product
|
||
for alias in db.query(Barcode).filter(Barcode.product_id == product.id):
|
||
artikel_zu_code[alias.code] = product
|
||
|
||
out.barcodes = []
|
||
for b in db.query(Barcode).filter(Barcode.group_id == group.id).order_by(Barcode.id).all():
|
||
eintrag = BarcodeOut.model_validate(b)
|
||
besitzer = artikel_zu_code.get(b.code)
|
||
if besitzer is not None:
|
||
eintrag.product_name = besitzer.name
|
||
eintrag.product_brand = besitzer.brand
|
||
out.barcodes.append(eintrag)
|
||
# Codes der Artikel in dieser Gruppe mitliefern. Sie waren bisher nirgends
|
||
# sichtbar, wodurch eine Gruppe mit Artikeln "0 EANs" anzeigte.
|
||
# Was schon als Gruppen-Code gefuehrt wird, hier ueberspringen - sonst
|
||
# steht derselbe Code zweimal in der Liste, einmal davon schreibgeschuetzt.
|
||
schon_da = {b.code for b in out.barcodes}
|
||
product_codes: list[ProductBarcodeOut] = []
|
||
for product in products:
|
||
if product.barcode and product.barcode not in schon_da:
|
||
product_codes.append(
|
||
ProductBarcodeOut(
|
||
code=product.barcode, product_id=product.id,
|
||
product_name=product.name, product_brand=product.brand,
|
||
)
|
||
)
|
||
for alias in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id):
|
||
if alias.code in schon_da:
|
||
continue
|
||
product_codes.append(
|
||
ProductBarcodeOut(
|
||
code=alias.code, product_id=product.id,
|
||
product_name=product.name, product_brand=product.brand,
|
||
)
|
||
)
|
||
out.product_barcodes = product_codes
|
||
|
||
# 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)
|
||
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
|
||
if unit is not None:
|
||
out.min_stock_unit_name = unit.name
|
||
out.min_stock_unit_factor = unit.factor
|
||
out.kind = unit.kind.value
|
||
|
||
# Bestand je Lagerort (inkl. Unterorte) in derselben Einheit wie out.stock.
|
||
# Bestand je Lagerort in BASISEINHEITEN – wie ``min_stock`` in denselben
|
||
# Zeilen. Nur ``out.stock`` oben rechnet in der Erfassungseinheit.
|
||
def _loc_stock(loc_id: str | None) -> float:
|
||
# „Ueberall" (Ort NULL) zaehlt den Gesamtbestand – inkl. Chargen, die
|
||
# (noch) an keinem Lagerort liegen.
|
||
total = (
|
||
stock_base
|
||
if loc_id is None
|
||
else summe_bestand_im_subtree_base(db, ctx.matching, loc_id)
|
||
)
|
||
return round(total, 3)
|
||
|
||
out.location_min_stocks = [
|
||
LocationMinStockOut(
|
||
location_id=e.location_id,
|
||
location_name=UEBERALL_NAME if e.location_id is None else (
|
||
e.location.name if e.location else None
|
||
),
|
||
min_stock=e.min_stock,
|
||
stock=_loc_stock(e.location_id),
|
||
)
|
||
# „Ueberall" zuerst, danach nach Anlagereihenfolge.
|
||
for e in sorted(group.location_min_stocks, key=lambda x: (x.location_id is not None, x.id))
|
||
]
|
||
return out
|
||
|
||
|
||
@router.get("", response_model=list[GroupOut])
|
||
def list_groups(
|
||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||
) -> list[GroupOut]:
|
||
groups = db.query(Group).order_by(Group.name).all()
|
||
return [_group_to_out(db, g) for g in groups]
|
||
|
||
|
||
@router.put("/{group_id}/location-min-stock", response_model=GroupOut)
|
||
def set_group_location_min_stock(
|
||
group_id: int,
|
||
payload: list[LocationMinStockIn],
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(require_admin),
|
||
) -> GroupOut:
|
||
"""Gruppen-Mindestbestände je Lagerort ersetzen (Menge 0 = Eintrag entfällt).
|
||
|
||
``location_id = null`` ist „Überall" und damit ein Ort wie jeder andere –
|
||
er ersetzt den frueheren Gesamt-Mindestbestand. Mengen in Basiseinheiten.
|
||
"""
|
||
group = db.get(Group, group_id)
|
||
if group is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||
|
||
db.query(GroupLocationMinStock).filter(
|
||
GroupLocationMinStock.group_id == group_id
|
||
).delete()
|
||
# None (= Überall) ist ein eigener Schluessel in der Dublettenpruefung.
|
||
gesehen: set[str | None] = set()
|
||
for eintrag in payload:
|
||
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
|
||
continue
|
||
if eintrag.location_id is not None and db.get(Location, eintrag.location_id) is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||
gesehen.add(eintrag.location_id)
|
||
db.add(GroupLocationMinStock(
|
||
group_id=group_id,
|
||
location_id=eintrag.location_id,
|
||
min_stock=eintrag.min_stock,
|
||
))
|
||
db.commit()
|
||
db.refresh(group)
|
||
return _group_to_out(db, group)
|
||
|
||
|
||
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
||
def create_group(
|
||
payload: GroupCreate,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(require_admin),
|
||
) -> GroupOut:
|
||
if db.query(Group).filter(Group.name == payload.name).first():
|
||
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
|
||
group = Group(
|
||
name=payload.name,
|
||
min_stock_unit_id=payload.min_stock_unit_id,
|
||
package_size=payload.package_size,
|
||
package_label=payload.package_label,
|
||
min_stock_in_packages=payload.min_stock_in_packages,
|
||
)
|
||
db.add(group)
|
||
# Erst flushen: die Ringpruefung in _obergruppen_setzen braucht group.id.
|
||
db.flush()
|
||
_obergruppen_setzen(db, group, payload.parent_ids)
|
||
# ``min_stock`` kommt in der Erfassungseinheit der Gruppe, gespeichert wird
|
||
# in Basiseinheiten – deshalb ueber den Divisor.
|
||
if payload.min_stock is not None:
|
||
schreibe_ueberall(
|
||
db, group, payload.min_stock * (group_min_context(group).divisor or 1.0)
|
||
)
|
||
db.commit()
|
||
db.refresh(group)
|
||
return _group_to_out(db, group)
|
||
|
||
|
||
@router.patch("/{group_id}", response_model=GroupOut)
|
||
def update_group(
|
||
group_id: int,
|
||
payload: GroupUpdate,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(require_admin),
|
||
) -> GroupOut:
|
||
group = db.get(Group, group_id)
|
||
if group is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||
data = payload.model_dump(exclude_unset=True)
|
||
# Muss RAUS, bevor unten stumpf jedes Feld per setattr gesetzt wird –
|
||
# ``parent_ids`` ist kein Modellattribut, sondern eine Beziehung.
|
||
obergruppen = data.pop("parent_ids", None)
|
||
# Ebenfalls kein Modellfeld mehr: der Mindestbestand ist die „Ueberall"-Zeile.
|
||
min_gesetzt = "min_stock" in data
|
||
min_wert = data.pop("min_stock", None)
|
||
if "name" in data and data["name"]:
|
||
clash = (
|
||
db.query(Group)
|
||
.filter(Group.name == data["name"], Group.id != group_id)
|
||
.first()
|
||
)
|
||
if clash:
|
||
raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben")
|
||
|
||
for field, value in data.items():
|
||
setattr(group, field, value)
|
||
if obergruppen is not None:
|
||
_obergruppen_setzen(db, group, obergruppen)
|
||
# Die Erfassungseinheit umzustellen (Gebinde <-> verwaltete Einheit) braucht
|
||
# keine Umrechnung mehr: gespeichert wird in Basiseinheiten, der physische
|
||
# Bedarf bleibt dadurch von selbst gleich.
|
||
if min_gesetzt:
|
||
divisor = group_min_context(group).divisor or 1.0
|
||
schreibe_ueberall(db, group, None if min_wert is None else min_wert * divisor)
|
||
db.commit()
|
||
db.refresh(group)
|
||
return _group_to_out(db, group)
|
||
|
||
|
||
def _describe_conflict(db: Session, code: str, group_id: int) -> str | None:
|
||
"""Sagt, wem ein Code schon gehört – "bereits vergeben" allein half nicht weiter."""
|
||
product = db.query(Product).filter(Product.barcode == code).first()
|
||
existing = db.query(Barcode).filter(Barcode.code == code).first()
|
||
|
||
if existing is not None and existing.product_id:
|
||
product = db.get(Product, existing.product_id) or product
|
||
|
||
if product is not None:
|
||
if product.group_id == group_id:
|
||
# Der Code steht durch den Artikel ohnehin schon in dieser Gruppe.
|
||
return (
|
||
f"Dieser Code gehört zum Artikel \"{product.name}\" und ist über ihn "
|
||
"bereits in dieser Gruppe."
|
||
)
|
||
if product.group_id is not None:
|
||
other = db.get(Group, product.group_id)
|
||
name = other.name if other else "einer anderen Gruppe"
|
||
return (
|
||
f"Dieser Code gehört zum Artikel \"{product.name}\", der in der Gruppe "
|
||
f"\"{name}\" liegt. Ändere die Gruppe des Artikels, statt den Code hier "
|
||
"einzutragen."
|
||
)
|
||
return (
|
||
f"Dieser Code gehört bereits zum Artikel \"{product.name}\". Ordne den "
|
||
"Artikel dieser Gruppe zu – sein Code erscheint dann automatisch hier."
|
||
)
|
||
|
||
if existing is not None and existing.group_id == group_id:
|
||
return "Dieser Code ist in dieser Gruppe bereits hinterlegt."
|
||
if existing is not None and existing.group_id:
|
||
other = db.get(Group, existing.group_id)
|
||
name = other.name if other else "einer anderen Gruppe"
|
||
return f"Dieser Code ist bereits der Gruppe \"{name}\" zugeordnet."
|
||
if existing is not None:
|
||
return "Dieser Code ist bereits vergeben."
|
||
return None
|
||
|
||
|
||
@router.post("/{group_id}/barcodes", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
||
def add_group_barcode(
|
||
group_id: int,
|
||
payload: BarcodeCreate,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(require_admin),
|
||
) -> GroupOut:
|
||
"""EAN einer Gruppe zuordnen (z.B. alle Mehl-Marken zur Gruppe "Mehl")."""
|
||
group = db.get(Group, group_id)
|
||
if group is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||
code = payload.code.strip()
|
||
conflict = _describe_conflict(db, code, group.id)
|
||
if conflict:
|
||
raise HTTPException(status.HTTP_409_CONFLICT, conflict)
|
||
db.add(Barcode(code=code, note=(payload.note or None), group_id=group.id))
|
||
db.commit()
|
||
db.refresh(group)
|
||
return _group_to_out(db, group)
|
||
|
||
|
||
@router.patch("/{group_id}/barcodes/{code}", response_model=GroupOut)
|
||
def update_group_barcode_note(
|
||
group_id: int,
|
||
code: str,
|
||
payload: BarcodeNoteUpdate,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(require_admin),
|
||
) -> GroupOut:
|
||
"""Notiz zu einem Gruppen-Code setzen.
|
||
|
||
Betrifft ausdrücklich auch Codes, die beim Zuordnen eines Artikels
|
||
automatisch entstanden sind – die hatten bisher gar keine Möglichkeit,
|
||
eine Notiz zu bekommen.
|
||
"""
|
||
group = db.get(Group, group_id)
|
||
if group is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||
entry = (
|
||
db.query(Barcode).filter(Barcode.group_id == group_id, Barcode.code == code).first()
|
||
)
|
||
if entry is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Code nicht gefunden")
|
||
|
||
note = (payload.note or "").strip()
|
||
entry.note = note or None
|
||
db.commit()
|
||
db.refresh(group)
|
||
return _group_to_out(db, group)
|
||
|
||
|
||
@router.delete("/{group_id}/barcodes/{code}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_group_barcode(
|
||
group_id: int,
|
||
code: str,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(require_admin),
|
||
) -> None:
|
||
entry = (
|
||
db.query(Barcode).filter(Barcode.group_id == group_id, Barcode.code == code).first()
|
||
)
|
||
if entry is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Code nicht gefunden")
|
||
db.delete(entry)
|
||
db.commit()
|
||
|
||
|
||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_group(
|
||
group_id: int,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(require_admin),
|
||
) -> None:
|
||
group = db.get(Group, group_id)
|
||
if group is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||
# Untergruppen bleiben bestehen und ruecken NICHT nach oben: im Graphen
|
||
# waere unklar, an welchen der moeglicherweise mehreren Grosseltern sie
|
||
# sollten – ein automatisches Umhaengen wuerde stillschweigend neue
|
||
# Bestandssummen erzeugen. Sie verlieren nur die Verbindung. Kanten
|
||
# ausdruecklich loesen, weil SQLite Fremdschluessel nicht erzwingt.
|
||
group.parents.clear()
|
||
group.children.clear()
|
||
db.flush()
|
||
db.delete(group)
|
||
db.commit()
|