Backend: Einzelstücke (Items) mit UID je Gegenstand
Gegenstands-Produkte koennen als Einzelstuecke gefuehrt werden (Product.individual):
jedes physische Stueck ist ein Item mit eigener kurzer UID (fuer QR), eigenem
Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz. So laesst sich dasselbe
Modell mehrfach getrennt fuehren (Powerbank 2024 + 2025).
Neu: models.Item + Product.individual (+Migration), Schemas, services/items.py
(UID-Erzeugung, Anreicherung), routers/items.py (CRUD, /items/by-uid/{uid} zur
QR-Aufloesung, /items/{id}/remove mit Grund als Bewegung). current_stock zaehlt
bei Einzelstueck-Produkten die Items. Tests + HTTP-Smoke gruen.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ from .routers import (
|
|||||||
dashboard,
|
dashboard,
|
||||||
field_definitions,
|
field_definitions,
|
||||||
groups,
|
groups,
|
||||||
|
items,
|
||||||
locations,
|
locations,
|
||||||
maintenance,
|
maintenance,
|
||||||
package_types,
|
package_types,
|
||||||
@@ -81,6 +82,9 @@ def _ensure_schema() -> None:
|
|||||||
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id INTEGER "
|
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id INTEGER "
|
||||||
"REFERENCES locations(id) ON DELETE SET NULL",
|
"REFERENCES locations(id) ON DELETE SET NULL",
|
||||||
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
|
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
|
||||||
|
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN "
|
||||||
|
"NOT NULL DEFAULT FALSE",
|
||||||
]
|
]
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
for stmt in stmts:
|
for stmt in stmts:
|
||||||
@@ -154,3 +158,4 @@ app.include_router(maintenance.router)
|
|||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(shops.router)
|
app.include_router(shops.router)
|
||||||
app.include_router(field_definitions.router)
|
app.include_router(field_definitions.router)
|
||||||
|
app.include_router(items.router)
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ class Product(Base):
|
|||||||
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
product_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
product_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
# Gegenstände: als Einzelstücke (Items mit eigener UID/QR) statt als Menge je
|
||||||
|
# Lagerort verwalten. So bekommt jedes physische Stück ein eigenes Kaufdatum,
|
||||||
|
# eine eigene Garantie und Bezugsquelle (z.B. dieselbe Powerbank 2024 + 2025).
|
||||||
|
individual: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
@@ -498,3 +502,36 @@ class ProductFieldValue(Base):
|
|||||||
|
|
||||||
product: Mapped[Product] = relationship(back_populates="field_values")
|
product: Mapped[Product] = relationship(back_populates="field_values")
|
||||||
field_definition: Mapped[FieldDefinition] = relationship()
|
field_definition: Mapped[FieldDefinition] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class Item(Base):
|
||||||
|
"""Ein physisches Einzelstück eines Gegenstands (Instanz) mit eigener UID/QR.
|
||||||
|
|
||||||
|
Nur für Gegenstands-Produkte mit Einzelstück-Verwaltung (``Product.individual``).
|
||||||
|
Je Stück ein eigener Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz –
|
||||||
|
so lässt sich dasselbe Modell mehrfach getrennt führen (Powerbank 2024 + 2025).
|
||||||
|
Der QR-Code trägt die UID; ein Scan öffnet genau dieses Stück.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
# Kurzer, gut lesbarer Code für den QR (ohne verwechselbare Zeichen).
|
||||||
|
uid: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False)
|
||||||
|
product_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
location_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
shop_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
acquired_on: Mapped[date | None] = mapped_column(Date, nullable=True) # gekauft am
|
||||||
|
warranty_until: Mapped[date | None] = mapped_column(Date, nullable=True) # Garantie bis
|
||||||
|
note: Mapped[str | None] = mapped_column(String(255), nullable=True) # Notiz/Zustand
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
product: Mapped[Product] = relationship()
|
||||||
|
location: Mapped[Location | None] = relationship()
|
||||||
|
shop: Mapped[Shop | None] = relationship()
|
||||||
|
|||||||
158
backend/app/routers/items.py
Normal file
158
backend/app/routers/items.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""Einzelstücke (Items) eines Gegenstands: anlegen, ändern, entfernen, per UID finden.
|
||||||
|
|
||||||
|
Ein Item ist ein physisches Exemplar mit eigener UID/QR und eigenen Angaben
|
||||||
|
(Lagerort, Kaufdatum, Garantie, Bezugsquelle, Notiz). Nur sinnvoll für
|
||||||
|
Gegenstands-Produkte mit ``Product.individual``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 Item, Location, Movement, MovementType, Product, Shop, User
|
||||||
|
from ..schemas import ItemCreate, ItemOut, ItemRemove, ItemUpdate
|
||||||
|
from ..services.items import generate_uid, item_to_out
|
||||||
|
|
||||||
|
router = APIRouter(tags=["items"])
|
||||||
|
|
||||||
|
|
||||||
|
def _product_or_404(db: Session, product_id: int) -> Product:
|
||||||
|
product = db.get(Product, product_id)
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def _item_or_404(db: Session, item_id: int) -> Item:
|
||||||
|
item = db.get(Item, item_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einzelstück nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _check_refs(db: Session, shop_id: int | None, location_id: int | None) -> None:
|
||||||
|
if shop_id is not None and db.get(Shop, shop_id) is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||||
|
if location_id is not None and db.get(Location, location_id) is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/products/{product_id}/items", response_model=list[ItemOut])
|
||||||
|
def list_items(
|
||||||
|
product_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> list[ItemOut]:
|
||||||
|
_product_or_404(db, product_id)
|
||||||
|
rows = db.query(Item).filter(Item.product_id == product_id).order_by(Item.id).all()
|
||||||
|
return [item_to_out(i) for i in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/products/{product_id}/items", response_model=list[ItemOut],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_items(
|
||||||
|
product_id: int,
|
||||||
|
payload: ItemCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> list[ItemOut]:
|
||||||
|
"""Ein oder mehrere (count) Einzelstücke mit gemeinsamen Startwerten anlegen."""
|
||||||
|
product = _product_or_404(db, product_id)
|
||||||
|
_check_refs(db, payload.shop_id, payload.location_id)
|
||||||
|
created: list[Item] = []
|
||||||
|
for _ in range(payload.count):
|
||||||
|
item = Item(
|
||||||
|
uid=generate_uid(db),
|
||||||
|
product_id=product.id,
|
||||||
|
location_id=payload.location_id,
|
||||||
|
shop_id=payload.shop_id,
|
||||||
|
acquired_on=payload.acquired_on,
|
||||||
|
warranty_until=payload.warranty_until,
|
||||||
|
note=payload.note,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
db.flush()
|
||||||
|
created.append(item)
|
||||||
|
db.commit()
|
||||||
|
for item in created:
|
||||||
|
db.refresh(item)
|
||||||
|
return [item_to_out(i) for i in created]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/by-uid/{uid}", response_model=ItemOut)
|
||||||
|
def item_by_uid(
|
||||||
|
uid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> ItemOut:
|
||||||
|
"""QR-Auflösung: UID → Einzelstück (mit Produktangaben)."""
|
||||||
|
item = db.query(Item).filter(Item.uid == uid.strip().upper()).first()
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Einzelstück mit dieser UID")
|
||||||
|
return item_to_out(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/{item_id}", response_model=ItemOut)
|
||||||
|
def get_item(
|
||||||
|
item_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> ItemOut:
|
||||||
|
return item_to_out(_item_or_404(db, item_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/items/{item_id}", response_model=ItemOut)
|
||||||
|
def update_item(
|
||||||
|
item_id: int,
|
||||||
|
payload: ItemUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> ItemOut:
|
||||||
|
item = _item_or_404(db, item_id)
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
_check_refs(db, data.get("shop_id"), data.get("location_id"))
|
||||||
|
for field, value in data.items():
|
||||||
|
setattr(item, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return item_to_out(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/{item_id}/remove", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def remove_item(
|
||||||
|
item_id: int,
|
||||||
|
payload: ItemRemove,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
"""Einzelstück mit Grund entfernen – als Bewegung protokolliert (Statistik je Modell)."""
|
||||||
|
item = _item_or_404(db, item_id)
|
||||||
|
db.add(
|
||||||
|
Movement(
|
||||||
|
product_id=item.product_id,
|
||||||
|
lot_id=None,
|
||||||
|
user_id=user.id,
|
||||||
|
type=MovementType.out,
|
||||||
|
quantity=1,
|
||||||
|
unit_used="Stück",
|
||||||
|
note=(payload.note or f"Einzelstück {item.uid}"),
|
||||||
|
location_id=item.location_id,
|
||||||
|
reason=payload.reason.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.delete(item)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_item(
|
||||||
|
item_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
"""Einzelstück ohne Grund löschen (Korrektur)."""
|
||||||
|
db.delete(_item_or_404(db, item_id))
|
||||||
|
db.commit()
|
||||||
@@ -335,6 +335,7 @@ def create_product(
|
|||||||
min_stock_in_packages=bool(payload.min_stock_in_packages),
|
min_stock_in_packages=bool(payload.min_stock_in_packages),
|
||||||
shop_id=payload.shop_id,
|
shop_id=payload.shop_id,
|
||||||
product_url=payload.product_url or None,
|
product_url=payload.product_url or None,
|
||||||
|
individual=bool(payload.individual),
|
||||||
source="manual",
|
source="manual",
|
||||||
)
|
)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
|
|||||||
@@ -281,6 +281,8 @@ class ProductBase(BaseModel):
|
|||||||
# Nur für Gegenstände: Bezugsquelle und Onlineshop-Link.
|
# Nur für Gegenstände: Bezugsquelle und Onlineshop-Link.
|
||||||
shop_id: int | None = None
|
shop_id: int | None = None
|
||||||
product_url: str | None = Field(default=None, max_length=1024)
|
product_url: str | None = Field(default=None, max_length=1024)
|
||||||
|
# Gegenstände als Einzelstücke (Items mit UID/QR) statt als Menge führen.
|
||||||
|
individual: bool = False
|
||||||
# Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}.
|
# Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}.
|
||||||
field_values: dict[int, str | None] | None = None
|
field_values: dict[int, str | None] | None = None
|
||||||
|
|
||||||
@@ -306,6 +308,7 @@ class ProductUpdate(BaseModel):
|
|||||||
min_stock_in_packages: bool | None = None
|
min_stock_in_packages: bool | None = None
|
||||||
shop_id: int | None = None
|
shop_id: int | None = None
|
||||||
product_url: str | None = Field(default=None, max_length=1024)
|
product_url: str | None = Field(default=None, max_length=1024)
|
||||||
|
individual: bool | None = None
|
||||||
field_values: dict[int, str | None] | None = None
|
field_values: dict[int, str | None] | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -330,6 +333,7 @@ class ProductOut(BaseModel):
|
|||||||
source: str
|
source: str
|
||||||
shop_id: int | None = None
|
shop_id: int | None = None
|
||||||
product_url: str | None = None
|
product_url: str | None = None
|
||||||
|
individual: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
# angereichert:
|
# angereichert:
|
||||||
stock: float = 0.0
|
stock: float = 0.0
|
||||||
@@ -498,6 +502,48 @@ class RemovalSummary(BaseModel):
|
|||||||
history: list[RemovalHistoryItem] = []
|
history: list[RemovalHistoryItem] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Einzelstücke (Items mit UID/QR) ----
|
||||||
|
class ItemCreate(BaseModel):
|
||||||
|
"""Ein oder mehrere Einzelstücke mit gemeinsamen Startwerten anlegen."""
|
||||||
|
count: int = Field(default=1, ge=1, le=200)
|
||||||
|
location_id: int | None = None
|
||||||
|
shop_id: int | None = None
|
||||||
|
acquired_on: date | None = None
|
||||||
|
warranty_until: date | None = None
|
||||||
|
note: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemUpdate(BaseModel):
|
||||||
|
location_id: int | None = None
|
||||||
|
shop_id: int | None = None
|
||||||
|
acquired_on: date | None = None
|
||||||
|
warranty_until: date | None = None
|
||||||
|
note: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemRemove(BaseModel):
|
||||||
|
reason: RemovalReason
|
||||||
|
note: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
uid: str
|
||||||
|
product_id: int
|
||||||
|
location_id: int | None = None
|
||||||
|
location_name: str | None = None
|
||||||
|
shop_id: int | None = None
|
||||||
|
shop_name: str | None = None
|
||||||
|
acquired_on: date | None = None
|
||||||
|
warranty_until: date | None = None
|
||||||
|
note: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
# Für QR-Auflösung/Anzeige mitgeliefert:
|
||||||
|
product_name: str | None = None
|
||||||
|
product_brand: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ---- Views ----
|
# ---- Views ----
|
||||||
class ShoppingItem(BaseModel):
|
class ShoppingItem(BaseModel):
|
||||||
product_id: int
|
product_id: int
|
||||||
|
|||||||
31
backend/app/services/items.py
Normal file
31
backend/app/services/items.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Einzelstücke (Items): UID-Erzeugung und Anreicherung für die Ausgabe."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Item
|
||||||
|
from ..schemas import ItemOut
|
||||||
|
|
||||||
|
# Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt.
|
||||||
|
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_uid(db: Session, length: int = 8) -> str:
|
||||||
|
"""Kurze, eindeutige UID erzeugen (bei Kollision neu würfeln)."""
|
||||||
|
while True:
|
||||||
|
code = "".join(secrets.choice(_ALPHABET) for _ in range(length))
|
||||||
|
if db.query(Item).filter(Item.uid == code).first() is None:
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def item_to_out(item: Item) -> ItemOut:
|
||||||
|
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen für die Anzeige."""
|
||||||
|
out = ItemOut.model_validate(item)
|
||||||
|
out.location_name = item.location.name if item.location else None
|
||||||
|
out.shop_name = item.shop.name if item.shop else None
|
||||||
|
out.product_name = item.product.name if item.product else None
|
||||||
|
out.product_brand = item.product.brand if item.product else None
|
||||||
|
return out
|
||||||
@@ -7,7 +7,7 @@ from datetime import date
|
|||||||
from sqlalchemy import asc
|
from sqlalchemy import asc
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..models import DatePrecision, Lot, Movement, MovementType, Product, User
|
from ..models import DatePrecision, Item, Lot, Movement, MovementType, Product, User
|
||||||
from .conversion import to_base
|
from .conversion import to_base
|
||||||
from .dates import clean_precision, normalize_best_before
|
from .dates import clean_precision, normalize_best_before
|
||||||
|
|
||||||
@@ -192,7 +192,14 @@ def removal_stats(db: Session, product_id: int) -> dict[str, dict]:
|
|||||||
|
|
||||||
|
|
||||||
def current_stock(db: Session, product_id: int) -> float:
|
def current_stock(db: Session, product_id: int) -> float:
|
||||||
"""Summe der Lot-Mengen eines Produkts (in Basiseinheiten)."""
|
"""Bestand eines Produkts (in Basiseinheiten).
|
||||||
|
|
||||||
|
Einzelstück-Produkte zählen die Anzahl ihrer Items, alle anderen summieren
|
||||||
|
die Lot-Mengen.
|
||||||
|
"""
|
||||||
|
product = db.get(Product, product_id)
|
||||||
|
if product is not None and product.individual:
|
||||||
|
return float(db.query(Item).filter(Item.product_id == product_id).count())
|
||||||
total = (
|
total = (
|
||||||
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
|
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.models import (
|
|||||||
Category,
|
Category,
|
||||||
CategoryTracking,
|
CategoryTracking,
|
||||||
FieldDefinition,
|
FieldDefinition,
|
||||||
|
Item,
|
||||||
Location,
|
Location,
|
||||||
Lot,
|
Lot,
|
||||||
Movement,
|
Movement,
|
||||||
@@ -21,6 +22,7 @@ from app.models import (
|
|||||||
ProductFieldValue,
|
ProductFieldValue,
|
||||||
RemovalReason,
|
RemovalReason,
|
||||||
)
|
)
|
||||||
|
from app.services.items import generate_uid
|
||||||
from app.routers.categories import create_category
|
from app.routers.categories import create_category
|
||||||
from app.schemas import CategoryCreate
|
from app.schemas import CategoryCreate
|
||||||
from app.services.fields import (
|
from app.services.fields import (
|
||||||
@@ -236,3 +238,40 @@ def test_feldwert_setzen_und_leeren(db):
|
|||||||
apply_field_values(db, product, {fd.id: ""})
|
apply_field_values(db, product, {fd.id: ""})
|
||||||
db.commit()
|
db.commit()
|
||||||
assert db.query(ProductFieldValue).filter_by(product_id=product.id).count() == 0
|
assert db.query(ProductFieldValue).filter_by(product_id=product.id).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Einzelstücke (Items mit UID/QR) ----
|
||||||
|
|
||||||
|
def _individual_product(db, name="Powerbank"):
|
||||||
|
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
|
||||||
|
db.add(cat)
|
||||||
|
db.flush()
|
||||||
|
product = Product(name=name, category_id=cat.id, individual=True)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_ist_eindeutig(db):
|
||||||
|
codes = {generate_uid(db) for _ in range(50)}
|
||||||
|
assert len(codes) == 50
|
||||||
|
# Keine verwechselbaren Zeichen (0/O/1/I/L) in der UID.
|
||||||
|
assert all(ch not in "01OIL" for code in codes for ch in code)
|
||||||
|
|
||||||
|
|
||||||
|
def test_einzelstueck_bestand_zaehlt_items(db):
|
||||||
|
product = _individual_product(db)
|
||||||
|
for _ in range(3):
|
||||||
|
db.add(Item(uid=generate_uid(db), product_id=product.id))
|
||||||
|
db.commit()
|
||||||
|
assert current_stock(db, product.id) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_mengen_produkt_zaehlt_weiter_lots(db):
|
||||||
|
# Ein Nicht-Einzelstück-Produkt zählt weiterhin über Lots, nicht über Items.
|
||||||
|
product, _ = _object_product(db, name="Unterhose")
|
||||||
|
a = _loc(db, "Schrank A")
|
||||||
|
object_add(db, product, 5, a.id, None)
|
||||||
|
db.commit()
|
||||||
|
assert current_stock(db, product.id) == 5
|
||||||
|
|||||||
Reference in New Issue
Block a user