Files
Vorrania/backend/app/routers/stock.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

595 lines
21 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.
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from ..crud import product_tracking, resolve_product
from ..database import get_db
from ..deps import get_current_user
from ..models import CategoryTracking, Location, Lot, Movement, MovementType, Product, User
from ..schemas import (
BatchCheckInRequest,
BatchCheckInResponse,
BulkLotCheckout,
BulkLotDelete,
BulkLotLocation,
CheckInRequest,
CheckInResponse,
CheckOutRequest,
CheckOutResponse,
LotConsume,
LotConsumeOut,
LotOpen,
LotOut,
LotRow,
LotSplit,
LotUpdate,
RelocateRequest,
RemoveRequest,
StockActionResponse,
)
from ..services.conversion import ConversionError, display_unit_info
from ..services.dates import clean_precision, normalize_best_before
from ..services.stock import (
StockError,
abspalten,
anbrechen,
check_in,
check_out,
check_out_lot,
consolidate_lot_group,
current_stock,
object_add,
object_relocate,
object_remove,
verbrauchen,
)
router = APIRouter(tags=["stock"])
OBJECT = CategoryTracking.object.value
@router.post("/stock/checkin", response_model=CheckInResponse)
def stock_checkin(
payload: CheckInRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> CheckInResponse:
product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) == OBJECT:
# Gegenstände: Menge am Lagerort erhöhen, kein MHD, keine Charge-Auswahl.
lot = object_add(
db,
product=product,
quantity=payload.quantity,
location_id=payload.location_id,
user=user,
note=payload.note,
)
db.commit()
db.refresh(lot)
return CheckInResponse(
lot=LotOut.model_validate(lot), product_stock=current_stock(db, product.id)
)
try:
lot = check_in(
db,
product=product,
quantity=payload.quantity,
unit=payload.unit,
best_before=payload.best_before,
best_before_precision=payload.best_before_precision.value,
location_id=payload.location_id,
user=user,
note=payload.note,
)
except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
db.commit()
db.refresh(lot)
return CheckInResponse(
lot=LotOut.model_validate(lot), product_stock=current_stock(db, product.id)
)
@router.post("/stock/checkin/batch", response_model=BatchCheckInResponse)
def stock_checkin_batch(
payload: BatchCheckInRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> BatchCheckInResponse:
"""Mehrere Chargen desselben Produkts in einem Vorgang einlagern.
Jede Zeile erzeugt eine eigene Charge mit eigener Menge und eigenem MHD –
z.B. 5 Gläser mit unterschiedlichen Mindesthaltbarkeitsdaten.
"""
product = resolve_product(db, payload.product_id, payload.barcode)
lots: list[Lot] = []
try:
for line in payload.lines:
lots.append(
check_in(
db,
product=product,
quantity=line.quantity,
unit=payload.unit,
best_before=line.best_before,
best_before_precision=line.best_before_precision.value,
location_id=line.location_id,
user=user,
note=payload.note,
)
)
except ConversionError as exc:
db.rollback()
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
db.commit()
for lot in lots:
db.refresh(lot)
return BatchCheckInResponse(
lots=[LotOut.model_validate(lot) for lot in lots],
product_stock=current_stock(db, product.id),
)
@router.post("/stock/checkout", response_model=CheckOutResponse)
def stock_checkout(
payload: CheckOutRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> CheckOutResponse:
product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) == OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Gegenstände werden über „Entfernen“ (mit Grund) oder „Umlagern“ gebucht, "
"nicht ausgecheckt.",
)
try:
if payload.lot_id is not None:
lot = db.get(Lot, payload.lot_id)
if lot is None or lot.product_id != product.id:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Charge gehört nicht zu diesem Produkt"
)
affected = check_out_lot(
db,
product=product,
lot=lot,
quantity=payload.quantity,
unit=payload.unit,
user=user,
note=payload.note,
)
else:
affected = check_out(
db,
product=product,
quantity=payload.quantity,
unit=payload.unit,
user=user,
note=payload.note,
)
except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
except StockError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
db.commit()
return CheckOutResponse(
affected_lots=affected, product_stock=current_stock(db, product.id)
)
@router.post("/stock/relocate", response_model=StockActionResponse)
def stock_relocate(
payload: RelocateRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> StockActionResponse:
"""Gegenstands-Menge von einem Lagerort zum anderen umbuchen (ohne Grund)."""
product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) != OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Umlagern gibt es nur für Gegenstände."
)
try:
object_relocate(
db,
product=product,
quantity=payload.quantity,
from_location_id=payload.from_location_id,
to_location_id=payload.to_location_id,
user=user,
note=payload.note,
)
except StockError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
db.commit()
return StockActionResponse(product_stock=current_stock(db, product.id))
@router.post("/stock/remove", response_model=StockActionResponse)
def stock_remove(
payload: RemoveRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> StockActionResponse:
"""Gegenstands-Menge mit Pflicht-Grund aus dem Bestand entfernen."""
product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) != OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Entfernen mit Grund gibt es nur für Gegenstände.",
)
try:
object_remove(
db,
product=product,
quantity=payload.quantity,
location_id=payload.location_id,
reason=payload.reason.value,
user=user,
note=payload.note,
)
except StockError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
db.commit()
return StockActionResponse(product_stock=current_stock(db, product.id))
@router.get("/lots", response_model=list[LotOut])
def list_lots(
product_id: int | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[Lot]:
query = db.query(Lot)
if product_id is not None:
query = query.filter(Lot.product_id == product_id)
return query.order_by(Lot.best_before.is_(None), Lot.best_before).all()
@router.get("/lots/rows", response_model=list[LotRow])
def list_all_lots(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[LotRow]:
"""Chargen der Charge-Artikel (Lebensmittel + Verbrauchsgegenstand) über alle
Artikel – für die übergreifende Chargenliste. „Menge je Lagerort" und
Einzelstücke gehören nicht hierher und werden übersprungen. Der Artikel wird
eager geladen (kein N+1)."""
lots = (
db.query(Lot)
.options(joinedload(Lot.product).joinedload(Product.category))
.order_by(Lot.created_at.desc())
.all()
)
rows: list[LotRow] = []
for lot in lots:
p = lot.product
tracking = product_tracking(db, p)
# Nur Charge-Artikel: Lebensmittel (food) oder Verbrauchsgegenstand (bulk).
food_like = tracking != CategoryTracking.object.value or bool(p.bulk)
if not food_like:
continue
name, factor = display_unit_info(p)
rows.append(LotRow(
id=lot.id, product_id=p.id, product_name=p.name, product_brand=p.brand,
tracking=CategoryTracking(tracking),
quantity=lot.quantity, base_unit=p.base_unit,
package_size=p.package_size, package_label=p.package_label,
unit_name=name, unit_factor=factor,
best_before=lot.best_before, best_before_precision=lot.best_before_precision,
location_id=lot.location_id, created_at=lot.created_at,
# Angebrochen: ohne diese drei Felder saehe die Liste eine offene
# Packung wie jede andere Charge - samt falscher Ablaufwarnung,
# denn gewarnt wird nach dem fruheren der beiden Daten.
opened_on=lot.opened_on, consume_by=lot.consume_by,
opened_quantity=lot.opened_quantity,
))
return rows
@router.post("/lots/bulk-location", response_model=int)
def bulk_lot_location(
payload: BulkLotLocation,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> int:
"""Lagerort mehrerer Chargen auf einmal setzen (oder mit null leeren). Reine
Ortsangabe – kein Umbuchen mit Bewegung. Gibt die Anzahl geänderter Zeilen."""
if not payload.lot_ids:
return 0
if payload.location_id is not None and db.get(Location, payload.location_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
n = (
db.query(Lot)
.filter(Lot.id.in_(payload.lot_ids))
.update({Lot.location_id: payload.location_id}, synchronize_session=False)
)
db.flush()
# Am Zielort entstandene Dubletten (gleicher Artikel + MHD) zusammenfassen.
gruppen = (
db.query(Lot.product_id, Lot.best_before, Lot.best_before_precision)
.filter(Lot.id.in_(payload.lot_ids))
.distinct()
.all()
)
for product_id, best_before, precision in gruppen:
consolidate_lot_group(db, product_id, best_before, precision, payload.location_id)
db.commit()
return n
@router.post("/lots/bulk-checkout", response_model=int)
def bulk_lot_checkout(
payload: BulkLotCheckout,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> int:
"""Mehrere Chargen ausbuchen – komplett oder je Charge dieselbe Teilmenge.
Anders als ``bulk-location`` **kein** Sammel-UPDATE: Je Charge muss eine
Bewegung entstehen, sonst fehlte der Vorgang im Verlauf und in der
Verbrauchsstatistik. Schlägt eine Charge fehl (zu wenig Bestand), wird
alles zurückgerollt – ein halb gebuchter Stand wäre schlimmer als gar
keiner; dieselbe Haltung wie beim Stapel-Einlagern.
Gibt die Anzahl der betroffenen Chargen zurück.
"""
if not payload.lot_ids:
return 0
lots = db.query(Lot).filter(Lot.id.in_(payload.lot_ids)).all()
if not lots:
return 0
if payload.quantity is not None:
# Eine gemeinsame Menge setzt eine gemeinsame Einheit voraus: „1 Packung"
# bei Milch und Broetchen sind verschiedene Dinge.
artikel = {lot.product_id for lot in lots}
if len(artikel) > 1:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Eine gemeinsame Menge geht nur, wenn alle ausgewählten Chargen "
"zum selben Artikel gehören. Sonst bitte komplett ausbuchen.",
)
betroffen = 0
try:
for lot in lots:
product = db.get(Product, lot.product_id)
if product is None:
continue
menge = payload.quantity if payload.quantity is not None else lot.quantity
einheit = payload.unit if payload.quantity is not None else product.base_unit.value
check_out_lot(db, product, lot, menge, einheit, user, payload.note)
betroffen += 1
except ConversionError as fehler:
db.rollback()
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
except StockError as fehler:
db.rollback()
raise HTTPException(status.HTTP_409_CONFLICT, str(fehler)) from fehler
db.commit()
return betroffen
@router.post("/lots/bulk-delete", response_model=int)
def bulk_lot_delete(
payload: BulkLotDelete,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> int:
"""Mehrere Chargen als Korrektur entfernen – nicht als Verbrauch.
Je Charge eine Korrektur-Bewegung wie beim einzelnen Löschen, damit der
Bestandsverlauf stimmt. Unbekannte IDs werden still übergangen, wie bei
``bulk-location``.
"""
if not payload.lot_ids:
return 0
lots = db.query(Lot).filter(Lot.id.in_(payload.lot_ids)).all()
for lot in lots:
db.add(
Movement(
product_id=lot.product_id,
lot_id=None,
user_id=user.id,
type=MovementType.adjust,
quantity=-lot.quantity,
unit_used="base",
note="Charge gelöscht",
)
)
db.delete(lot)
db.commit()
return len(lots)
@router.post("/lots/{lot_id}/split", response_model=LotOut)
def split_lot(
lot_id: int,
payload: LotSplit,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Lot:
"""Charge aufteilen: `quantity` (Basiseinheiten) abzweigen und als neue Charge
mit gleichem MHD an einen anderen Lagerort legen. Der Rest bleibt in der alten
Charge. Reine Umbuchung wie das Setzen des Lagerorts – kein Bewegungseintrag,
der Gesamtbestand ändert sich nicht. Gibt die neu entstandene Charge zurück."""
lot = db.get(Lot, lot_id)
if lot is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
if payload.location_id is not None and db.get(Location, payload.location_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
# Die eigentliche Arbeit macht der Dienst; hier gilt weiter die strengere
# Variante: nur echt weniger als die Gesamtmenge, und nur an einen ANDEREN
# Ort. Das Anbrechen benutzt dieselbe Funktion mit gelockerten Regeln.
try:
neu = abspalten(db, lot, payload.quantity, payload.location_id)
except StockError as fehler:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
# Liegt am Zielort schon eine Charge mit gleichem MHD, verschmelzen beide.
survivor = consolidate_lot_group(
db, lot.product_id, neu.best_before, neu.best_before_precision, payload.location_id
)
db.commit()
db.refresh(survivor)
return survivor
@router.post("/lots/{lot_id}/open", response_model=LotOut)
def open_lot(
lot_id: int,
payload: LotOpen,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Lot:
"""Eine Packung der Charge anbrechen und als eigene Charge führen.
Aus 4 × 1 l wird eine offene Charge mit 1 l (mit Verbrauchsdatum) und eine
ungeöffnete mit 3 l. Bewusst **ohne** anschließende Konsolidierung: Die
offene Packung soll gerade nicht mit der ungeöffneten verschmelzen.
"""
lot = db.get(Lot, lot_id)
if lot is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
product = db.get(Product, lot.product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
if product_tracking(db, product) == OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Nur Chargen lassen sich anbrechen. Gegenstände, die nur als Menge "
"je Lagerort geführt werden, haben keine Packung.",
)
try:
offen = anbrechen(db, product, lot, payload.quantity)
except StockError as fehler:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
db.commit()
db.refresh(offen)
return offen
@router.post("/lots/{lot_id}/consume", response_model=LotConsumeOut)
def consume_lot(
lot_id: int,
payload: LotConsume,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> LotConsumeOut:
"""Teilmenge aus einer Charge verbrauchen („100 ml verwendet", „2 EL").
War die Charge noch zu, wird sie dabei angebrochen (``auto_open``) – die
Rückfrage dazu stellt die Oberfläche. Die Einheit kennt neben den
verwalteten Einheiten auch die Hausmaße der Kategorie.
"""
lot = db.get(Lot, lot_id)
if lot is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
product = db.get(Product, lot.product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
if product_tracking(db, product) == OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Gegenstände werden über 'Entfernen' (mit Grund) gebucht, nicht verbraucht.",
)
try:
affected, offen, geoeffnet = verbrauchen(
db, product, lot, payload.quantity, payload.unit, user,
payload.note, payload.auto_open,
)
except ConversionError as fehler:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
except StockError as fehler:
raise HTTPException(status.HTTP_409_CONFLICT, str(fehler)) from fehler
db.commit()
if offen is not None:
db.refresh(offen)
return LotConsumeOut(
affected_lots=affected,
lot=LotOut.model_validate(offen) if offen is not None else None,
opened=geoeffnet,
)
@router.patch("/lots/{lot_id}", response_model=LotOut)
def update_lot(
lot_id: int,
payload: LotUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> Lot:
"""Korrigiert eine Charge (Menge in Basiseinheiten, MHD, Lagerort)."""
lot = db.get(Lot, lot_id)
if lot is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
data = payload.model_dump(exclude_unset=True)
# MHD und Genauigkeit hängen zusammen: Wird eines von beiden angefasst, muss
# der gespeicherte Tag neu bestimmt werden (Monatsangabe = Monatsletzter).
if "best_before" in data or "best_before_precision" in data:
precision = (
clean_precision(data["best_before_precision"])
if "best_before_precision" in data
else lot.best_before_precision
)
best_before = data["best_before"] if "best_before" in data else lot.best_before
data["best_before"] = normalize_best_before(best_before, precision)
data["best_before_precision"] = precision
old_quantity = lot.quantity
for field, value in data.items():
setattr(lot, field, value)
delta = lot.quantity - old_quantity
if abs(delta) > 1e-9:
db.add(
Movement(
product_id=lot.product_id,
lot_id=lot.id,
user_id=user.id,
type=MovementType.adjust,
quantity=delta,
unit_used="base",
note="Charge korrigiert",
)
)
db.flush()
# Ist die Charge durch die Änderung (Lagerort/MHD) zur Dublette einer anderen
# geworden, werden beide zusammengefasst.
survivor = consolidate_lot_group(
db, lot.product_id, lot.best_before, lot.best_before_precision, lot.location_id
)
db.commit()
db.refresh(survivor)
return survivor
@router.delete("/lots/{lot_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_lot(
lot_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
"""Entfernt eine Charge komplett aus dem Bestand."""
lot = db.get(Lot, lot_id)
if lot is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
db.add(
Movement(
product_id=lot.product_id,
lot_id=None,
user_id=user.id,
type=MovementType.adjust,
quantity=-lot.quantity,
unit_used="base",
note="Charge gelöscht",
)
)
db.delete(lot)
db.commit()