Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln
Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende: Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich ueber Open Products Facts. Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,8 +10,17 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Category, Product, User
|
||||
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate
|
||||
from ..models import (
|
||||
Category,
|
||||
CategoryTracking,
|
||||
FieldDefinition,
|
||||
Product,
|
||||
ProductFieldValue,
|
||||
User,
|
||||
)
|
||||
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut
|
||||
from ..services.fields import effective_field_definitions
|
||||
from .field_definitions import to_out as field_to_out
|
||||
|
||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||
|
||||
@@ -61,9 +70,19 @@ def create_category(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> CategoryOut:
|
||||
if payload.parent_id is not None and db.get(Category, payload.parent_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden")
|
||||
category = Category(name=payload.name, parent_id=payload.parent_id)
|
||||
parent = None
|
||||
if payload.parent_id is not None:
|
||||
parent = db.get(Category, payload.parent_id)
|
||||
if parent is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden")
|
||||
# Modus: ausdrücklich gewählt > vom Elternteil geerbt > neue Oberkategorie = Gegenstand.
|
||||
if payload.tracking is not None:
|
||||
tracking = payload.tracking.value
|
||||
elif parent is not None:
|
||||
tracking = parent.tracking
|
||||
else:
|
||||
tracking = CategoryTracking.object.value
|
||||
category = Category(name=payload.name, parent_id=payload.parent_id, tracking=tracking)
|
||||
db.add(category)
|
||||
db.commit()
|
||||
db.refresh(category)
|
||||
@@ -95,6 +114,9 @@ def update_category(
|
||||
"Unterkategorien untergeordnet werden.",
|
||||
)
|
||||
|
||||
# Modus als kurzen String ablegen (Spalte ist String, nicht Enum-Typ).
|
||||
if "tracking" in data and data["tracking"] is not None:
|
||||
data["tracking"] = CategoryTracking(data["tracking"]).value
|
||||
for field, value in data.items():
|
||||
setattr(category, field, value)
|
||||
db.commit()
|
||||
@@ -120,6 +142,35 @@ def delete_category(
|
||||
db.query(Category).filter(Category.parent_id == category_id).update(
|
||||
{Category.parent_id: None}
|
||||
)
|
||||
# Eigene Felder dieser Kategorie samt aller erfassten Werte entfernen.
|
||||
feld_ids = [
|
||||
fid
|
||||
for (fid,) in db.query(FieldDefinition.id)
|
||||
.filter(FieldDefinition.category_id == category_id)
|
||||
.all()
|
||||
]
|
||||
if feld_ids:
|
||||
db.query(ProductFieldValue).filter(
|
||||
ProductFieldValue.field_definition_id.in_(feld_ids)
|
||||
).delete(synchronize_session=False)
|
||||
db.query(FieldDefinition).filter(
|
||||
FieldDefinition.category_id == category_id
|
||||
).delete(synchronize_session=False)
|
||||
db.delete(category)
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/{category_id}/fields", response_model=list[FieldDefinitionOut])
|
||||
def category_fields(
|
||||
category_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[FieldDefinitionOut]:
|
||||
"""Alle für eine Kategorie geltenden Felder – inkl. der von Oberkategorien geerbten."""
|
||||
if db.get(Category, category_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
|
||||
return [
|
||||
field_to_out(fd, inherited=inherited)
|
||||
for fd, inherited in effective_field_definitions(db, category_id)
|
||||
]
|
||||
|
||||
143
backend/app/routers/field_definitions.py
Normal file
143
backend/app/routers/field_definitions.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Verwaltung der selbst definierten Felder je Kategorie."""
|
||||
|
||||
import json
|
||||
|
||||
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 Category, FieldDefinition, FieldType, ProductFieldValue, User
|
||||
from ..schemas import FieldDefinitionCreate, FieldDefinitionOut, FieldDefinitionUpdate
|
||||
from ..services.fields import options_list, slugify
|
||||
|
||||
router = APIRouter(prefix="/field-definitions", tags=["field-definitions"])
|
||||
|
||||
|
||||
def to_out(fd: FieldDefinition, inherited: bool = False) -> FieldDefinitionOut:
|
||||
return FieldDefinitionOut(
|
||||
id=fd.id,
|
||||
category_id=fd.category_id,
|
||||
label=fd.label,
|
||||
key=fd.key,
|
||||
field_type=FieldType(fd.field_type),
|
||||
unit=fd.unit,
|
||||
options=options_list(fd),
|
||||
required=fd.required,
|
||||
position=fd.position,
|
||||
is_builtin=fd.is_builtin,
|
||||
inherited=inherited,
|
||||
)
|
||||
|
||||
|
||||
def _unique_key(db: Session, category_id: int, label: str, exclude_id: int | None = None) -> str:
|
||||
"""Eindeutigen Schlüssel je Kategorie erzeugen (kapazitaet, kapazitaet_2, …)."""
|
||||
basis = slugify(label)
|
||||
kandidat = basis
|
||||
n = 1
|
||||
while True:
|
||||
query = db.query(FieldDefinition).filter(
|
||||
FieldDefinition.category_id == category_id, FieldDefinition.key == kandidat
|
||||
)
|
||||
if exclude_id is not None:
|
||||
query = query.filter(FieldDefinition.id != exclude_id)
|
||||
if query.first() is None:
|
||||
return kandidat
|
||||
n += 1
|
||||
kandidat = f"{basis}_{n}"
|
||||
|
||||
|
||||
def _options_json(field_type: FieldType, options: list[str] | None) -> str | None:
|
||||
if field_type != FieldType.select:
|
||||
return None
|
||||
return json.dumps([o.strip() for o in (options or []) if o.strip()])
|
||||
|
||||
|
||||
@router.get("", response_model=list[FieldDefinitionOut])
|
||||
def list_field_definitions(
|
||||
category_id: int | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[FieldDefinitionOut]:
|
||||
"""Felder auflisten – ohne category_id alle, sonst nur die dieser Kategorie."""
|
||||
query = db.query(FieldDefinition)
|
||||
if category_id is not None:
|
||||
query = query.filter(FieldDefinition.category_id == category_id)
|
||||
rows = query.order_by(
|
||||
FieldDefinition.category_id, FieldDefinition.position, FieldDefinition.id
|
||||
).all()
|
||||
return [to_out(fd) for fd in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=FieldDefinitionOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_field_definition(
|
||||
payload: FieldDefinitionCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> FieldDefinitionOut:
|
||||
if db.get(Category, payload.category_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
|
||||
fd = FieldDefinition(
|
||||
category_id=payload.category_id,
|
||||
label=payload.label.strip(),
|
||||
key=_unique_key(db, payload.category_id, payload.label),
|
||||
field_type=payload.field_type.value,
|
||||
unit=(payload.unit or None),
|
||||
options=_options_json(payload.field_type, payload.options),
|
||||
required=bool(payload.required),
|
||||
position=payload.position,
|
||||
)
|
||||
db.add(fd)
|
||||
db.commit()
|
||||
db.refresh(fd)
|
||||
return to_out(fd)
|
||||
|
||||
|
||||
@router.patch("/{field_id}", response_model=FieldDefinitionOut)
|
||||
def update_field_definition(
|
||||
field_id: int,
|
||||
payload: FieldDefinitionUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> FieldDefinitionOut:
|
||||
fd = db.get(FieldDefinition, field_id)
|
||||
if fd is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Feld nicht gefunden")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "label" in data and data["label"]:
|
||||
fd.label = data["label"].strip()
|
||||
fd.key = _unique_key(db, fd.category_id, fd.label, exclude_id=fd.id)
|
||||
if "field_type" in data and data["field_type"] is not None:
|
||||
fd.field_type = data["field_type"].value
|
||||
if "unit" in data:
|
||||
fd.unit = data["unit"] or None
|
||||
if "required" in data and data["required"] is not None:
|
||||
fd.required = bool(data["required"])
|
||||
if "position" in data and data["position"] is not None:
|
||||
fd.position = data["position"]
|
||||
# Optionen immer passend zum (ggf. neuen) Typ ablegen.
|
||||
if "options" in data or "field_type" in data:
|
||||
neuer_typ = FieldType(fd.field_type)
|
||||
optionen = data["options"] if "options" in data else options_list(fd)
|
||||
fd.options = _options_json(neuer_typ, optionen)
|
||||
db.commit()
|
||||
db.refresh(fd)
|
||||
return to_out(fd)
|
||||
|
||||
|
||||
@router.delete("/{field_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_field_definition(
|
||||
field_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Löscht das Feld samt aller dazu erfassten Werte (ON DELETE CASCADE)."""
|
||||
fd = db.get(FieldDefinition, field_id)
|
||||
if fd is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Feld nicht gefunden")
|
||||
db.query(ProductFieldValue).filter(
|
||||
ProductFieldValue.field_definition_id == field_id
|
||||
).delete()
|
||||
db.delete(fd)
|
||||
db.commit()
|
||||
@@ -2,17 +2,41 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..crud import product_to_out
|
||||
from ..crud import product_to_out, product_tracking
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Barcode, BaseUnit, Category, Group, Product, ProductImage, User
|
||||
from ..models import (
|
||||
Barcode,
|
||||
BaseUnit,
|
||||
Category,
|
||||
Group,
|
||||
Location,
|
||||
Movement,
|
||||
MovementType,
|
||||
Product,
|
||||
ProductImage,
|
||||
RemovalReason,
|
||||
Shop,
|
||||
User,
|
||||
)
|
||||
from ..off import lookup_barcode
|
||||
from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate
|
||||
from ..schemas import (
|
||||
BarcodeCreate,
|
||||
LookupResult,
|
||||
ProductCreate,
|
||||
ProductOut,
|
||||
ProductUpdate,
|
||||
RemovalHistoryItem,
|
||||
RemovalStat,
|
||||
RemovalSummary,
|
||||
)
|
||||
from ..services import images
|
||||
from ..services.categories import suggest_category
|
||||
from .categories import descendant_ids
|
||||
from ..services.conversion import ConversionError, resolve_product_unit
|
||||
from ..services.fields import FieldError, apply_field_values
|
||||
from ..services.group_codes import detach as detach_group_code, sync as sync_group_code
|
||||
from ..services.stock import removal_stats
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["products"])
|
||||
|
||||
@@ -92,6 +116,56 @@ def get_product(
|
||||
return product_to_out(db, product)
|
||||
|
||||
|
||||
@router.get("/{product_id}/removals", response_model=RemovalSummary)
|
||||
def product_removals(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> RemovalSummary:
|
||||
"""Entnahmen mit Grund: Summe je Grund plus die jüngsten Einträge."""
|
||||
product = db.get(Product, product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||
|
||||
stats = [
|
||||
RemovalStat(reason=RemovalReason(grund), quantity=v["quantity"], count=v["count"])
|
||||
for grund, v in removal_stats(db, product_id).items()
|
||||
]
|
||||
stats.sort(key=lambda s: s.quantity, reverse=True)
|
||||
|
||||
rows = (
|
||||
db.query(Movement)
|
||||
.filter(
|
||||
Movement.product_id == product_id,
|
||||
Movement.type == MovementType.out,
|
||||
Movement.reason.isnot(None),
|
||||
)
|
||||
.order_by(Movement.created_at.desc())
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
loc_names = {loc.id: loc.name for loc in db.query(Location).all()}
|
||||
user_ids = {m.user_id for m in rows if m.user_id}
|
||||
users = (
|
||||
{u.id: u.username for u in db.query(User).filter(User.id.in_(user_ids)).all()}
|
||||
if user_ids
|
||||
else {}
|
||||
)
|
||||
history = [
|
||||
RemovalHistoryItem(
|
||||
reason=RemovalReason(m.reason),
|
||||
quantity=m.quantity,
|
||||
location_id=m.location_id,
|
||||
location_name=loc_names.get(m.location_id),
|
||||
note=m.note,
|
||||
username=users.get(m.user_id),
|
||||
created_at=m.created_at,
|
||||
)
|
||||
for m in rows
|
||||
]
|
||||
return RemovalSummary(stats=stats, history=history)
|
||||
|
||||
|
||||
@router.get("/{product_id}/off", response_model=LookupResult)
|
||||
def off_vergleich(
|
||||
product_id: int,
|
||||
@@ -123,8 +197,10 @@ def off_vergleich(
|
||||
"Dieser Artikel hat keinen Barcode – ohne den kann Open Food Facts nichts finden.",
|
||||
)
|
||||
|
||||
# Gegenstände zuerst in der allgemeinen Produkt-DB nachschlagen, Lebensmittel in OFF.
|
||||
prefer = "object" if product_tracking(db, product) == "object" else None
|
||||
for code in codes:
|
||||
suggestion = lookup_barcode(code)
|
||||
suggestion = lookup_barcode(code, prefer=prefer)
|
||||
if suggestion:
|
||||
category = suggest_category(db, suggestion)
|
||||
return LookupResult(
|
||||
@@ -183,6 +259,8 @@ def create_product(
|
||||
base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id)
|
||||
except ConversionError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
if payload.shop_id is not None and db.get(Shop, payload.shop_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||
product = Product(
|
||||
barcode=payload.barcode or None,
|
||||
name=payload.name,
|
||||
@@ -198,11 +276,17 @@ def create_product(
|
||||
min_stock=payload.min_stock,
|
||||
min_stock_unit_id=payload.min_stock_unit_id,
|
||||
min_stock_in_packages=bool(payload.min_stock_in_packages),
|
||||
shop_id=payload.shop_id,
|
||||
product_url=payload.product_url or None,
|
||||
source="manual",
|
||||
)
|
||||
db.add(product)
|
||||
db.flush() # product.id fuer den Gruppen-Code
|
||||
sync_group_code(db, product)
|
||||
try:
|
||||
apply_field_values(db, product, payload.field_values)
|
||||
except FieldError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
if product.image_url:
|
||||
# Gleich beim Anlegen holen. Schlaegt es fehl, entsteht kein Fehler:
|
||||
# Ein fehlendes Bild darf das Anlegen eines Artikels nicht verhindern.
|
||||
@@ -224,6 +308,8 @@ def update_product(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
# Feldwerte sind keine Spalte, sondern eigene Zeilen – getrennt behandeln.
|
||||
field_values = data.pop("field_values", None)
|
||||
if "barcode" in data and data["barcode"]:
|
||||
clash = (
|
||||
db.query(Product)
|
||||
@@ -234,6 +320,8 @@ def update_product(
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits"
|
||||
)
|
||||
if data.get("shop_id") is not None and db.get(Shop, data["shop_id"]) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||
# Einheit: unit_id (falls gesetzt) bestimmt base_unit + Anzeigeeinheit.
|
||||
if "unit_id" in data:
|
||||
unit_id = data.pop("unit_id")
|
||||
@@ -253,6 +341,11 @@ def update_product(
|
||||
data["date_precision"] = data["date_precision"].value
|
||||
for field, value in data.items():
|
||||
setattr(product, field, value)
|
||||
if field_values is not None:
|
||||
try:
|
||||
apply_field_values(db, product, field_values)
|
||||
except FieldError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
# Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen.
|
||||
sync_group_code(db, product)
|
||||
if "image_url" in data:
|
||||
|
||||
88
backend/app/routers/shops.py
Normal file
88
backend/app/routers/shops.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Shops / Bezugsquellen: verwaltbare Liste für „gekauft bei“ (nur Gegenstände)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Product, Shop, User
|
||||
from ..schemas import ShopCreate, ShopOut, ShopUpdate
|
||||
|
||||
router = APIRouter(prefix="/shops", tags=["shops"])
|
||||
|
||||
|
||||
def _to_out(db: Session, shop: Shop) -> ShopOut:
|
||||
out = ShopOut.model_validate(shop)
|
||||
out.product_count = db.query(Product).filter(Product.shop_id == shop.id).count()
|
||||
return out
|
||||
|
||||
|
||||
@router.get("", response_model=list[ShopOut])
|
||||
def list_shops(
|
||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||
) -> list[ShopOut]:
|
||||
return [_to_out(db, s) for s in db.query(Shop).order_by(Shop.name).all()]
|
||||
|
||||
|
||||
@router.post("", response_model=ShopOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_shop(
|
||||
payload: ShopCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> ShopOut:
|
||||
name = payload.name.strip()
|
||||
if db.query(Shop).filter(func.lower(Shop.name) == name.lower()).first():
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Shop gibt es bereits")
|
||||
shop = Shop(name=name, website=(payload.website or None))
|
||||
db.add(shop)
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
return _to_out(db, shop)
|
||||
|
||||
|
||||
@router.patch("/{shop_id}", response_model=ShopOut)
|
||||
def update_shop(
|
||||
shop_id: int,
|
||||
payload: ShopUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> ShopOut:
|
||||
shop = db.get(Shop, shop_id)
|
||||
if shop is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "name" in data and data["name"]:
|
||||
name = data["name"].strip()
|
||||
doppelt = (
|
||||
db.query(Shop)
|
||||
.filter(func.lower(Shop.name) == name.lower(), Shop.id != shop_id)
|
||||
.first()
|
||||
)
|
||||
if doppelt is not None:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Shop gibt es bereits")
|
||||
shop.name = name
|
||||
if "website" in data:
|
||||
shop.website = data["website"] or None
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
return _to_out(db, shop)
|
||||
|
||||
|
||||
@router.delete("/{shop_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_shop(
|
||||
shop_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Artikel bleiben bestehen, sie verlieren nur die Bezugsquelle (SET NULL)."""
|
||||
shop = db.get(Shop, shop_id)
|
||||
if shop is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||
# SQLite setzt Fremdschlüssel nicht ohne Weiteres um, deshalb ausdrücklich.
|
||||
db.query(Product).filter(Product.shop_id == shop_id).update(
|
||||
{Product.shop_id: None}
|
||||
)
|
||||
db.delete(shop)
|
||||
db.commit()
|
||||
@@ -1,10 +1,10 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..crud import resolve_product
|
||||
from ..crud import product_tracking, resolve_product
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import Lot, Movement, MovementType, User
|
||||
from ..models import CategoryTracking, Lot, Movement, MovementType, User
|
||||
from ..schemas import (
|
||||
BatchCheckInRequest,
|
||||
BatchCheckInResponse,
|
||||
@@ -14,6 +14,9 @@ from ..schemas import (
|
||||
CheckOutResponse,
|
||||
LotOut,
|
||||
LotUpdate,
|
||||
RelocateRequest,
|
||||
RemoveRequest,
|
||||
StockActionResponse,
|
||||
)
|
||||
from ..services.conversion import ConversionError
|
||||
from ..services.dates import clean_precision, normalize_best_before
|
||||
@@ -23,10 +26,15 @@ from ..services.stock import (
|
||||
check_out,
|
||||
check_out_lot,
|
||||
current_stock,
|
||||
object_add,
|
||||
object_relocate,
|
||||
object_remove,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["stock"])
|
||||
|
||||
OBJECT = CategoryTracking.object.value
|
||||
|
||||
|
||||
@router.post("/stock/checkin", response_model=CheckInResponse)
|
||||
def stock_checkin(
|
||||
@@ -35,6 +43,21 @@ def stock_checkin(
|
||||
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,
|
||||
@@ -103,6 +126,12 @@ def stock_checkout(
|
||||
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)
|
||||
@@ -138,6 +167,63 @@ def stock_checkout(
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
|
||||
@@ -22,18 +22,28 @@ from ..services.group_codes import sync as sync_group_code
|
||||
from ..models import (
|
||||
BaseUnit,
|
||||
Category,
|
||||
CategoryTracking,
|
||||
DatePrecision,
|
||||
FieldDefinition,
|
||||
Group,
|
||||
Location,
|
||||
Lot,
|
||||
Movement,
|
||||
MovementType,
|
||||
Product,
|
||||
Shop,
|
||||
Unit,
|
||||
UnitKind,
|
||||
User,
|
||||
)
|
||||
from ..services.conversion import BASE_OF_KIND, display_unit_info, find_unit
|
||||
from ..services.fields import (
|
||||
FieldError,
|
||||
apply_field_values,
|
||||
effective_field_definitions,
|
||||
options_list,
|
||||
slugify,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["transfer"])
|
||||
|
||||
@@ -128,7 +138,7 @@ def export_backup_json(
|
||||
loc_name = {loc.id: loc.name for loc in locations}
|
||||
|
||||
data = {
|
||||
"version": 1,
|
||||
"version": 2,
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"exported_at_local": datetime.now().isoformat(timespec="seconds"),
|
||||
"units": [
|
||||
@@ -147,6 +157,32 @@ def export_backup_json(
|
||||
{"name": loc.name, "parent": loc_name.get(loc.parent_id)}
|
||||
for loc in locations
|
||||
],
|
||||
# Kategorien mit Verwaltungsart, damit Lebensmittel/Gegenstände beim
|
||||
# Wiederherstellen erhalten bleiben (auch leere Kategorien).
|
||||
"categories": [
|
||||
{"path": _category_path(db, c), "tracking": c.tracking}
|
||||
for c in db.query(Category).order_by(Category.id).all()
|
||||
],
|
||||
# Bezugsquellen (nur für Gegenstände).
|
||||
"shops": [
|
||||
{"name": s.name, "website": s.website}
|
||||
for s in db.query(Shop).order_by(Shop.id).all()
|
||||
],
|
||||
# Selbst definierte Felder je Kategorie.
|
||||
"field_definitions": [
|
||||
{
|
||||
"category": _category_path(db, db.get(Category, fd.category_id)),
|
||||
"label": fd.label,
|
||||
"field_type": fd.field_type,
|
||||
"unit": fd.unit,
|
||||
"options": options_list(fd),
|
||||
"required": fd.required,
|
||||
"position": fd.position,
|
||||
}
|
||||
for fd in db.query(FieldDefinition)
|
||||
.order_by(FieldDefinition.category_id, FieldDefinition.position, FieldDefinition.id)
|
||||
.all()
|
||||
],
|
||||
"products": [],
|
||||
}
|
||||
|
||||
@@ -168,6 +204,12 @@ def export_backup_json(
|
||||
"min_stock": p.min_stock,
|
||||
"min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None,
|
||||
"min_stock_in_packages": bool(p.min_stock_in_packages),
|
||||
# Gegenstands-Felder:
|
||||
"shop": p.shop.name if p.shop else None,
|
||||
"product_url": p.product_url,
|
||||
"field_values": {
|
||||
pfv.field_definition.label: pfv.value for pfv in p.field_values
|
||||
},
|
||||
"lots": [
|
||||
{
|
||||
"quantity": lot.quantity,
|
||||
@@ -307,8 +349,16 @@ def _category_path(db: Session, category: Category | None) -> str:
|
||||
return " > ".join(reversed(teile))
|
||||
|
||||
|
||||
def _get_or_create_category(db: Session, path: str | None) -> Category | None:
|
||||
"""Legt den ganzen Pfad an, falls Teile davon fehlen."""
|
||||
def _get_or_create_category(
|
||||
db: Session, path: str | None, tracking: str = CategoryTracking.food.value
|
||||
) -> Category | None:
|
||||
"""Legt den ganzen Pfad an, falls Teile davon fehlen.
|
||||
|
||||
Importierte Kategorien sind standardmäßig „food": Backups stammen aus der
|
||||
Lebensmittel-Ausgabe, und so verhalten sich wiederhergestellte Artikel wie
|
||||
zuvor. Der Modus aus einem neueren Backup (Liste ``categories``) überschreibt
|
||||
das anschließend.
|
||||
"""
|
||||
path = (path or "").strip()
|
||||
if not path:
|
||||
return None
|
||||
@@ -320,13 +370,29 @@ def _get_or_create_category(db: Session, path: str | None) -> Category | None:
|
||||
)
|
||||
node = query.first()
|
||||
if node is None:
|
||||
node = Category(name=name, parent_id=parent.id if parent else None)
|
||||
node = Category(
|
||||
name=name,
|
||||
parent_id=parent.id if parent else None,
|
||||
tracking=tracking,
|
||||
)
|
||||
db.add(node)
|
||||
db.flush()
|
||||
parent = node
|
||||
return parent
|
||||
|
||||
|
||||
def _get_or_create_shop(db: Session, name: str | None, website: str | None = None) -> Shop | None:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
shop = db.query(Shop).filter(Shop.name == name).first()
|
||||
if shop is None:
|
||||
shop = Shop(name=name, website=(website or None))
|
||||
db.add(shop)
|
||||
db.flush()
|
||||
return shop
|
||||
|
||||
|
||||
def _get_or_create_group(db: Session, name: str | None) -> Group | None:
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
@@ -524,6 +590,42 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
|
||||
if child and parent and child.parent_id is None and child.id != parent.id:
|
||||
child.parent_id = parent.id
|
||||
|
||||
# Kategorien samt Verwaltungsart (Backup v2). Ältere Backups ohne diese Liste
|
||||
# legen ihre Kategorien weiter über die Produktpfade an (Standard: food).
|
||||
for entry in data.get("categories", []):
|
||||
cat = _get_or_create_category(
|
||||
db,
|
||||
entry.get("path"),
|
||||
tracking=entry.get("tracking") or CategoryTracking.food.value,
|
||||
)
|
||||
if cat is not None and entry.get("tracking"):
|
||||
cat.tracking = entry["tracking"]
|
||||
for entry in data.get("shops", []):
|
||||
_get_or_create_shop(db, entry.get("name"), entry.get("website"))
|
||||
db.flush()
|
||||
for entry in data.get("field_definitions", []):
|
||||
cat = _get_or_create_category(db, entry.get("category"))
|
||||
label = (entry.get("label") or "").strip()
|
||||
if cat is None or not label:
|
||||
continue
|
||||
if db.query(FieldDefinition).filter_by(category_id=cat.id, label=label).first():
|
||||
continue
|
||||
ftype = entry.get("field_type") or "text"
|
||||
optionen = entry.get("options") or []
|
||||
db.add(
|
||||
FieldDefinition(
|
||||
category_id=cat.id,
|
||||
label=label,
|
||||
key=slugify(label),
|
||||
field_type=ftype,
|
||||
unit=(entry.get("unit") or None),
|
||||
options=json.dumps(optionen) if ftype == "select" and optionen else None,
|
||||
required=bool(entry.get("required")),
|
||||
position=int(entry.get("position") or 0),
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
for entry in data.get("products", []):
|
||||
try:
|
||||
row = {
|
||||
@@ -543,6 +645,27 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
|
||||
"mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
|
||||
}
|
||||
product = _get_or_create_product(db, row, created_products)
|
||||
# Gegenstands-Zusatzfelder – nur ergänzend, nichts überschreiben.
|
||||
shop_name = (entry.get("shop") or "").strip()
|
||||
if shop_name and product.shop_id is None:
|
||||
shop = _get_or_create_shop(db, shop_name)
|
||||
product.shop_id = shop.id if shop else None
|
||||
if entry.get("product_url") and not product.product_url:
|
||||
product.product_url = entry["product_url"]
|
||||
feldwerte = entry.get("field_values") or {}
|
||||
if feldwerte and product.category_id:
|
||||
nach_label = {
|
||||
fd.label: fd
|
||||
for fd, _ in effective_field_definitions(db, product.category_id)
|
||||
}
|
||||
for label, value in feldwerte.items():
|
||||
fd = nach_label.get(label)
|
||||
if fd is None:
|
||||
continue
|
||||
try:
|
||||
apply_field_values(db, product, {fd.id: value})
|
||||
except FieldError:
|
||||
pass
|
||||
if mode == "replace_listed" and product.id not in cleared:
|
||||
_clear_lots(db, product, user)
|
||||
cleared.add(product.id)
|
||||
|
||||
Reference in New Issue
Block a user