Compare commits

...

4 Commits

Author SHA1 Message Date
Scarriffle
5d3ae185ce iOS: Mindestbestand je Lagerort (Produkte) + Einkaufsliste je Ort
- Produkt-Detail: neuer Editor „Mindestbestand je Lagerort" (Zeilen: Lagerort +
  Menge in Artikeleinheiten), zusaetzlich zum Gesamt-Mindestbestand.
- Einkaufsliste: Abschnitte „Bedarf: <Lagerort>" mit Produkten und Gruppen, die
  am Ort unter dem dort hinterlegten Mindestbestand liegen.
- Models/Client: LocationMinStock(+In), LocationNeeds*, setProductLocationMinStock,
  shoppingByLocation; Product um location_min_stocks erweitert.
(Gruppen-Bedarf je Ort ist vorerst nur im Web pflegbar, wird aber in der iOS-
 Einkaufsliste mit angezeigt.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 07:23:12 +02:00
Scarriffle
dff7677e50 Web: Mindestbestand je Lagerort + Einkaufsliste je Ort
- Neuer LocationMinStock-Editor (Zeilen: Lagerort + Menge, separat speichern).
- Produktformular: Abschnitt „Mindestbestand je Lagerort" (Menge in Artikel-
  einheit), zusaetzlich zum Gesamt-Mindestbestand.
- Gruppen: je Gruppe ein Editor „Mindestbestand je Lagerort".
- Einkaufsliste: Abschnitt „Bedarf: <Lagerort>" mit Produkten und Gruppen, die
  am Ort unter dem dort hinterlegten Mindestbestand liegen.
- API-Methoden set*LocationMinStock und shoppingByLocation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 07:12:59 +02:00
Scarriffle
67bc2dd87d Backend: Preiserkennung präziser (nicht mehr jede Zahl)
Nur noch Beträge, die nach Preis aussehen: direkt hinter einem Summen-Stichwort
oder mit Währung (CHF/EUR/€/…) in der Nähe. Ohne solchen Hinweis kein Vorschlag.
Datumsangaben (31.12.2027) werden per Lookaround nicht mehr als Betrag (31.12)
missgelesen. Kandidatenliste enthaelt entsprechend nur plausible Preise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 07:06:12 +02:00
Scarriffle
d0d854be5a Backend: Mindestbestand je Lagerort für Produkte und Gruppen
Zusaetzlich zum globalen Mindestbestand: je Produkt und je Gruppe laesst sich pro
Lagerort ein Mindestbestand (in Artikeleinheiten) hinterlegen.
- Neue Tabellen product_location_min_stock / group_location_min_stock.
- PUT /products/{id}/location-min-stock und /groups/{id}/location-min-stock
  ersetzen die Eintraege; Produkt-/Gruppen-Ausgabe liefert sie mit.
- Neue Einkaufsliste GET /shopping-list/by-location: Bedarfe je Ort (Produkte +
  Gruppen), Bestand-am-Ort gegen Mindestbestand-am-Ort.
- Helfer location_stock_base (Bestand je Ort). 4 neue Tests, Suite 144 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 06:58:08 +02:00
21 changed files with 852 additions and 39 deletions

View File

@@ -8,7 +8,7 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from .models import Barcode, Category, CategoryTracking, Lot, Product
from .schemas import BarcodeOut, ProductOut
from .schemas import BarcodeOut, LocationMinStockOut, ProductOut
from .services.conversion import KIND_OF_BASE, display_unit_info
from .services.stock import current_stock
@@ -62,6 +62,15 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
else:
out.min_stock_display = product.min_stock / factor
out.min_stock_unit_label = name
out.location_min_stocks = [
LocationMinStockOut(
location_id=e.location_id,
location_name=e.location.name if e.location else None,
min_stock=e.min_stock,
)
for e in sorted(product.location_min_stocks, key=lambda x: x.id)
]
return out

View File

@@ -131,6 +131,9 @@ class Group(Base):
products: Mapped[list[Product]] = relationship(back_populates="group")
min_stock_unit: Mapped[Unit | None] = relationship()
location_min_stocks: Mapped[list["GroupLocationMinStock"]] = relationship(
cascade="all, delete-orphan"
)
class Location(Base):
@@ -247,6 +250,9 @@ class Product(Base):
field_values: Mapped[list[ProductFieldValue]] = relationship(
back_populates="product", cascade="all, delete-orphan"
)
location_min_stocks: Mapped[list["ProductLocationMinStock"]] = relationship(
cascade="all, delete-orphan"
)
class ApiToken(Base):
@@ -562,3 +568,46 @@ class ItemDocument(Base):
# Nur bei Bedarf laden (Download): sonst zoege jede Item-Liste die Belege mit.
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, deferred=True)
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class ProductLocationMinStock(Base):
"""Mindestbestand eines Produkts an EINEM Lagerort (in Artikeleinheiten).
Zusätzlich zum globalen ``Product.min_stock``: so laesst sich derselbe Artikel
an mehreren Orten getrennt fuehren (z.B. 5 zuhause, 3 im Ferienhaus).
"""
__tablename__ = "product_location_min_stock"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
)
location_id: Mapped[int] = mapped_column(
ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
)
# In Artikeleinheiten (Packungen/Stueck), wie in der Produktliste gezaehlt.
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
location: Mapped[Location] = relationship()
__table_args__ = (UniqueConstraint("product_id", "location_id", name="uq_prod_loc_min"),)
class GroupLocationMinStock(Base):
"""Mindestbestand einer Gruppe an EINEM Lagerort (in der Gruppen-Einheit)."""
__tablename__ = "group_location_min_stock"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
group_id: Mapped[int] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
location_id: Mapped[int] = mapped_column(
ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
)
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
location: Mapped[Location] = relationship()
__table_args__ = (UniqueConstraint("group_id", "location_id", name="uq_group_loc_min"),)

