Compare commits

...

8 Commits

Author SHA1 Message Date
Scarriffle
a38c0651c5 iOS: Kaufpreis + Beleg beim Anlegen; Kaufdatum/Shop-Vorschlag
- ItemAddSheet: Kaufpreis (+Währung) und Beleg (Bild/PDF). PDF wird beim Waehlen
  analysiert und Kaufdatum/Garantie/Preis/Shop vorbefuellt; erkannter neuer Shop
  wird beim Anlegen erstellt. Beleg haengt an das erste angelegte Stueck.
- ItemEditView-Vorschlag zeigt/uebernimmt jetzt auch Kaufdatum und Shop
  (bekannten zuweisen, sonst anlegen).
- Models/Client: DocumentSuggestions, analyzeItemDocument, ItemCreateRequest um
  price_cents/currency erweitert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 09:43:02 +02:00
Scarriffle
4e7512b4bc Web: Kaufpreis + Beleg schon beim Anlegen; Kaufdatum/Shop-Vorschlag
- Anlege-Maske: Kaufpreis (+Währung) und Beleg-Upload. Beim Datei-Auswählen wird
  der Beleg analysiert (ohne Speichern) und Kaufdatum/Garantie/Preis/Shop
  vorbefuellt; ein erkannter, noch nicht hinterlegter Shop wird beim Anlegen
  erstellt. Der Beleg haengt an das erste angelegte Stueck.
- Beleg-Vorschlag (bestehende Stuecke) zeigt/uebernimmt jetzt auch Kaufdatum und
  Shop (bekannter Shop zuweisen, sonst anlegen).
- API analyzeItemDocument.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 09:36:25 +02:00
Scarriffle
bbf10b36c6 Backend: Beleg-Analyse um Kaufdatum + Shop; Analyse-Endpoint
- guess_acquired_on (Kaufdatum) und guess_shop (bekannter Shop erkannt -> id;
  sonst Kandidatenname zum Anlegen) ergaenzt.
- Upload liefert zusaetzlich suggested_acquired_on / shop_id / shop_name.
- Neuer POST /items/analyze-document: analysiert einen Beleg OHNE zu speichern
  (zum Vorbefuellen beim Anlegen). Gemeinsamer Helfer _doc_suggestions.
- 6 neue Tests; Suite 150 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 09:30:35 +02:00
Scarriffle
729dfe766b brand: App-Icon-SVG (sechseckiges Zeichen) ablegen
Saubere App-Icon-Version des Vorrania-Zeichens (vollflächig, feste Farbe, 1024px,
ohne abgerundete Ecken) unter brand/app-icon.svg, plus README. Quelle des
Zeichens ist das "box"-Icon; das Favicon liegt weiterhin in web/public.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 07:25:43 +02:00
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
26 changed files with 1343 additions and 101 deletions

View File

@@ -8,7 +8,7 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import Barcode, Category, CategoryTracking, Lot, Product 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.conversion import KIND_OF_BASE, display_unit_info
from .services.stock import current_stock from .services.stock import current_stock
@@ -62,6 +62,15 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
else: else:
out.min_stock_display = product.min_stock / factor out.min_stock_display = product.min_stock / factor
out.min_stock_unit_label = name 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 return out

View File

