Einheiten (neu):
- Tabelle units (name, kind=count|weight|volume, factor, is_builtin); eingebaut
Stueck/Gramm/Kilogramm/Milliliter/Liter, Admin kann eigene anlegen (z.B. Pfund=500g).
- Bestaende bleiben intern in kanonischer Basis (Stueck/Gramm/Milliliter);
Product.display_unit_id und Group.min_stock_unit_id als nullable FKs.
- Neuer Umrechnungs-Service (services/conversion.py) ersetzt die feste Einheitenlogik;
Ein-/Auslagern und Gruppen-Mindestbestand rechnen ueber den Faktor.
- Gruppen-Mindestbestand mit Einheit; Gruppenbestand summiert nur Produkte
passender Art. Neue Verwaltungsseite "Einheiten" (Admin).
- Schonende Migration beim Start: ADD COLUMN IF NOT EXISTS (Postgres), damit
bestehende Installationen ihre Daten behalten.
Chargen:
- PATCH /lots/{id} und DELETE /lots/{id}: Menge/MHD korrigieren, Charge loeschen
(wird als Korrektur-Bewegung protokolliert). Bearbeitung im Produktdetail.
Auslagern:
- Optionales lot_id: gezielt aus einer bestimmten Charge/MHD abbuchen statt FEFO;
Auswahl-Dropdown in der Auslagern-Seite.
Sonstiges: Roadmap aktualisiert, Tests fuer die Umrechnung.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
161 lines
4.4 KiB
Python
161 lines
4.4 KiB
Python
"""Chargen-Logik: Einlagern erzeugt Lots, Auslagern bucht per FEFO ab."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
from sqlalchemy import asc
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..models import Lot, Movement, MovementType, Product, User
|
|
from .conversion import to_base
|
|
|
|
|
|
class StockError(ValueError):
|
|
"""Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand)."""
|
|
|
|
|
|
def current_stock(db: Session, product_id: int) -> float:
|
|
"""Summe der Lot-Mengen eines Produkts (in Basiseinheiten)."""
|
|
total = (
|
|
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
|
|
)
|
|
return float(sum(q for (q,) in total))
|
|
|
|
|
|
def check_in(
|
|
db: Session,
|
|
product: Product,
|
|
quantity: float,
|
|
unit: str,
|
|
best_before: date | None,
|
|
location_id: int | None,
|
|
user: User | None,
|
|
note: str | None = None,
|
|
) -> Lot:
|
|
"""Legt eine neue Charge an und protokolliert die Bewegung."""
|
|
quantity_base = to_base(db, product, quantity, unit)
|
|
|
|
lot = Lot(
|
|
product_id=product.id,
|
|
quantity=quantity_base,
|
|
best_before=best_before,
|
|
location_id=location_id,
|
|
)
|
|
db.add(lot)
|
|
db.flush() # lot.id verfügbar machen
|
|
|
|
db.add(
|
|
Movement(
|
|
product_id=product.id,
|
|
lot_id=lot.id,
|
|
user_id=user.id if user else None,
|
|
type=MovementType.in_,
|
|
quantity=quantity_base,
|
|
unit_used=unit,
|
|
note=note,
|
|
)
|
|
)
|
|
return lot
|
|
|
|
|
|
def check_out_lot(
|
|
db: Session,
|
|
product: Product,
|
|
lot: Lot,
|
|
quantity: float,
|
|
unit: str,
|
|
user: User | None,
|
|
note: str | None = None,
|
|
) -> list[dict]:
|
|
"""Bucht gezielt von EINER Charge ab (manuelle Auswahl statt FEFO)."""
|
|
needed = to_base(db, product, quantity, unit)
|
|
if needed > lot.quantity + 1e-9:
|
|
raise StockError(
|
|
f"Diese Charge hat nur {lot.quantity:g} {product.base_unit.value} "
|
|
f"(benötigt {needed:g})."
|
|
)
|
|
|
|
lot.quantity -= needed
|
|
db.add(
|
|
Movement(
|
|
product_id=product.id,
|
|
lot_id=lot.id,
|
|
user_id=user.id if user else None,
|
|
type=MovementType.out,
|
|
quantity=needed,
|
|
unit_used=unit,
|
|
note=note,
|
|
)
|
|
)
|
|
affected = [{"lot_id": lot.id, "quantity": needed}]
|
|
if lot.quantity <= 1e-9:
|
|
db.delete(lot)
|
|
return affected
|
|
|
|
|
|
def _fefo_lots(db: Session, product_id: int) -> list[Lot]:
|
|
"""Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter."""
|
|
lots = (
|
|
db.query(Lot)
|
|
.filter(Lot.product_id == product_id, Lot.quantity > 0)
|
|
.order_by(asc(Lot.created_at))
|
|
.all()
|
|
)
|
|
# NULL-best_before ans Ende (nach Datum aufsteigend). In Python sortieren, damit
|
|
# es über SQLite und Postgres identisch funktioniert.
|
|
return sorted(
|
|
lots,
|
|
key=lambda lot: (lot.best_before is None, lot.best_before or date.max, lot.id),
|
|
)
|
|
|
|
|
|
def check_out(
|
|
db: Session,
|
|
product: Product,
|
|
quantity: float,
|
|
unit: str,
|
|
user: User | None,
|
|
note: str | None = None,
|
|
) -> list[dict]:
|
|
"""Bucht ``quantity`` (in ``unit``) per FEFO von den Chargen ab.
|
|
|
|
Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück.
|
|
Wirft StockError, wenn der Gesamtbestand nicht ausreicht.
|
|
"""
|
|
needed = to_base(db, product, quantity, unit)
|
|
available = current_stock(db, product.id)
|
|
if needed > available + 1e-9:
|
|
raise StockError(
|
|
f"Nicht genug Bestand: benötigt {needed:g}, verfügbar {available:g} "
|
|
f"{product.base_unit.value}"
|
|
)
|
|
|
|
affected: list[dict] = []
|
|
remaining = needed
|
|
for lot in _fefo_lots(db, product.id):
|
|
if remaining <= 1e-9:
|
|
break
|
|
take = min(lot.quantity, remaining)
|
|
lot.quantity -= take
|
|
remaining -= take
|
|
|
|
db.add(
|
|
Movement(
|
|
product_id=product.id,
|
|
lot_id=lot.id,
|
|
user_id=user.id if user else None,
|
|
type=MovementType.out,
|
|
quantity=take,
|
|
unit_used=unit,
|
|
note=note,
|
|
)
|
|
)
|
|
affected.append({"lot_id": lot.id, "quantity": take})
|
|
|
|
# Leere Charge entfernen, damit das "MHD-Array" sauber bleibt.
|
|
if lot.quantity <= 1e-9:
|
|
db.delete(lot)
|
|
|
|
return affected
|