View File

@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Barcode, Group, Product, User
from ..models import Barcode, Group, GroupLocationMinStock, Location, Product, User
from ..schemas import (
BarcodeCreate,
BarcodeNoteUpdate,
@@ -11,6 +11,8 @@ from ..schemas import (
GroupCreate,
GroupOut,
GroupUpdate,
LocationMinStockIn,
LocationMinStockOut,
ProductBarcodeOut,
)
from ..services.conversion import BASE_OF_KIND
@@ -76,6 +78,15 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
out.kind = unit.kind.value
else:
out.stock = float(sum(current_stock(db, p.id) for p in products))
out.location_min_stocks = [
LocationMinStockOut(
location_id=e.location_id,
location_name=e.location.name if e.location else None,
min_stock=e.min_stock,
)
for e in sorted(group.location_min_stocks, key=lambda x: x.id)
]
return out
@@ -87,6 +98,38 @@ def list_groups(
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)."""
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()
gesehen: set[int] = set()
for eintrag in payload:
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
continue
if 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,

View File

@@ -15,6 +15,7 @@ from ..models import (
MovementType,
Product,
ProductImage,
ProductLocationMinStock,
RemovalReason,
Shop,
User,
@@ -22,6 +23,7 @@ from ..models import (
from ..off import lookup_barcode
from ..schemas import (
BarcodeCreate,
LocationMinStockIn,
LookupResult,
ProductCreate,
ProductOut,
@@ -116,6 +118,38 @@ def get_product(
return product_to_out(db, product)
@router.put("/{product_id}/location-min-stock", response_model=ProductOut)
def set_product_location_min_stock(
product_id: int,
payload: list[LocationMinStockIn],
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
"""Mindestbestände je Lagerort komplett ersetzen (Menge 0 = Eintrag entfällt)."""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
db.query(ProductLocationMinStock).filter(
ProductLocationMinStock.product_id == product_id
).delete()
gesehen: set[int] = set()
for eintrag in payload:
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
continue
if 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(ProductLocationMinStock(
product_id=product_id,
location_id=eintrag.location_id,
min_stock=eintrag.min_stock,
))
db.commit()
db.refresh(product)
return product_to_out(db, product)
@router.get("/{product_id}/removals", response_model=RemovalSummary)
def product_removals(
product_id: int,

View File

@@ -1,3 +1,4 @@
from collections import defaultdict
from datetime import date, timedelta
from fastapi import APIRouter, Depends
@@ -5,10 +6,27 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import Group, Lot, Movement, Product, User
from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem
from ..services.conversion import BASE_OF_KIND, display_unit_info
from ..services.stock import current_stock
from ..models import (
Group,
GroupLocationMinStock,
Location,
Lot,
Movement,
Product,
ProductLocationMinStock,
User,
)
from ..schemas import (
ExpiringItem,
GroupShoppingItem,
LocationNeedGroup,
LocationNeedProduct,
LocationNeeds,
MovementOut,
ShoppingItem,
)
from ..services.conversion import BASE_OF_KIND, article_unit, display_unit_info
from ..services.stock import current_stock, location_stock_base
from .settings import get_expiry_warning_days
router = APIRouter(tags=["views"])
@@ -83,6 +101,64 @@ def group_shopping_list(
return items
@router.get("/shopping-list/by-location", response_model=list[LocationNeeds])
def shopping_list_by_location(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[LocationNeeds]:
"""Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT
unter dem dort hinterlegten Mindestbestand liegt."""
prod_needs: dict[int, list[LocationNeedProduct]] = defaultdict(list)
group_needs: dict[int, list[LocationNeedGroup]] = defaultdict(list)
for e in db.query(ProductLocationMinStock).all():
product = db.get(Product, e.product_id)
if product is None:
continue
faktor, label = article_unit(product)
stock = location_stock_base(db, product, e.location_id) / (faktor or 1.0)
if stock < e.min_stock:
prod_needs[e.location_id].append(LocationNeedProduct(
product_id=product.id, name=product.name, unit_label=label,
stock=round(stock, 3), min_stock=e.min_stock,
deficit=round(e.min_stock - stock, 3),
))
for e in db.query(GroupLocationMinStock).all():
group = db.get(Group, e.group_id)
if group is None:
continue
unit = group.min_stock_unit
if unit is not None:
base = BASE_OF_KIND[unit.kind]
matching = [p for p in group.products if p.base_unit == base]
stock = sum(location_stock_base(db, p, e.location_id) for p in matching) / unit.factor
unit_name = unit.name
else:
stock = float(sum(location_stock_base(db, p, e.location_id) for p in group.products))
unit_name = ""
if stock < e.min_stock:
group_needs[e.location_id].append(LocationNeedGroup(
group_id=group.id, name=group.name, unit_name=unit_name,
stock=round(stock, 3), min_stock=e.min_stock,
deficit=round(e.min_stock - stock, 3),
))
loc_ids = set(prod_needs) | set(group_needs)
namen = {
loc.id: loc.name
for loc in db.query(Location).filter(Location.id.in_(loc_ids)).all()
}
result: list[LocationNeeds] = []
for loc_id in sorted(loc_ids, key=lambda i: namen.get(i, "")):
result.append(LocationNeeds(
location_id=loc_id,
location_name=namen.get(loc_id, "?"),
products=sorted(prod_needs.get(loc_id, []), key=lambda x: x.deficit, reverse=True),
groups=sorted(group_needs.get(loc_id, []), key=lambda x: x.deficit, reverse=True),
))
return result
@router.get("/expiring", response_model=list[ExpiringItem])
def expiring(
days: int | None = None,

View File

@@ -109,6 +109,19 @@ class UserUpdate(BaseModel):
# ---- Groups ----
class LocationMinStockIn(BaseModel):
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Artikeleinheiten)."""
location_id: int
min_stock: float = Field(ge=0)
class LocationMinStockOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
location_id: int
location_name: str | None = None
min_stock: float
class GroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
@@ -123,6 +136,8 @@ class GroupOut(BaseModel):
barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind
# EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar.
product_barcodes: list[ProductBarcodeOut] = []
# Mindestbestand je Lagerort (zusaetzlich zum Gesamt-Mindestbestand oben).
location_min_stocks: list[LocationMinStockOut] = []
class GroupCreate(BaseModel):
@@ -351,6 +366,8 @@ class ProductOut(BaseModel):
tracking: CategoryTracking = CategoryTracking.food
shop_name: str | None = None
field_values: dict[int, str | None] = {}
# Mindestbestand je Lagerort (in Artikeleinheiten), zusaetzlich zum globalen.
location_min_stocks: list[LocationMinStockOut] = []
@field_validator("field_values", mode="before")
@classmethod
@@ -606,6 +623,33 @@ class GroupShoppingItem(BaseModel):
product_count: int
class LocationNeedProduct(BaseModel):
"""Ein Produkt-Bedarf an einem Lagerort (Mengen in Artikeleinheiten)."""
product_id: int
name: str
unit_label: str = ""
stock: float
min_stock: float
deficit: float
class LocationNeedGroup(BaseModel):
group_id: int
name: str
unit_name: str = ""
stock: float
min_stock: float
deficit: float
class LocationNeeds(BaseModel):
"""Alle Bedarfe (Produkte + Gruppen) eines Lagerorts."""
location_id: int
location_name: str
products: list[LocationNeedProduct] = []
groups: list[LocationNeedGroup] = []
class MovementOut(BaseModel):
id: int
product_id: int

View File

@@ -206,6 +206,26 @@ def current_stock(db: Session, product_id: int) -> float:
return float(sum(q for (q,) in total))
def location_stock_base(db: Session, product: Product, location_id: int) -> float:
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des
Ortes summiert.
"""
if product.individual:
return float(
db.query(Item)
.filter(Item.product_id == product.id, Item.location_id == location_id)
.count()
)
total = (
db.query(Lot.quantity)
.filter(Lot.product_id == product.id, Lot.location_id == location_id)
.all()
)
return float(sum(q for (q,) in total))
def check_in(
db: Session,
product: Product,

View File

@@ -134,8 +134,13 @@ _TOTAL_KW = re.compile(
r"zu zahlen|amount due|grand total|total due",
re.IGNORECASE,
)
# Beträge mit 2 Nachkommastellen, mit/ohne Tausendertrenner: 1'299.00 / 1.299,00 / 49,90
_AMOUNT_RE = re.compile(r"\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2}")
_CURRENCY_RE = re.compile(r"chf|eur|sfr|fr\.|rp\.|€|\$", re.IGNORECASE)
# Beträge mit genau 2 Nachkommastellen, mit/ohne Tausendertrenner
# (1'299.00 / 1.299,00 / 49,90). Die Lookarounds verhindern Treffer mitten in
# längeren Zahlen und in Datumsangaben (z.B. "31.12.2027" liefert kein "31.12").
_AMOUNT_RE = re.compile(
r"(?<![\d.,])(?:\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2})(?![.,]?\d)"
)
def _amount_to_cents(raw: str) -> int | None:
@@ -151,51 +156,70 @@ def _amount_to_cents(raw: str) -> int | None:
return int(ganz) * 100 + int(dezimal)
def _has_currency_near(text: str, start: int, end: int, window: int = 6) -> bool:
return bool(_CURRENCY_RE.search(text[max(0, start - window): end + window]))
def _near_total(text: str, pos: int) -> bool:
"""Steht der Betrag kurz hinter einem Summen-Stichwort?"""
for kw in _TOTAL_KW.finditer(text):
if kw.start() <= pos <= kw.end() + 40:
return True
return False
def _price_hits(text: str) -> list[tuple[int, int, bool]]:
"""(Position, Betrag in Cent, Währung in der Nähe?) für alle Beträge."""
hits: list[tuple[int, int, bool]] = []
for m in _AMOUNT_RE.finditer(text):
c = _amount_to_cents(m.group())
if c:
hits.append((m.start(), c, _has_currency_near(text, m.start(), m.end())))
return hits
def guess_price_cents(text: str) -> int | None:
"""Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``.
Bevorzugt einen Betrag nahe einem Summen-Stichwort; sonst den größten Betrag
(die Gesamtsumme ist auf Rechnungen meist der höchste Wert).
Bewusst zurückhaltend, damit nicht irgendeine Zahl gewinnt:
1. Betrag direkt nach einem Summen-Stichwort (Gesamtbetrag/Total/…).
2. Sonst der größte Betrag MIT Währungszeichen (CHF/EUR/€/…) in der Nähe.
3. Ohne Hinweis (weder Summe noch Währung) lieber kein Vorschlag.
"""
if not text:
return None
betraege = [
(m.start(), _amount_to_cents(m.group())) for m in _AMOUNT_RE.finditer(text)
]
betraege = [(pos, c) for pos, c in betraege if c]
if not betraege:
hits = _price_hits(text)
if not hits:
return None
# Die Summe steht hinter ihrem Label ("Gesamtbetrag: 54.90") deshalb den
# nächsten Betrag *nach* dem Stichwort nehmen, nicht den absolut nächsten
# (sonst gewönne eine davor stehende Zwischenzeile).
best: int | None = None
best_dist = 10**9
for kw in _TOTAL_KW.finditer(text):
for pos, c in betraege:
for pos, c, _cur in hits:
if pos >= kw.start():
dist = pos - kw.start()
if dist <= 40 and dist < best_dist:
best, best_dist = c, dist
return best if best is not None else max(c for _, c in betraege)
if best is not None:
return best
mit_waehrung = [c for _pos, c, cur in hits if cur]
return max(mit_waehrung) if mit_waehrung else None
def guess_price_candidates(text: str) -> list[int]:
"""Alle plausiblen Beträge aus dem Beleg (in Rappen/Cent), ohne Dubletten.
"""Plausible Beträge (Rappen/Cent), bester zuerst, ohne Dubletten.
Der beste Tipp (:func:`guess_price_cents`) steht vorne; danach die übrigen
Beträge absteigend. So kann die Oberfläche eine Auswahl anbieten, falls der
automatische Tipp danebenliegt.
Nur Beträge, die nach Preis aussehen d.h. mit Währung in der Nähe oder kurz
hinter einem Summen-Stichwort. Beliebige Zahlen (Mengen, Artikelnummern,
Datumsteile) fliegen raus. So bleibt die Auswahl kurz und brauchbar.
"""
if not text:
return []
einzigartig = set()
for m in _AMOUNT_RE.finditer(text):
c = _amount_to_cents(m.group())
if c:
einzigartig.add(c)
if not einzigartig:
plausibel = {
c for pos, c, cur in _price_hits(text) if cur or _near_total(text, pos)
}
if not plausibel:
return []
kandidaten = sorted(einzigartig, reverse=True)
kandidaten = sorted(plausibel, reverse=True)
best = guess_price_cents(text)
if best is not None and best in kandidaten:
kandidaten.remove(best)

View File

@@ -0,0 +1,62 @@
"""Bedarfe (Mindestbestände) je Lagerort und die Einkaufsliste je Ort."""
import pytest
from app.models import (
BaseUnit,
Location,
Lot,
Product,
ProductLocationMinStock,
Role,
User,
)
from app.routers.views import shopping_list_by_location
@pytest.fixture()
def user(db):
person = User(username="tester", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def test_bedarf_je_lagerort(db, user):
# package_size=1 -> Artikeleinheit == Basiseinheit, macht die Rechnung klar.
p = Product(name="Kaffee", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.flush()
zuhause = Location(name="Zuhause")
ferien = Location(name="Ferienhaus")
db.add_all([zuhause, ferien])
db.flush()
# 1 Stück zuhause, nichts im Ferienhaus.
db.add(Lot(product_id=p.id, quantity=1, location_id=zuhause.id))
db.add(ProductLocationMinStock(product_id=p.id, location_id=zuhause.id, min_stock=3))
db.add(ProductLocationMinStock(product_id=p.id, location_id=ferien.id, min_stock=2))
db.commit()
needs = shopping_list_by_location(db=db, _=user)
nach_ort = {n.location_name: n for n in needs}
assert set(nach_ort) == {"Zuhause", "Ferienhaus"}
assert nach_ort["Zuhause"].products[0].deficit == 2 # 3 - 1
assert nach_ort["Ferienhaus"].products[0].deficit == 2 # 2 - 0
def test_gedeckter_ort_erscheint_nicht(db, user):
p = Product(name="Reis", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.flush()
zuhause = Location(name="Zuhause")
db.add(zuhause)
db.flush()
db.add(Lot(product_id=p.id, quantity=5, location_id=zuhause.id))
db.add(ProductLocationMinStock(product_id=p.id, location_id=zuhause.id, min_stock=3))
db.commit()
# Bestand (5) >= Mindestbestand (3) -> kein Bedarf, kein Eintrag.
assert shopping_list_by_location(db=db, _=user) == []

View File

@@ -60,19 +60,29 @@ def test_preis_deutsches_format():
assert guess_price_cents(text) == 129900
def test_preis_ohne_stichwort_nimmt_groessten_betrag():
text = "Position A 12,90\nPosition B 199,00\nDanke."
def test_preis_ohne_waehrung_und_ohne_stichwort_ist_none():
# Reine Zahlen ohne Waehrung/Summen-Stichwort werden NICHT als Preis geraten.
assert guess_price_cents("Position A 12,90\nPosition B 199,00\nDanke.") is None
def test_preis_mit_waehrung_ohne_stichwort_nimmt_groessten():
text = "Kabel 12.90 CHF\nGeraet 199.00 CHF"
assert guess_price_cents(text) == 19900
def test_datum_wird_nicht_als_preis_gelesen():
assert guess_price_cents("Garantie gültig bis 31.12.2027.") is None
assert guess_price_candidates("Kaufdatum 05.06.2024, Garantie bis 31.12.2027") == []
def test_preis_ohne_betrag_ist_none():
assert guess_price_cents("Kein Preis hier.") is None
assert guess_price_cents("") is None
def test_preis_kandidaten_bester_zuerst_ohne_dubletten():
text = "Artikel 49.00\nVersand 5.90\nGesamtbetrag: 54.90\nnochmal 49.00"
text = "Artikel 49.00 CHF\nVersand 5.90 CHF\nGesamtbetrag: 54.90 CHF\nnochmal 49.00 CHF"
kandidaten = guess_price_candidates(text)
assert kandidaten[0] == 5490 # bester Tipp vorne
assert kandidaten[0] == 5490 # bester Tipp (Summe) vorne
assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten
assert guess_price_candidates("") == []

View File

@@ -0,0 +1 @@
<svg id="Layer_1" enable-background="new 0 0 500 500" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m394.394 334.235v-62.008l-53.677 31.007v62.009zm-120.521 0 53.733 31.007v-62.009l-53.733-31.007zm60.288-104.356 53.677 31.002-53.677 31.007-53.733-31.007zm73.344 31.001v77.136c0 2.342-1.233 4.51-3.306 5.681l-66.788 38.56c-2.049 1.189-4.531 1.175-6.555 0l-66.788-38.56c-2.017-1.171-3.306-3.339-3.306-5.681v-77.136c0-2.342 1.289-4.499 3.306-5.67l66.788-38.577c2.017-1.171 4.538-1.171 6.555 0l66.788 38.577c2.073 1.171 3.306 3.328 3.306 5.67zm-241.658-31.001-53.677 31.002 53.677 31.007 53.677-31.007zm60.233 104.356-53.677 31.007v-62.009l53.677-31.007zm-120.465 0v-62.008l53.677 31.007v62.009zm-13.111 3.782v-77.137c0-2.342 1.233-4.499 3.25-5.67l66.844-38.577c2.017-1.171 4.482-1.171 6.555 0l66.788 38.577c2.017 1.171 3.25 3.328 3.25 5.67v77.136c0 2.342-1.233 4.51-3.25 5.681l-66.788 38.56c-2.028 1.177-4.518 1.182-6.555 0l-66.844-38.56c-2.017-1.17-3.25-3.338-3.25-5.68zm157.5-253.245-53.677 30.996 53.677 31.007 53.677-31.007zm60.233 104.351v-62.003l-53.677 31.007v62.003zm-120.465 0 53.677 31.007v-62.003l-53.677-31.007zm-9.861 9.463 66.788 38.565c2.065 1.161 4.512 1.18 6.555 0l66.844-38.565c2.017-1.171 3.25-3.339 3.25-5.676v-77.142c0-2.331-1.233-4.499-3.25-5.67l-66.844-38.565c-2.017-1.171-4.482-1.171-6.555 0l-66.788 38.565c-2.017 1.171-3.25 3.339-3.25 5.67v77.142c0 2.337 1.233 4.505 3.25 5.676zm205.91 216.298-.28 2.264-14.344 42.695c-.952 2.734-3.474 4.466-6.219 4.466-4.443 0-7.617-4.413-6.219-8.64l7.9-23.572c-84.823 54.5-197.289 43.07-269.56-29.192-77.322-77.31-84.773-200.369-17.369-286.246 2.241-2.846 6.388-3.345 9.245-1.109 2.802 2.241 3.306 6.354 1.064 9.2-63.314 80.666-56.31 196.267 16.305 268.888 68.749 68.749 175.99 78.689 255.665 25.841l-28.183-3.636c-3.586-.459-6.107-3.748-5.659-7.329.448-3.592 3.754-6.135 7.34-5.664l44.6 5.737v.006c2.218.442 2.167.629 3.754 1.793l.504.538.112.123c.761 1.255 1.344 2.163 1.344 3.837zm34.402-31.539c-2.245 2.86-6.315 3.324-9.189 1.109-2.857-2.236-3.362-6.359-1.121-9.211 63.37-80.655 56.366-196.25-16.305-268.882-68.693-68.749-175.99-78.694-255.665-25.835l28.239 3.625c3.586.465 6.107 3.748 5.659 7.34-.47 3.604-3.822 6.133-7.34 5.659l-44.656-5.749c-1.283-.325-2.054-.403-3.306-1.429-.112-.106-.224-.23-.392-.342l-.56-.656c-.467-.093-1.457-2.622-1.457-4.102l.336-2.051 14.344-42.661c1.121-3.435 4.875-5.289 8.293-4.135s5.267 4.869 4.146 8.303l-7.9 23.561c84.549-54.349 197.113-43.238 269.56 29.197 77.323 77.318 84.775 200.382 17.314 286.259z" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -35,6 +35,31 @@
"groups" : [
{
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"solid" : "display-p3:0.00000,0.00000,0.00000,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"solid" : "display-p3:0.58245,0.53181,0.88393,1.00000"
}
}
],
"glass" : true,
"image-name" : "stock-rotation.svg",
"name" : "stock-rotation",
"position" : {
"scale" : 1.7,
"translation-in-points" : [
0,
0
]
}
},
{
"fill-specializations" : [
{
@@ -51,6 +76,7 @@
}
],
"glass" : false,
"hidden" : true,
"image-name" : "barcode-gelesen.svg",
"name" : "barcode-gelesen",
"position" : {

View File

@@ -121,6 +121,13 @@ actor APIClient {
return try await send(request, as: Product.self)
}
/// Mindestbestände je Lagerort ersetzen (Menge in Artikeleinheiten).
func setProductLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> Product {
var request = try makeRequest("/products/\(id)/location-min-stock", method: "PUT")
try jsonBody(&request, list)
return try await send(request, as: Product.self)
}
/// Aktuelles Artikelfoto laden (mit Anmeldung). Gibt nil bei 404 zurück.
func productImage(id: Int) async throws -> Data? {
let request = try makeRequest("/products/\(id)/image")
@@ -244,6 +251,10 @@ actor APIClient {
try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self)
}
func shoppingByLocation() async throws -> [LocationNeeds] {
try await send(try makeRequest("/shopping-list/by-location"), as: [LocationNeeds].self)
}
func expiring(days: Int? = nil) async throws -> [ExpiringItem] {
let path = days.map { "/expiring?days=\($0)" } ?? "/expiring"
return try await send(try makeRequest(path), as: [ExpiringItem].self)

View File

@@ -50,6 +50,7 @@ struct ShoppingListView: View {
@State private var items: [ShoppingItem] = []
@State private var groups: [GroupShoppingItem] = []
@State private var byLocation: [LocationNeeds] = []
@State private var busy = true
@State private var error: String?
@@ -80,7 +81,25 @@ struct ShoppingListView: View {
}
}
}
if items.isEmpty && groups.isEmpty && !busy {
ForEach(byLocation) { ort in
Section("Bedarf: \(ort.locationName)") {
ForEach(ort.products) { p in
ListRow(
systemImage: "shippingbox",
title: p.name,
subtitle: "fehlt \(formatAmount(p.deficit)) \(p.unitLabel)"
) { EmptyView() }
}
ForEach(ort.groups) { g in
ListRow(
systemImage: "square.stack.3d.up",
title: g.name,
subtitle: "fehlt \(formatAmount(g.deficit)) \(g.unitName)"
) { GroupBadge() }
}
}
}
if items.isEmpty && groups.isEmpty && byLocation.isEmpty && !busy {
Text("Nichts einzukaufen alle Mindestbestände sind gedeckt.")
.foregroundStyle(.secondary)
}
@@ -97,6 +116,7 @@ struct ShoppingListView: View {
do {
items = try await APIClient.shared.shoppingList()
groups = try await APIClient.shared.shoppingGroups()
byLocation = try await APIClient.shared.shoppingByLocation()
error = nil
} catch {
self.error = error.localizedDescription

View File

@@ -76,9 +76,12 @@ struct Product: Codable, Identifiable, Hashable {
let fieldValues: [String: String?]?
/// Gegenstand als Einzelstücke (Items mit UID/QR) statt als Menge geführt.
let individual: Bool?
/// Mindestbestand je Lagerort (zusaetzlich zum globalen Mindestbestand).
let locationMinStocks: [LocationMinStock]?
enum CodingKeys: String, CodingKey {
case id, barcode, name, brand, stock, kind, barcodes, tracking, individual
case locationMinStocks = "location_min_stocks"
case datePrecision = "date_precision"
case categoryId = "category_id"
case categoryName = "category_name"
@@ -129,6 +132,31 @@ struct Product: Codable, Identifiable, Hashable {
}
}
/// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige).
struct LocationMinStock: Codable, Hashable, Identifiable {
let locationId: Int
let locationName: String?
let minStock: Double
var id: Int { locationId }
enum CodingKeys: String, CodingKey {
case minStock = "min_stock"
case locationId = "location_id"
case locationName = "location_name"
}
}
/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten).
struct LocationMinStockIn: Codable {
let locationId: Int
let minStock: Double
enum CodingKeys: String, CodingKey {
case minStock = "min_stock"
case locationId = "location_id"
}
}
/// Auswahleintrag fuer Einheiten.
///
/// Anzeige und uebertragener Wert sind bewusst getrennt: Das Backend kennt
@@ -324,6 +352,59 @@ struct GroupShoppingItem: Codable, Identifiable {
}
}
// MARK: - Bedarfe je Lagerort
struct LocationNeedProduct: Codable, Identifiable {
let productId: Int
let name: String
let unitLabel: String
let stock: Double
let minStock: Double
let deficit: Double
var id: Int { productId }
enum CodingKeys: String, CodingKey {
case name, stock, deficit
case productId = "product_id"
case unitLabel = "unit_label"
case minStock = "min_stock"
}
}
struct LocationNeedGroup: Codable, Identifiable {
let groupId: Int
let name: String
let unitName: String
let stock: Double
let minStock: Double
let deficit: Double
var id: Int { groupId }
enum CodingKeys: String, CodingKey {
case name, stock, deficit
case groupId = "group_id"
case unitName = "unit_name"
case minStock = "min_stock"
}
}
struct LocationNeeds: Codable, Identifiable {
let locationId: Int
let locationName: String
let products: [LocationNeedProduct]
let groups: [LocationNeedGroup]
var id: Int { locationId }
enum CodingKeys: String, CodingKey {
case products, groups
case locationId = "location_id"
case locationName = "location_name"
}
}
struct ExpiringItem: Codable, Identifiable {
let lotId: Int
let productId: Int

View File

@@ -132,6 +132,18 @@ struct ProductDetailView: View {
}
}
Section {
NavigationLink {
ProductLocationMinView(product: current) { await reload() }
} label: {
let n = (current.locationMinStocks ?? []).count
Label(n > 0 ? "Mindestbestand je Lagerort (\(n))" : "Mindestbestand je Lagerort",
systemImage: "mappin.and.ellipse")
}
} footer: {
Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum Gesamt-Mindestbestand.")
}
Section("Erkennung") {
LabeledContent("Barcode", value: current.barcode ?? "")
if !current.barcodes.isEmpty {
@@ -399,3 +411,98 @@ struct LotEditView: View {
}
}
}
// MARK: - Mindestbestand je Lagerort
/// Bedarf eines Produkts je Lagerort bearbeiten (zusätzlich zum globalen
/// Mindestbestand). Menge in Artikeleinheiten.
struct ProductLocationMinView: View {
let product: Product
var onChanged: (() async -> Void)? = nil
@Environment(\.dismiss) private var dismiss
private struct MinRow: Identifiable {
let id = UUID()
var locationId: Int?
var amount: String
}
@State private var locations: [StorageLocation] = []
@State private var rows: [MinRow] = []
@State private var busy = false
@State private var error: String?
var body: some View {
Form {
Section {
Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause). Menge in \(product.articleUnitLabel).")
.font(.caption).foregroundStyle(.secondary)
}
ForEach($rows) { $row in
HStack {
Picker("Lagerort", selection: $row.locationId) {
Text(" wählen ").tag(Int?.none)
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) }
}
TextField("Menge", text: $row.amount)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 70)
Button(role: .destructive) {
rows.removeAll { $0.id == row.id }
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
}
Button {
rows.append(MinRow(locationId: nil, amount: ""))
} label: {
Label("Lagerort hinzufügen", systemImage: "plus")
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle("Bedarf je Lagerort")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
.disabled(busy)
}
}
.task { await load() }
}
private func load() async {
locations = (try? await APIClient.shared.locations()) ?? []
rows = (product.locationMinStocks ?? []).map {
MinRow(locationId: $0.locationId, amount: formatAmount($0.minStock))
}
}
private func save() async {
busy = true
defer { busy = false }
var list: [LocationMinStockIn] = []
var gesehen: Set<Int> = []
for row in rows {
guard let loc = row.locationId, !gesehen.contains(loc) else { continue }
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
if wert > 0 {
gesehen.insert(loc)
list.append(LocationMinStockIn(locationId: loc, minStock: wert))
}
}
do {
_ = try await APIClient.shared.setProductLocationMinStock(id: product.id, list)
await onChanged?()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}

View File

@@ -185,9 +185,16 @@ export const api = {
updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }),
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
// Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste).
setProductLocationMinStock: (id, list) =>
request(`/products/${id}/location-min-stock`, { method: "PUT", body: list }),
setGroupLocationMinStock: (id, list) =>
request(`/groups/${id}/location-min-stock`, { method: "PUT", body: list }),
// Views
shoppingList: () => request("/shopping-list"),
groupShoppingList: () => request("/shopping-list/groups"),
shoppingByLocation: () => request("/shopping-list/by-location"),
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`),

View File

@@ -0,0 +1,85 @@
import { useState } from "react";
import Icon from "./Icon";
/**
* Editor für Mindestbestände je Lagerort (Bedarfe). Eine Zeile je Ort mit Menge;
* „Bedarfe speichern" ersetzt über onSave die komplette Liste. Menge 0/leer =
* Ort fällt weg.
*/
export default function LocationMinStock({ locations, initial = [], unitLabel = "", onSave, onError }) {
const [rows, setRows] = useState(() =>
initial.map((e) => ({ location_id: String(e.location_id), min_stock: String(e.min_stock) })),
);
const [busy, setBusy] = useState(false);
const [ok, setOk] = useState(false);
const used = new Set(rows.map((r) => r.location_id));
const frei = locations.filter((l) => !used.has(String(l.id)));
function addRow() {
if (!frei.length) return;
setRows([...rows, { location_id: String(frei[0].id), min_stock: "" }]);
setOk(false);
}
function setRow(i, patch) {
setRows(rows.map((r, j) => (j === i ? { ...r, ...patch } : r)));
setOk(false);
}
function removeRow(i) {
setRows(rows.filter((_, j) => j !== i));
setOk(false);
}
async function save() {
setBusy(true);
try {
const list = rows
.filter((r) => r.location_id !== "" && Number(r.min_stock) > 0)
.map((r) => ({ location_id: Number(r.location_id), min_stock: Number(r.min_stock) }));
await onSave(list);
setOk(true);
} catch (err) {
onError?.(err.message);
} finally {
setBusy(false);
}
}
if (!locations.length) {
return <p className="muted small mt-0">Erst Lagerorte anlegen, dann Bedarfe je Ort möglich.</p>;
}
return (
<div className="stack">
{rows.length === 0 && (
<p className="muted small mt-0">Kein Bedarf je Lagerort festgelegt.</p>
)}
{rows.map((r, i) => (
<div className="field-inline" key={i} style={{ marginBottom: 0 }}>
<label className="grow" style={{ flex: "1 1 auto", minWidth: 0, margin: 0 }}>
<select value={r.location_id} onChange={(e) => setRow(i, { location_id: e.target.value })}>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label style={{ margin: 0, width: 130 }}>
<input type="number" min="0" step="0.01" placeholder="Menge" value={r.min_stock}
onChange={(e) => setRow(i, { min_stock: e.target.value })} />
</label>
{unitLabel && <span className="muted small" style={{ alignSelf: "center" }}>{unitLabel}</span>}
<button type="button" className="btn-icon danger" onClick={() => removeRow(i)} title="Entfernen">
<Icon name="trash" size={16} />
</button>
</div>
))}
<div className="field-inline" style={{ marginBottom: 0 }}>
<button type="button" className="btn" onClick={addRow} disabled={!frei.length}>
<Icon name="plus" size={16} />Lagerort
</button>
<button type="button" className="btn primary" onClick={save} disabled={busy}>
<Icon name="check" size={16} />{busy ? "Speichern…" : "Bedarfe speichern"}
</button>
{ok && <span className="muted small" style={{ alignSelf: "center" }}>Gespeichert.</span>}
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useConfirm } from "../confirm";
import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon";
import LocationMinStock from "../components/LocationMinStock";
import { fmt } from "../units";
export default function Groups() {
@@ -11,15 +12,19 @@ export default function Groups() {
const { isAdmin } = useAuth();
const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]);
const [locations, setLocations] = useState([]);
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
const [selectedId, setSelectedId] = useState(null);
const [error, setError] = useState(null);
async function load() {
try {
const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
const [gs, us, ls] = await Promise.all([
api.listGroups(), api.listUnits(), api.listLocations(),
]);
setGroups(gs);
setUnits(us);
setLocations(ls);
} catch (err) {
setError(err.message);
}
@@ -204,6 +209,30 @@ export default function Groups() {
</section>
)}
{selected && isAdmin && (
<section className="card">
<div className="card-head">
<Icon name="location" />
<h2>Mindestbestand je Lagerort</h2>
</div>
<p className="muted small mt-0">
Bedarf dieser Gruppe je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum
Gesamt-Mindestbestand{selected.min_stock_unit_name ? ` (in ${selected.min_stock_unit_name})` : ""}.
</p>
<LocationMinStock
key={selected.id}
locations={locations}
initial={selected.location_min_stocks || []}
unitLabel={selected.min_stock_unit_name || ""}
onError={setError}
onSave={async (list) => {
await api.setGroupLocationMinStock(selected.id, list);
await load();
}}
/>
</section>
)}
{isAdmin && (
<form className="card" onSubmit={add}>
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>

View File

@@ -11,6 +11,7 @@ import { useToast } from "../toast";
import { useSettings } from "../settings";
import { asTree } from "../categoryTree";
import CategorySelect from "../components/CategorySelect";
import LocationMinStock from "../components/LocationMinStock";
import { DynamicFields, FIELD_TYPES } from "../fields";
import ObjektBestand from "../components/ObjektBestand";
import Einzelstuecke from "../components/Einzelstuecke";
@@ -675,6 +676,31 @@ export default function ProductForm() {
</select>
</label>
</div>
{!isNew && product && isAdmin && (
<div style={{ marginTop: "var(--sp-2)" }}>
<div className="card-head" style={{ marginBottom: "var(--sp-1)" }}>
<Icon name="location" size={16} />
<h3 style={{ margin: 0 }}>Mindestbestand je Lagerort</h3>
</div>
<p className="muted small mt-0">
Zusätzlich zum Gesamt-Mindestbestand: eigener Bedarf je Lagerort (z.B.
Ferienhaus, Zuhause). Menge in {product.package_label || product.unit_name || "Stück"}.
Wird separat gespeichert.
</p>
<LocationMinStock
locations={locations}
initial={product.location_min_stocks || []}
unitLabel={product.package_label || product.unit_name || "Stück"}
onError={(m) => toast(m, "warn")}
onSave={async (list) => {
const updated = await api.setProductLocationMinStock(product.id, list);
setProduct(updated);
toast("Bedarfe je Lagerort gespeichert.");
}}
/>
</div>
)}
</>)}
<div className="row">
<label className="grow seg-label">

View File

@@ -6,12 +6,13 @@ import { amountText, fmt, unitShort } from "../units";
export default function ShoppingList() {
const [items, setItems] = useState([]);
const [groups, setGroups] = useState([]);
const [byLocation, setByLocation] = useState([]);
const [checked, setChecked] = useState({});
const [error, setError] = useState(null);
useEffect(() => {
Promise.all([api.shoppingList(), api.groupShoppingList()])
.then(([p, g]) => { setItems(p); setGroups(g); })
Promise.all([api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation()])
.then(([p, g, l]) => { setItems(p); setGroups(g); setByLocation(l); })
.catch((e) => setError(e.message));
}, []);
@@ -31,6 +32,10 @@ export default function ShoppingList() {
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{byLocation.length > 0 && (
<div className="sub" style={{ marginBottom: "var(--sp-2)" }}>Gesamt</div>
)}
<div className="card">
{empty ? (
<div className="empty">Alle Mindestbestände erreicht.</div>
@@ -70,6 +75,50 @@ export default function ShoppingList() {
</ul>
)}
</div>
{byLocation.map((loc) => (
<div key={loc.location_id} style={{ marginTop: "var(--sp-4)" }}>
<div className="sub" style={{ marginBottom: "var(--sp-2)" }}>
<Icon name="location" size={14} /> Bedarf: {loc.location_name}
</div>
<div className="card">
<ul className="checklist">
{loc.groups.map((it) => {
const key = `l${loc.location_id}g${it.group_id}`;
return (
<li key={key} className={checked[key] ? "done" : ""}>
<label>
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
<span className="badge accent">Gruppe</span>
<span className="item-name">{it.name}</span>
</label>
<span className="muted small">
fehlt <strong>{fmt(it.deficit)} {it.unit_name}</strong>{" "}
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
</span>
</li>
);
})}
{loc.products.map((it) => {
const key = `l${loc.location_id}p${it.product_id}`;
return (
<li key={key} className={checked[key] ? "done" : ""}>
<label>
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
<span className="item-name">{it.name}</span>
</label>
<span className="muted small">
fehlt <strong>{fmt(it.deficit)} {it.unit_label}</strong>{" "}
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
</span>
</li>
);
})}
</ul>
</div>
</div>
))}
<p className="muted small">
Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert.
</p>