@@ -131,6 +131,9 @@ class Group(Base):
products: Mapped[list[Product]] = relationship(back_populates="group") products: Mapped[list[Product]] = relationship(back_populates="group")
min_stock_unit: Mapped[Unit | None] = relationship() min_stock_unit: Mapped[Unit | None] = relationship()
location_min_stocks: Mapped[list["GroupLocationMinStock"]] = relationship(
cascade="all, delete-orphan"
)
class Location(Base): class Location(Base):
@@ -247,6 +250,9 @@ class Product(Base):
field_values: Mapped[list[ProductFieldValue]] = relationship( field_values: Mapped[list[ProductFieldValue]] = relationship(
back_populates="product", cascade="all, delete-orphan" back_populates="product", cascade="all, delete-orphan"
) )
location_min_stocks: Mapped[list["ProductLocationMinStock"]] = relationship(
cascade="all, delete-orphan"
)
class ApiToken(Base): class ApiToken(Base):
@@ -562,3 +568,46 @@ class ItemDocument(Base):
# Nur bei Bedarf laden (Download): sonst zoege jede Item-Liste die Belege mit. # Nur bei Bedarf laden (Download): sonst zoege jede Item-Liste die Belege mit.
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, deferred=True) data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, deferred=True)
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) 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 ..database import get_db
from ..deps import get_current_user, require_admin 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 ( from ..schemas import (
BarcodeCreate, BarcodeCreate,
BarcodeNoteUpdate, BarcodeNoteUpdate,
@@ -11,6 +11,8 @@ from ..schemas import (
GroupCreate, GroupCreate,
GroupOut, GroupOut,
GroupUpdate, GroupUpdate,
LocationMinStockIn,
LocationMinStockOut,
ProductBarcodeOut, ProductBarcodeOut,
) )
from ..services.conversion import BASE_OF_KIND 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 out.kind = unit.kind.value
else: else:
out.stock = float(sum(current_stock(db, p.id) for p in products)) 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 return out
@@ -87,6 +98,38 @@ def list_groups(
return [_group_to_out(db, g) for g in 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) @router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
def create_group( def create_group(
payload: GroupCreate, payload: GroupCreate,

View File

@@ -24,6 +24,7 @@ from ..models import (
User, User,
) )
from ..schemas import ( from ..schemas import (
DocumentSuggestions,
ItemCreate, ItemCreate,
ItemDocumentUploadOut, ItemDocumentUploadOut,
ItemOut, ItemOut,
@@ -33,8 +34,10 @@ from ..schemas import (
from ..services.items import generate_uid, item_to_out from ..services.items import generate_uid, item_to_out
from ..services.warranty import ( from ..services.warranty import (
extract_pdf_text, extract_pdf_text,
guess_acquired_on,
guess_price_candidates, guess_price_candidates,
guess_price_cents, guess_price_cents,
guess_shop,
guess_warranty_until, guess_warranty_until,
) )
@@ -208,20 +211,8 @@ def _safe_filename(name: str | None) -> str:
return cleaned[:255] or "beleg" return cleaned[:255] or "beleg"
@router.post( def _read_document(file: UploadFile, data: bytes) -> str:
"/items/{item_id}/documents", """Validiert Größe/Typ eines hochgeladenen Belegs und gibt den Content-Type."""
response_model=ItemDocumentUploadOut,
status_code=status.HTTP_201_CREATED,
)
async def upload_item_document(
item_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ItemDocumentUploadOut:
"""Beleg (PDF oder Bild) hochladen. Bei PDF wird ein Garantieende vorgeschlagen."""
item = _item_or_404(db, item_id)
data = await file.read()
if not data: if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei") raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
if len(data) > DOC_MAX_BYTES: if len(data) > DOC_MAX_BYTES:
@@ -234,6 +225,43 @@ async def upload_item_document(
raise HTTPException( raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt." status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt."
) )
return content_type
def _doc_suggestions(
db: Session, data: bytes, content_type: str, item_acquired_on=None
) -> DocumentSuggestions:
"""Garantie, Preis, Kaufdatum und Shop aus einem PDF schätzen (Bilder: leer)."""
if content_type != "application/pdf":
return DocumentSuggestions()
text = extract_pdf_text(data)
shop_id, shop_name = guess_shop(text, [(s.id, s.name) for s in db.query(Shop).all()])
return DocumentSuggestions(
suggested_warranty_until=guess_warranty_until(text, acquired_on=item_acquired_on),
suggested_price_cents=guess_price_cents(text),
suggested_price_candidates=guess_price_candidates(text),
suggested_acquired_on=guess_acquired_on(text),
suggested_shop_id=shop_id,
suggested_shop_name=shop_name,
)
@router.post(
"/items/{item_id}/documents",
response_model=ItemDocumentUploadOut,
status_code=status.HTTP_201_CREATED,
)
async def upload_item_document(
item_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ItemDocumentUploadOut:
"""Beleg (PDF oder Bild) hochladen. Bei PDF werden Garantie, Preis, Kaufdatum
und Shop vorgeschlagen."""
item = _item_or_404(db, item_id)
data = await file.read()
content_type = _read_document(file, data)
doc = ItemDocument( doc = ItemDocument(
item_id=item.id, item_id=item.id,
@@ -245,26 +273,28 @@ async def upload_item_document(
db.commit() db.commit()
db.refresh(doc) db.refresh(doc)
# Garantieende und Preis nur aus PDFs schätzen (Bilder haben keine Textebene). vorschlag = _doc_suggestions(db, data, content_type, item.acquired_on)
warranty = price = None
candidates: list[int] = []
if content_type == "application/pdf":
text = extract_pdf_text(data)
warranty = guess_warranty_until(text, acquired_on=item.acquired_on)
price = guess_price_cents(text)
candidates = guess_price_candidates(text)
return ItemDocumentUploadOut( return ItemDocumentUploadOut(
id=doc.id, id=doc.id,
filename=doc.filename, filename=doc.filename,
content_type=doc.content_type, content_type=doc.content_type,
uploaded_at=doc.uploaded_at, uploaded_at=doc.uploaded_at,
suggested_warranty_until=warranty, **vorschlag.model_dump(),
suggested_price_cents=price,
suggested_price_candidates=candidates,
) )
@router.post("/items/analyze-document", response_model=DocumentSuggestions)
async def analyze_item_document(
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> DocumentSuggestions:
"""Beleg nur analysieren (nichts speichern) zum Vorbefüllen beim Anlegen."""
data = await file.read()
content_type = _read_document(file, data)
return _doc_suggestions(db, data, content_type)
@router.get("/items/{item_id}/documents/{doc_id}") @router.get("/items/{item_id}/documents/{doc_id}")
def get_item_document( def get_item_document(
item_id: int, item_id: int,

View File

@@ -15,6 +15,7 @@ from ..models import (
MovementType, MovementType,
Product, Product,
ProductImage, ProductImage,
ProductLocationMinStock,
RemovalReason, RemovalReason,
Shop, Shop,
User, User,
@@ -22,6 +23,7 @@ from ..models import (
from ..off import lookup_barcode from ..off import lookup_barcode
from ..schemas import ( from ..schemas import (
BarcodeCreate, BarcodeCreate,
LocationMinStockIn,
LookupResult, LookupResult,
ProductCreate, ProductCreate,
ProductOut, ProductOut,
@@ -116,6 +118,38 @@ def get_product(
return product_to_out(db, 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) @router.get("/{product_id}/removals", response_model=RemovalSummary)
def product_removals( def product_removals(
product_id: int, product_id: int,

View File

@@ -1,3 +1,4 @@
from collections import defaultdict
from datetime import date, timedelta from datetime import date, timedelta
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -5,10 +6,27 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user from ..deps import get_current_user
from ..models import Group, Lot, Movement, Product, User from ..models import (
from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem Group,
from ..services.conversion import BASE_OF_KIND, display_unit_info GroupLocationMinStock,
from ..services.stock import current_stock 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 from .settings import get_expiry_warning_days
router = APIRouter(tags=["views"]) router = APIRouter(tags=["views"])
@@ -83,6 +101,64 @@ def group_shopping_list(
return items 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]) @router.get("/expiring", response_model=list[ExpiringItem])
def expiring( def expiring(
days: int | None = None, days: int | None = None,

View File

@@ -109,6 +109,19 @@ class UserUpdate(BaseModel):
# ---- Groups ---- # ---- 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): class GroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@@ -123,6 +136,8 @@ class GroupOut(BaseModel):
barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind
# EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar. # EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar.
product_barcodes: list[ProductBarcodeOut] = [] product_barcodes: list[ProductBarcodeOut] = []
# Mindestbestand je Lagerort (zusaetzlich zum Gesamt-Mindestbestand oben).
location_min_stocks: list[LocationMinStockOut] = []
class GroupCreate(BaseModel): class GroupCreate(BaseModel):
@@ -351,6 +366,8 @@ class ProductOut(BaseModel):
tracking: CategoryTracking = CategoryTracking.food tracking: CategoryTracking = CategoryTracking.food
shop_name: str | None = None shop_name: str | None = None
field_values: dict[int, str | 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") @field_validator("field_values", mode="before")
@classmethod @classmethod
@@ -540,12 +557,20 @@ class ItemDocumentOut(BaseModel):
uploaded_at: datetime uploaded_at: datetime
class ItemDocumentUploadOut(ItemDocumentOut): class DocumentSuggestions(BaseModel):
"""Antwort nach dem Upload mit aus dem PDF geschätztem Garantieende und Preis.""" """Aus einem Beleg-PDF geschätzte Werte (ohne den Beleg zu speichern)."""
suggested_warranty_until: date | None = None suggested_warranty_until: date | None = None
suggested_price_cents: int | None = None suggested_price_cents: int | None = None
# Alle plausiblen Preise (bester zuerst) zur Auswahl, falls es mehrere gibt. # Alle plausiblen Preise (bester zuerst) zur Auswahl, falls es mehrere gibt.
suggested_price_candidates: list[int] = [] suggested_price_candidates: list[int] = []
suggested_acquired_on: date | None = None
# Erkannter Shop: id = bereits hinterlegt; sonst name = Vorschlag zum Anlegen.
suggested_shop_id: int | None = None
suggested_shop_name: str | None = None
class ItemDocumentUploadOut(ItemDocumentOut, DocumentSuggestions):
"""Antwort nach dem Upload Beleg-Metadaten plus die geschätzten Werte."""
class ItemOut(BaseModel): class ItemOut(BaseModel):
@@ -606,6 +631,33 @@ class GroupShoppingItem(BaseModel):
product_count: int 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): class MovementOut(BaseModel):
id: int id: int
product_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)) 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( def check_in(
db: Session, db: Session,
product: Product, product: Product,

View File

@@ -126,6 +126,57 @@ def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | N
return _find_date_near_keyword(text) return _find_date_near_keyword(text)
def guess_acquired_on(text: str) -> date | None:
"""Kaufdatum aus dem Beleg (Datum nahe Rechnungs-/Kaufdatum-Stichwort)."""
return _find_purchase_date(text) if text else None
# --------------------------------------------------------------------------
# Shop / Bezugsquelle aus dem Beleg
# --------------------------------------------------------------------------
_LEGAL_RE = re.compile(
r"\b(GmbH|AG|SA|Sàrl|S\.?à r\.?l\.?|Ltd|Inc|SE|KG|OHG|e\.?K\.?|AS|BV|S\.p\.A\.)\b"
)
def _vendor_candidate(text: str) -> str | None:
"""Bester Rate-Name des Händlers meist im Kopf des Belegs. Best effort."""
lines = [z.strip() for z in text.splitlines() if z.strip()]
for z in lines[:20]:
if _LEGAL_RE.search(z):
return z[:60]
for z in lines[:8]:
buchstaben = sum(c.isalpha() for c in z)
if 3 <= len(z) <= 40 and buchstaben >= 3 and not re.search(
r"rechnung|invoice|quittung|beleg|kassenbon|datum|receipt|order|bestell",
z, re.IGNORECASE,
):
return z[:60]
return None
def guess_shop(text: str, shops: list[tuple[int, str]]) -> tuple[int | None, str | None]:
"""(shop_id, name).
Kommt der Name eines bereits hinterlegten Shops im Beleg vor, wird dieser
vorgeschlagen (längster Treffer gewinnt). Sonst ein Kandidatenname zum
Anlegen oder (None, None), wenn nichts Brauchbares gefunden wird.
"""
if not text:
return (None, None)
low = text.lower()
treffer: tuple[int, str] | None = None
best_len = 0
for sid, name in shops:
n = name.strip().lower()
if len(n) >= 3 and n in low and len(n) > best_len:
treffer = (sid, name)
best_len = len(n)
if treffer is not None:
return treffer
return (None, _vendor_candidate(text))
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Kaufpreis aus dem Beleg schätzen # Kaufpreis aus dem Beleg schätzen
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -134,8 +185,13 @@ _TOTAL_KW = re.compile(
r"zu zahlen|amount due|grand total|total due", r"zu zahlen|amount due|grand total|total due",
re.IGNORECASE, re.IGNORECASE,
) )
# Beträge mit 2 Nachkommastellen, mit/ohne Tausendertrenner: 1'299.00 / 1.299,00 / 49,90 _CURRENCY_RE = re.compile(r"chf|eur|sfr|fr\.|rp\.|€|\$", re.IGNORECASE)
_AMOUNT_RE = re.compile(r"\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2}") # 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: def _amount_to_cents(raw: str) -> int | None:
@@ -151,51 +207,70 @@ def _amount_to_cents(raw: str) -> int | None:
return int(ganz) * 100 + int(dezimal) 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: def guess_price_cents(text: str) -> int | None:
"""Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``. """Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``.
Bevorzugt einen Betrag nahe einem Summen-Stichwort; sonst den größten Betrag Bewusst zurückhaltend, damit nicht irgendeine Zahl gewinnt:
(die Gesamtsumme ist auf Rechnungen meist der höchste Wert). 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: if not text:
return None return None
betraege = [ hits = _price_hits(text)
(m.start(), _amount_to_cents(m.group())) for m in _AMOUNT_RE.finditer(text) if not hits:
]
betraege = [(pos, c) for pos, c in betraege if c]
if not betraege:
return None 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: int | None = None
best_dist = 10**9 best_dist = 10**9
for kw in _TOTAL_KW.finditer(text): for kw in _TOTAL_KW.finditer(text):
for pos, c in betraege: for pos, c, _cur in hits:
if pos >= kw.start(): if pos >= kw.start():
dist = pos - kw.start() dist = pos - kw.start()
if dist <= 40 and dist < best_dist: if dist <= 40 and dist < best_dist:
best, best_dist = c, 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]: 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 Nur Beträge, die nach Preis aussehen d.h. mit Währung in der Nähe oder kurz
Beträge absteigend. So kann die Oberfläche eine Auswahl anbieten, falls der hinter einem Summen-Stichwort. Beliebige Zahlen (Mengen, Artikelnummern,
automatische Tipp danebenliegt. Datumsteile) fliegen raus. So bleibt die Auswahl kurz und brauchbar.
""" """
if not text: if not text:
return [] return []
einzigartig = set() plausibel = {
for m in _AMOUNT_RE.finditer(text): c for pos, c, cur in _price_hits(text) if cur or _near_total(text, pos)
c = _amount_to_cents(m.group()) }
if c: if not plausibel:
einzigartig.add(c)
if not einzigartig:
return [] return []
kandidaten = sorted(einzigartig, reverse=True) kandidaten = sorted(plausibel, reverse=True)
best = guess_price_cents(text) best = guess_price_cents(text)
if best is not None and best in kandidaten: if best is not None and best in kandidaten:
kandidaten.remove(best) 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

@@ -3,8 +3,10 @@
from datetime import date from datetime import date
from app.services.warranty import ( from app.services.warranty import (
guess_acquired_on,
guess_price_candidates, guess_price_candidates,
guess_price_cents, guess_price_cents,
guess_shop,
guess_warranty_until, guess_warranty_until,
) )
@@ -60,19 +62,51 @@ def test_preis_deutsches_format():
assert guess_price_cents(text) == 129900 assert guess_price_cents(text) == 129900
def test_preis_ohne_stichwort_nimmt_groessten_betrag(): def test_preis_ohne_waehrung_und_ohne_stichwort_ist_none():
text = "Position A 12,90\nPosition B 199,00\nDanke." # 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 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(): def test_preis_ohne_betrag_ist_none():
assert guess_price_cents("Kein Preis hier.") is None assert guess_price_cents("Kein Preis hier.") is None
assert guess_price_cents("") is None assert guess_price_cents("") is None
def test_preis_kandidaten_bester_zuerst_ohne_dubletten(): 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) 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 set(kandidaten) == {5490, 4900, 590} # ohne Dubletten
assert guess_price_candidates("") == [] assert guess_price_candidates("") == []
# ---- Kaufdatum & Shop ----
def test_kaufdatum_aus_beleg():
assert guess_acquired_on("Rechnungsdatum: 05.06.2024") == date(2024, 6, 5)
assert guess_acquired_on("nichts hier") is None
def test_shop_erkennt_bekannten_namen():
shops = [(1, "Digitec"), (2, "Galaxus")]
assert guess_shop("Rechnung von Digitec AG, Zürich", shops) == (1, "Digitec")
def test_shop_schlaegt_neuen_namen_vor():
sid, name = guess_shop("ACME Electronics GmbH\nRechnung\nDatum 01.01.2024", [(1, "Galaxus")])
assert sid is None
assert name == "ACME Electronics GmbH"
def test_shop_ohne_text_leer():
assert guess_shop("", [(1, "Digitec")]) == (None, None)

25
brand/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Vorrania Markenzeichen
Das Zeichen ist die sechseckige „box"-Silhouette (aus `web/src/components/Icon.jsx`,
Name `box`) in Weiß auf der Akzentfläche.
## Dateien
- **`app-icon.svg`** 1024×1024, vollflächig, feste Farbe (`#4d5cd4`), ohne
abgerundete Ecken. Für App-Icon (iOS/macOS via Icon Composer oder Xcode
„AppIcon", App Store). Die Rundung macht das System selbst.
- **Favicon / Web-Logo:** `../web/public/favicon.svg` dasselbe Zeichen mit
abgerundeter Platte und Hell-/Dunkel-Umschaltung (für den Browser-Tab).
## Farben
- Akzent (hell): `#4d5cd4`
- Akzent (dunkel, nur Favicon): `#7c8ae8`
- Zeichen: `#ffffff`
## Als PNG exportieren (falls ein Werkzeug PNG verlangt)
```sh
# benötigt librsvg (brew install librsvg)
rsvg-convert -w 1024 -h 1024 app-icon.svg -o app-icon-1024.png
```

11
brand/app-icon.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Vorrania">
<!-- App-Icon: dasselbe "box"-Zeichen wie Favicon/Seitenleiste, aber vollflächig
(keine abgerundeten Ecken iOS/macOS maskieren selbst) und mit fester
Farbe (kein Dark-Mode-Umschalten). 1024×1024 für App Store / Icon Composer. -->
<rect width="1024" height="1024" fill="#4d5cd4" />
<g transform="translate(192 192) scale(26.6667)"
fill="none" stroke="#ffffff" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 736 B

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" : [ "groups" : [
{ {
"layers" : [ "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" : [ "fill-specializations" : [
{ {
@@ -51,6 +76,7 @@
} }
], ],
"glass" : false, "glass" : false,
"hidden" : true,
"image-name" : "barcode-gelesen.svg", "image-name" : "barcode-gelesen.svg",
"name" : "barcode-gelesen", "name" : "barcode-gelesen",
"position" : { "position" : {

View File

@@ -121,6 +121,13 @@ actor APIClient {
return try await send(request, as: Product.self) 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. /// Aktuelles Artikelfoto laden (mit Anmeldung). Gibt nil bei 404 zurück.
func productImage(id: Int) async throws -> Data? { func productImage(id: Int) async throws -> Data? {
let request = try makeRequest("/products/\(id)/image") let request = try makeRequest("/products/\(id)/image")
@@ -200,6 +207,25 @@ actor APIClient {
return try await send(request, as: ItemDocumentUpload.self) return try await send(request, as: ItemDocumentUpload.self)
} }
/// Beleg nur analysieren (nichts speichern) zum Vorbefüllen beim Anlegen.
func analyzeItemDocument(data: Data, filename: String,
contentType: String) async throws -> DocumentSuggestions {
var request = try makeRequest("/items/analyze-document", method: "POST")
let boundary = "Boundary-\(UUID().uuidString)"
request.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
let safeName = filename.replacingOccurrences(of: "\"", with: "")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(safeName)\"\r\n"
.data(using: .utf8)!)
body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
return try await send(request, as: DocumentSuggestions.self)
}
func itemDocumentData(itemId: Int, docId: Int) async throws -> Data { func itemDocumentData(itemId: Int, docId: Int) async throws -> Data {
let request = try makeRequest("/items/\(itemId)/documents/\(docId)") let request = try makeRequest("/items/\(itemId)/documents/\(docId)")
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
@@ -244,6 +270,10 @@ actor APIClient {
try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self) 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] { func expiring(days: Int? = nil) async throws -> [ExpiringItem] {
let path = days.map { "/expiring?days=\($0)" } ?? "/expiring" let path = days.map { "/expiring?days=\($0)" } ?? "/expiring"
return try await send(try makeRequest(path), as: [ExpiringItem].self) return try await send(try makeRequest(path), as: [ExpiringItem].self)

View File

@@ -105,6 +105,9 @@ struct ItemEditView: View {
@State private var suggWarranty: String? @State private var suggWarranty: String?
@State private var suggPrice: Int? @State private var suggPrice: Int?
@State private var suggPriceCandidates: [Int] = [] @State private var suggPriceCandidates: [Int] = []
@State private var suggAcquired: String?
@State private var suggShopId: Int?
@State private var suggShopName: String?
@State private var busy = false @State private var busy = false
@State private var busyDoc = false @State private var busyDoc = false
@State private var error: String? @State private var error: String?
@@ -161,9 +164,10 @@ struct ItemEditView: View {
} }
Section("Belege (Rechnung/Garantieschein)") { Section("Belege (Rechnung/Garantieschein)") {
if suggWarranty != nil || suggPrice != nil { if hatVorschlag {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary) Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary)
if let a = suggAcquired { Text("Gekauft am \(a)") }
if let w = suggWarranty { Text("Garantie bis \(w)") } if let w = suggWarranty { Text("Garantie bis \(w)") }
if suggPriceCandidates.count > 1 { if suggPriceCandidates.count > 1 {
// Mehrere Beträge gefunden Auswahl anbieten. // Mehrere Beträge gefunden Auswahl anbieten.
@@ -179,8 +183,13 @@ struct ItemEditView: View {
} else if let p = suggPrice { } else if let p = suggPrice {
Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)") Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)")
} }
if let sid = suggShopId {
Text("Shop: \(shops.first(where: { $0.id == sid })?.name ?? "bekannt")")
} else if let name = suggShopName {
Text("Shop anlegen: \(name)")
}
HStack { HStack {
Button("Übernehmen") { applySuggestion() } Button("Übernehmen") { Task { await applySuggestion() } }
Spacer() Spacer()
Button("Verwerfen") { verwerfeVorschlag() } Button("Verwerfen") { verwerfeVorschlag() }
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -301,9 +310,13 @@ struct ItemEditView: View {
do { do {
let res = try await APIClient.shared.uploadItemDocument( let res = try await APIClient.shared.uploadItemDocument(
itemId: item.id, data: data, filename: filename, contentType: contentType) itemId: item.id, data: data, filename: filename, contentType: contentType)
suggWarranty = res.suggestedWarrantyUntil let s = res.suggestions
suggPrice = res.suggestedPriceCents suggWarranty = s.suggestedWarrantyUntil
suggPriceCandidates = res.suggestedPriceCandidates suggPrice = s.suggestedPriceCents
suggPriceCandidates = s.suggestedPriceCandidates
suggAcquired = s.suggestedAcquiredOn
suggShopId = s.suggestedShopId
suggShopName = s.suggestedShopName
await reloadDocuments() await reloadDocuments()
} catch { self.error = error.localizedDescription } } catch { self.error = error.localizedDescription }
} }
@@ -331,9 +344,23 @@ struct ItemEditView: View {
} catch { self.error = error.localizedDescription } } catch { self.error = error.localizedDescription }
} }
private func applySuggestion() { private var hatVorschlag: Bool {
suggWarranty != nil || suggPrice != nil || suggAcquired != nil
|| suggShopId != nil || suggShopName != nil
}
private func applySuggestion() async {
if let a = suggAcquired, let d = stringToDate(a) { acquired = d; hasAcquired = true }
if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true } if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true }
if let p = suggPrice { priceText = ItemEditView.formatCents(p) } if let p = suggPrice { priceText = ItemEditView.formatCents(p) }
if let sid = suggShopId {
shopId = sid
} else if let name = suggShopName,
let shop = try? await APIClient.shared.createShop(
NewShopRequest(name: name, website: nil)) {
if !shops.contains(where: { $0.id == shop.id }) { shops.append(shop) }
shopId = shop.id
}
verwerfeVorschlag() verwerfeVorschlag()
} }
@@ -341,6 +368,9 @@ struct ItemEditView: View {
suggWarranty = nil suggWarranty = nil
suggPrice = nil suggPrice = nil
suggPriceCandidates = [] suggPriceCandidates = []
suggAcquired = nil
suggShopId = nil
suggShopName = nil
} }
/// Eingabe in Hauptwährungseinheit Rappen/Cent. /// Eingabe in Hauptwährungseinheit Rappen/Cent.
@@ -383,6 +413,15 @@ struct ItemAddSheet: View {
@State private var hasWarranty = false @State private var hasWarranty = false
@State private var warranty = Date() @State private var warranty = Date()
@State private var note = "" @State private var note = ""
@State private var priceText = ""
@State private var currency = "CHF"
@State private var docData: Data?
@State private var docName = ""
@State private var docType = ""
@State private var newShopName: String?
@State private var addInfo: String?
@State private var showFileImporter = false
@State private var pickerItem: PhotosPickerItem?
@State private var busy = false @State private var busy = false
@State private var error: String? @State private var error: String?
@@ -406,6 +445,37 @@ struct ItemAddSheet: View {
} footer: { } footer: {
Text("Gemeinsame Startwerte danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.") Text("Gemeinsame Startwerte danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.")
} }
Section("Kaufpreis") {
HStack {
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
Picker("", selection: $currency) {
Text("CHF").tag("CHF")
Text("EUR").tag("EUR")
}
.pickerStyle(.segmented).frame(width: 130)
}
}
Section {
if docData != nil { Text(docName).font(.callout) }
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Bild wählen", systemImage: "photo")
}
Button { showFileImporter = true } label: {
Label("PDF wählen", systemImage: "doc.badge.plus")
}
if let addInfo { Text(addInfo).font(.caption).foregroundStyle(.secondary) }
if let newShopName {
Text("Neuer Shop „\(newShopName)“ wird beim Anlegen erstellt.")
.font(.caption).foregroundStyle(.secondary)
}
} header: {
Text("Beleg (Rechnung/Garantieschein)")
} footer: {
Text("Der Beleg wird an das erste angelegte Stück gehängt; aus einem PDF werden Kaufdatum, Garantie, Preis und Shop vorgeschlagen.")
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section { Section {
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy) Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy)
@@ -414,20 +484,83 @@ struct ItemAddSheet: View {
.navigationTitle("Einzelstücke anlegen") .navigationTitle("Einzelstücke anlegen")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } } .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
if case .success(let url) = result {
Task {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
if let data = try? Data(contentsOf: url) {
await analyze(data, name: url.lastPathComponent, type: "application/pdf")
}
}
}
}
.onChange(of: pickerItem) { neu in
guard let neu else { return }
Task {
if let data = try? await neu.loadTransferable(type: Data.self) {
await analyze(data, name: "foto.jpg", type: "image/jpeg")
}
pickerItem = nil
}
}
.task { .task {
locations = (try? await APIClient.shared.locations()) ?? [] locations = (try? await APIClient.shared.locations()) ?? []
shops = (try? await APIClient.shared.shops()) ?? [] shops = (try? await APIClient.shared.shops()) ?? []
} }
} }
/// Beleg beim Anlegen analysieren und Felder vorbefüllen (nur bei PDF liefert
/// der Server Treffer).
private func analyze(_ data: Data, name: String, type: String) async {
docData = data
docName = name
docType = type
addInfo = nil
newShopName = nil
guard let s = try? await APIClient.shared.analyzeItemDocument(
data: data, filename: name, contentType: type) else { return }
var teile: [String] = []
if let a = s.suggestedAcquiredOn, let d = stringToDate(a) {
acquired = d; hasAcquired = true; teile.append("Kaufdatum")
}
if let w = s.suggestedWarrantyUntil, let d = stringToDate(w) {
warranty = d; hasWarranty = true; teile.append("Garantie")
}
if let p = s.suggestedPriceCents {
priceText = ItemEditView.formatCents(p); teile.append("Preis")
}
if let sid = s.suggestedShopId {
shopId = sid; teile.append("Shop")
} else if let sn = s.suggestedShopName {
newShopName = sn
}
addInfo = teile.isEmpty ? "Beleg erkannt keine Automatik-Treffer."
: "Übernommen: \(teile.joined(separator: ", "))."
}
private func create() async { private func create() async {
busy = true; defer { busy = false }; error = nil busy = true; defer { busy = false }; error = nil
do { do {
_ = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest( var sid = shopId
count: count, locationId: locationId, shopId: shopId, if sid == nil, let name = newShopName,
let shop = try? await APIClient.shared.createShop(
NewShopRequest(name: name, website: nil)) {
sid = shop.id
}
let cents = ItemEditView.parseCents(priceText)
let created = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
count: count, locationId: locationId, shopId: sid,
acquiredOn: hasAcquired ? dateToString(acquired) : nil, acquiredOn: hasAcquired ? dateToString(acquired) : nil,
warrantyUntil: hasWarranty ? dateToString(warranty) : nil, warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
note: note.isEmpty ? nil : note)) note: note.isEmpty ? nil : note,
priceCents: cents,
currency: cents == nil ? nil : currency))
if let data = docData, let first = created.first {
_ = try? await APIClient.shared.uploadItemDocument(
itemId: first.id, data: data,
filename: docName.isEmpty ? "beleg" : docName, contentType: docType)
}
await onDone() await onDone()
dismiss() dismiss()
} catch { self.error = error.localizedDescription } } catch { self.error = error.localizedDescription }

View File

@@ -50,6 +50,7 @@ struct ShoppingListView: View {
@State private var items: [ShoppingItem] = [] @State private var items: [ShoppingItem] = []
@State private var groups: [GroupShoppingItem] = [] @State private var groups: [GroupShoppingItem] = []
@State private var byLocation: [LocationNeeds] = []
@State private var busy = true @State private var busy = true
@State private var error: String? @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.") Text("Nichts einzukaufen alle Mindestbestände sind gedeckt.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -97,6 +116,7 @@ struct ShoppingListView: View {
do { do {
items = try await APIClient.shared.shoppingList() items = try await APIClient.shared.shoppingList()
groups = try await APIClient.shared.shoppingGroups() groups = try await APIClient.shared.shoppingGroups()
byLocation = try await APIClient.shared.shoppingByLocation()
error = nil error = nil
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription

View File

@@ -76,9 +76,12 @@ struct Product: Codable, Identifiable, Hashable {
let fieldValues: [String: String?]? let fieldValues: [String: String?]?
/// Gegenstand als Einzelstücke (Items mit UID/QR) statt als Menge geführt. /// Gegenstand als Einzelstücke (Items mit UID/QR) statt als Menge geführt.
let individual: Bool? let individual: Bool?
/// Mindestbestand je Lagerort (zusaetzlich zum globalen Mindestbestand).
let locationMinStocks: [LocationMinStock]?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, barcode, name, brand, stock, kind, barcodes, tracking, individual case id, barcode, name, brand, stock, kind, barcodes, tracking, individual
case locationMinStocks = "location_min_stocks"
case datePrecision = "date_precision" case datePrecision = "date_precision"
case categoryId = "category_id" case categoryId = "category_id"
case categoryName = "category_name" 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. /// Auswahleintrag fuer Einheiten.
/// ///
/// Anzeige und uebertragener Wert sind bewusst getrennt: Das Backend kennt /// 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 { struct ExpiringItem: Codable, Identifiable {
let lotId: Int let lotId: Int
let productId: Int let productId: Int
@@ -631,25 +712,46 @@ struct Item: Codable, Identifiable, Hashable {
} }
/// Antwort nach dem Beleg-Upload mit den aus dem PDF geschätzten Werten. /// Antwort nach dem Beleg-Upload mit den aus dem PDF geschätzten Werten.
struct ItemDocumentUpload: Codable { /// Aus einem Beleg-PDF geschätzte Werte (Analyse ohne Speichern).
let id: Int struct DocumentSuggestions: Codable {
let suggestedWarrantyUntil: String? var suggestedWarrantyUntil: String? = nil
let suggestedPriceCents: Int? var suggestedPriceCents: Int? = nil
let suggestedPriceCandidates: [Int] var suggestedPriceCandidates: [Int] = []
var suggestedAcquiredOn: String? = nil
var suggestedShopId: Int? = nil
var suggestedShopName: String? = nil
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id
case suggestedWarrantyUntil = "suggested_warranty_until" case suggestedWarrantyUntil = "suggested_warranty_until"
case suggestedPriceCents = "suggested_price_cents" case suggestedPriceCents = "suggested_price_cents"
case suggestedPriceCandidates = "suggested_price_candidates" case suggestedPriceCandidates = "suggested_price_candidates"
case suggestedAcquiredOn = "suggested_acquired_on"
case suggestedShopId = "suggested_shop_id"
case suggestedShopName = "suggested_shop_name"
} }
init() {}
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
suggestedWarrantyUntil = try c.decodeIfPresent(String.self, forKey: .suggestedWarrantyUntil) suggestedWarrantyUntil = try c.decodeIfPresent(String.self, forKey: .suggestedWarrantyUntil)
suggestedPriceCents = try c.decodeIfPresent(Int.self, forKey: .suggestedPriceCents) suggestedPriceCents = try c.decodeIfPresent(Int.self, forKey: .suggestedPriceCents)
suggestedPriceCandidates = try c.decodeIfPresent([Int].self, forKey: .suggestedPriceCandidates) ?? [] suggestedPriceCandidates = try c.decodeIfPresent([Int].self, forKey: .suggestedPriceCandidates) ?? []
suggestedAcquiredOn = try c.decodeIfPresent(String.self, forKey: .suggestedAcquiredOn)
suggestedShopId = try c.decodeIfPresent(Int.self, forKey: .suggestedShopId)
suggestedShopName = try c.decodeIfPresent(String.self, forKey: .suggestedShopName)
}
}
struct ItemDocumentUpload: Codable {
let id: Int
let suggestions: DocumentSuggestions
enum IdKey: String, CodingKey { case id }
init(from decoder: Decoder) throws {
id = try decoder.container(keyedBy: IdKey.self).decode(Int.self, forKey: .id)
suggestions = try DocumentSuggestions(from: decoder)
} }
} }
@@ -660,13 +762,16 @@ struct ItemCreateRequest: Codable {
let acquiredOn: String? let acquiredOn: String?
let warrantyUntil: String? let warrantyUntil: String?
let note: String? let note: String?
var priceCents: Int? = nil
var currency: String? = nil
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case count, note case count, note, currency
case locationId = "location_id" case locationId = "location_id"
case shopId = "shop_id" case shopId = "shop_id"
case acquiredOn = "acquired_on" case acquiredOn = "acquired_on"
case warrantyUntil = "warranty_until" case warrantyUntil = "warranty_until"
case priceCents = "price_cents"
} }
} }

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") { Section("Erkennung") {
LabeledContent("Barcode", value: current.barcode ?? "") LabeledContent("Barcode", value: current.barcode ?? "")
if !current.barcodes.isEmpty { 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

@@ -164,6 +164,12 @@ export const api = {
deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }), deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }),
// Belege je Einzelstück (Rechnung/Garantieschein). Der Upload liefert einen // Belege je Einzelstück (Rechnung/Garantieschein). Der Upload liefert einen
// vorgeschlagenen "Garantie bis"-Wert zurück (nur aus PDFs). // vorgeschlagenen "Garantie bis"-Wert zurück (nur aus PDFs).
// Beleg nur analysieren (nichts speichern) zum Vorbefüllen beim Anlegen.
analyzeItemDocument: (file) => {
const fd = new FormData();
fd.append("file", file);
return request("/items/analyze-document", { method: "POST", formData: fd });
},
uploadItemDocument: (itemId, file) => { uploadItemDocument: (itemId, file) => {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);
@@ -185,9 +191,16 @@ export const api = {
updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }), updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }),
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }), 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 // Views
shoppingList: () => request("/shopping-list"), shoppingList: () => request("/shopping-list"),
groupShoppingList: () => request("/shopping-list/groups"), groupShoppingList: () => request("/shopping-list/groups"),
shoppingByLocation: () => request("/shopping-list/by-location"),
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`), expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`), listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`),

View File

@@ -25,6 +25,7 @@ function QrImg({ text, size = 60 }) {
const EMPTY_ADD = { const EMPTY_ADD = {
count: 1, location_id: "", shop_id: "", acquired_on: "", warranty_until: "", note: "", count: 1, location_id: "", shop_id: "", acquired_on: "", warranty_until: "", note: "",
price: "", currency: "CHF",
}; };
/** /**
@@ -39,6 +40,9 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
const [remove, setRemove] = useState(null); // { item, reason, note } | null const [remove, setRemove] = useState(null); // { item, reason, note } | null
const [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen const [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
const [docFile, setDocFile] = useState(null); // Beleg, der beim Anlegen mitkommt
const [newShopName, setNewShopName] = useState(null); // erkannter, noch nicht hinterlegter Shop
const [addInfo, setAddInfo] = useState(null); // Hinweis nach Beleg-Analyse
const origin = window.location.origin; const origin = window.location.origin;
async function load() { async function load() {
@@ -53,23 +57,91 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
if (onChanged) await onChanged(); if (onChanged) await onChanged();
} }
// Kaufpreis-Eingabe (Hauptwährungseinheit) → Rappen/Cent.
function centsFrom(str) {
const roh = String(str).trim().replace(",", ".");
if (roh === "") return null;
const v = Math.round(parseFloat(roh) * 100);
return Number.isNaN(v) ? null : v;
}
// Beleg beim Anlegen: analysieren und Kaufdatum/Garantie/Preis/Shop vorbefüllen.
async function pickDoc(file) {
setDocFile(file);
setAddInfo(null);
setNewShopName(null);
try {
const s = await api.analyzeItemDocument(file);
const patch = {};
if (s.suggested_acquired_on) patch.acquired_on = s.suggested_acquired_on;
if (s.suggested_warranty_until) patch.warranty_until = s.suggested_warranty_until;
if (s.suggested_price_cents != null) { patch.price = (s.suggested_price_cents / 100).toFixed(2); patch.currency = "CHF"; }
if (s.suggested_shop_id != null) patch.shop_id = String(s.suggested_shop_id);
setAddForm((f) => ({ ...f, ...patch }));
if (s.suggested_shop_id == null && s.suggested_shop_name) setNewShopName(s.suggested_shop_name);
const teile = [];
if (s.suggested_acquired_on) teile.push("Kaufdatum");
if (s.suggested_warranty_until) teile.push("Garantie");
if (s.suggested_price_cents != null) teile.push("Preis");
if (s.suggested_shop_id != null) teile.push("Shop");
setAddInfo(teile.length ? `Aus dem Beleg übernommen: ${teile.join(", ")}.` : "Beleg erkannt keine Automatik-Treffer.");
} catch (err) { onError(err.message); }
}
async function add(e) { async function add(e) {
e.preventDefault(); e.preventDefault();
try { try {
await api.createItems(product.id, { // Erkannter, aber noch nicht hinterlegter Shop: beim Anlegen erstellen.
let shopId = addForm.shop_id === "" ? null : Number(addForm.shop_id);
if (shopId == null && newShopName) {
const shop = await api.createShop({ name: newShopName });
shopId = shop.id;
}
const cents = centsFrom(addForm.price);
const created = await api.createItems(product.id, {
count: Number(addForm.count) || 1, count: Number(addForm.count) || 1,
location_id: addForm.location_id === "" ? null : Number(addForm.location_id), location_id: addForm.location_id === "" ? null : Number(addForm.location_id),
shop_id: addForm.shop_id === "" ? null : Number(addForm.shop_id), shop_id: shopId,
acquired_on: addForm.acquired_on || null, acquired_on: addForm.acquired_on || null,
warranty_until: addForm.warranty_until || null, warranty_until: addForm.warranty_until || null,
note: addForm.note || null, note: addForm.note || null,
price_cents: cents,
currency: cents == null ? null : addForm.currency,
}); });
setAddForm({ ...EMPTY_ADD, location_id: addForm.location_id, shop_id: addForm.shop_id }); // Beleg an das erste angelegte Stück hängen.
if (docFile && created && created.length) {
await api.uploadItemDocument(created[0].id, docFile);
}
setAddForm({ ...EMPTY_ADD, location_id: addForm.location_id, shop_id: addForm.shop_id, currency: addForm.currency });
setDocFile(null);
setNewShopName(null);
setAddInfo(null);
await nachAktion(); await nachAktion();
toast("Einzelstück(e) angelegt."); toast("Einzelstück(e) angelegt.");
} catch (err) { onError(err.message); } } catch (err) { onError(err.message); }
} }
const shopName = (id) => shops.find((s) => s.id === id)?.name || "?";
// Beleg-Vorschlag an ein bestehendes Stück übernehmen (Shop ggf. anlegen).
async function applySuggestion(it) {
try {
const body = {};
if (suggestion.date) body.warranty_until = suggestion.date;
if (suggestion.acquiredOn) body.acquired_on = suggestion.acquiredOn;
if (suggestion.priceCents != null) { body.price_cents = suggestion.priceCents; body.currency = it.currency || "CHF"; }
if (suggestion.shopId != null) {
body.shop_id = suggestion.shopId;
} else if (suggestion.shopName) {
const shop = await api.createShop({ name: suggestion.shopName });
body.shop_id = shop.id;
if (onChanged) await onChanged();
}
await patchItem(it, body);
setSuggestion(null);
} catch (err) { onError(err.message); }
}
// Kaufpreis in Hauptwährungseinheit eingeben, als Rappen/Cent speichern. // Kaufpreis in Hauptwährungseinheit eingeben, als Rappen/Cent speichern.
function savePrice(item, value) { function savePrice(item, value) {
const roh = value.trim().replace(",", "."); const roh = value.trim().replace(",", ".");
@@ -83,12 +155,17 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
try { try {
const res = await api.uploadItemDocument(item.id, file); const res = await api.uploadItemDocument(item.id, file);
await load(); await load();
if (res && (res.suggested_warranty_until || res.suggested_price_cents != null)) { const hatVorschlag = res && (res.suggested_warranty_until || res.suggested_price_cents != null
|| res.suggested_acquired_on || res.suggested_shop_id != null || res.suggested_shop_name);
if (hatVorschlag) {
setSuggestion({ setSuggestion({
itemId: item.id, itemId: item.id,
date: res.suggested_warranty_until || null, date: res.suggested_warranty_until || null,
priceCents: res.suggested_price_cents ?? null, priceCents: res.suggested_price_cents ?? null,
priceCandidates: res.suggested_price_candidates || [], priceCandidates: res.suggested_price_candidates || [],
acquiredOn: res.suggested_acquired_on || null,
shopId: res.suggested_shop_id ?? null,
shopName: res.suggested_shop_name || null,
}); });
} }
toast("Beleg hochgeladen."); toast("Beleg hochgeladen.");
@@ -265,13 +342,13 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadDoc(it, f); e.target.value = ""; }} /> onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadDoc(it, f); e.target.value = ""; }} />
)} )}
</label> </label>
{suggestion && suggestion.itemId === it.id {suggestion && suggestion.itemId === it.id && (
&& (suggestion.date || suggestion.priceCents != null) && (
<div className="alert ok" style={{ marginTop: "var(--sp-2)", flexWrap: "wrap" }}> <div className="alert ok" style={{ marginTop: "var(--sp-2)", flexWrap: "wrap" }}>
<Icon name="check" size={16} /> <Icon name="check" size={16} />
<span> <span>
Im Beleg erkannt Im Beleg erkannt
{suggestion.date ? `: Garantie bis ${suggestion.date}` : ""} {suggestion.acquiredOn ? `: gekauft ${suggestion.acquiredOn}` : ""}
{suggestion.date ? `${suggestion.acquiredOn ? "," : ":"} Garantie bis ${suggestion.date}` : ""}
</span> </span>
{suggestion.priceCents != null && ( {suggestion.priceCents != null && (
(suggestion.priceCandidates || []).length > 1 ? ( (suggestion.priceCandidates || []).length > 1 ? (
@@ -288,17 +365,14 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
<span>Kaufpreis {(suggestion.priceCents / 100).toFixed(2)} {it.currency || "CHF"}</span> <span>Kaufpreis {(suggestion.priceCents / 100).toFixed(2)} {it.currency || "CHF"}</span>
) )
)} )}
{suggestion.shopId != null && (
<span>Shop: {shopName(suggestion.shopId)}</span>
)}
{suggestion.shopId == null && suggestion.shopName && (
<span>Shop anlegen: <strong>{suggestion.shopName}</strong></span>
)}
<button type="button" className="btn sm" style={{ marginLeft: "auto" }} <button type="button" className="btn sm" style={{ marginLeft: "auto" }}
onClick={() => { onClick={() => applySuggestion(it)}>
const body = {};
if (suggestion.date) body.warranty_until = suggestion.date;
if (suggestion.priceCents != null) {
body.price_cents = suggestion.priceCents;
body.currency = it.currency || "CHF";
}
patchItem(it, body);
setSuggestion(null);
}}>
Übernehmen Übernehmen
</button> </button>
<button type="button" className="btn sm ghost" onClick={() => setSuggestion(null)}>Verwerfen</button> <button type="button" className="btn sm ghost" onClick={() => setSuggestion(null)}>Verwerfen</button>
@@ -382,9 +456,33 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
onChange={(e) => setAddForm({ ...addForm, note: e.target.value })} /> onChange={(e) => setAddForm({ ...addForm, note: e.target.value })} />
</label> </label>
</div> </div>
<div className="row">
<label style={{ width: 200 }}>Kaufpreis
<div className="field-inline">
<input type="number" min="0" step="0.01" placeholder="0.00" value={addForm.price}
onChange={(e) => setAddForm({ ...addForm, price: e.target.value })} />
<select value={addForm.currency} style={{ marginTop: 0 }}
onChange={(e) => setAddForm({ ...addForm, currency: e.target.value })}>
<option value="CHF">CHF</option>
<option value="EUR">EUR</option>
</select>
</div>
</label>
<label className="grow">Beleg (Rechnung/Garantieschein)
<input type="file" accept="application/pdf,image/*"
onChange={(e) => { const f = e.target.files?.[0]; if (f) pickDoc(f); }} />
</label>
</div>
{addInfo && <p className="muted small mt-0"><Icon name="check" size={14} /> {addInfo}</p>}
{newShopName && (
<p className="muted small mt-0">
Neuer Shop <strong>{newShopName}</strong> wird beim Anlegen erstellt oder oben einen bestehenden wählen.
</p>
)}
<p className="muted small"> <p className="muted small">
Gemeinsame Startwerte für alle neuen Stücke danach je Stück änderbar Gemeinsame Startwerte für alle neuen Stücke danach je Stück änderbar
(z.B. dasselbe Modell 2024 und 2025). Jedes bekommt eine eigene UID + QR. (z.B. dasselbe Modell 2024 und 2025). Jedes bekommt eine eigene UID + QR.
Ein Beleg wird an das erste angelegte Stück gehängt.
</p> </p>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button> <button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
</form> </form>

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 { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList"; import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import LocationMinStock from "../components/LocationMinStock";
import { fmt } from "../units"; import { fmt } from "../units";
export default function Groups() { export default function Groups() {
@@ -11,15 +12,19 @@ export default function Groups() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]); const [units, setUnits] = useState([]);
const [locations, setLocations] = useState([]);
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" }); const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
const [selectedId, setSelectedId] = useState(null); const [selectedId, setSelectedId] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
async function load() { async function load() {
try { 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); setGroups(gs);
setUnits(us); setUnits(us);
setLocations(ls);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} }
@@ -204,6 +209,30 @@ export default function Groups() {
</section> </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 && ( {isAdmin && (
<form className="card" onSubmit={add}> <form className="card" onSubmit={add}>
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div> <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 { useSettings } from "../settings";
import { asTree } from "../categoryTree"; import { asTree } from "../categoryTree";
import CategorySelect from "../components/CategorySelect"; import CategorySelect from "../components/CategorySelect";
import LocationMinStock from "../components/LocationMinStock";
import { DynamicFields, FIELD_TYPES } from "../fields"; import { DynamicFields, FIELD_TYPES } from "../fields";
import ObjektBestand from "../components/ObjektBestand"; import ObjektBestand from "../components/ObjektBestand";
import Einzelstuecke from "../components/Einzelstuecke"; import Einzelstuecke from "../components/Einzelstuecke";
@@ -675,6 +676,31 @@ export default function ProductForm() {
</select> </select>
</label> </label>
</div> </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"> <div className="row">
<label className="grow seg-label"> <label className="grow seg-label">

View File

@@ -6,12 +6,13 @@ import { amountText, fmt, unitShort } from "../units";
export default function ShoppingList() { export default function ShoppingList() {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [byLocation, setByLocation] = useState([]);
const [checked, setChecked] = useState({}); const [checked, setChecked] = useState({});
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => {
Promise.all([api.shoppingList(), api.groupShoppingList()]) Promise.all([api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation()])
.then(([p, g]) => { setItems(p); setGroups(g); }) .then(([p, g, l]) => { setItems(p); setGroups(g); setByLocation(l); })
.catch((e) => setError(e.message)); .catch((e) => setError(e.message));
}, []); }, []);
@@ -31,6 +32,10 @@ export default function ShoppingList() {
</div> </div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</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"> <div className="card">
{empty ? ( {empty ? (
<div className="empty">Alle Mindestbestände erreicht.</div> <div className="empty">Alle Mindestbestände erreicht.</div>
@@ -70,6 +75,50 @@ export default function ShoppingList() {
</ul> </ul>
)} )}
</div> </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"> <p className="muted small">
Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert. Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert.
</p> </p>