Compare commits
8 Commits
2bc871f229
...
a38c0651c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a38c0651c5 | ||
|
|
4e7512b4bc | ||
|
|
bbf10b36c6 | ||
|
|
729dfe766b | ||
|
|
5d3ae185ce | ||
|
|
dff7677e50 | ||
|
|
67bc2dd87d | ||
|
|
d0d854be5a |
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"),)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,6 +24,7 @@ from ..models import (
|
||||
User,
|
||||
)
|
||||
from ..schemas import (
|
||||
DocumentSuggestions,
|
||||
ItemCreate,
|
||||
ItemDocumentUploadOut,
|
||||
ItemOut,
|
||||
@@ -33,8 +34,10 @@ from ..schemas import (
|
||||
from ..services.items import generate_uid, item_to_out
|
||||
from ..services.warranty import (
|
||||
extract_pdf_text,
|
||||
guess_acquired_on,
|
||||
guess_price_candidates,
|
||||
guess_price_cents,
|
||||
guess_shop,
|
||||
guess_warranty_until,
|
||||
)
|
||||
|
||||
@@ -208,20 +211,8 @@ def _safe_filename(name: str | None) -> str:
|
||||
return cleaned[:255] or "beleg"
|
||||
|
||||
|
||||
@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 wird ein Garantieende vorgeschlagen."""
|
||||
item = _item_or_404(db, item_id)
|
||||
data = await file.read()
|
||||
def _read_document(file: UploadFile, data: bytes) -> str:
|
||||
"""Validiert Größe/Typ eines hochgeladenen Belegs und gibt den Content-Type."""
|
||||
if not data:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
|
||||
if len(data) > DOC_MAX_BYTES:
|
||||
@@ -234,6 +225,43 @@ async def upload_item_document(
|
||||
raise HTTPException(
|
||||
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(
|
||||
item_id=item.id,
|
||||
@@ -245,26 +273,28 @@ async def upload_item_document(
|
||||
db.commit()
|
||||
db.refresh(doc)
|
||||
|
||||
# Garantieende und Preis nur aus PDFs schätzen (Bilder haben keine Textebene).
|
||||
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)
|
||||
|
||||
vorschlag = _doc_suggestions(db, data, content_type, item.acquired_on)
|
||||
return ItemDocumentUploadOut(
|
||||
id=doc.id,
|
||||
filename=doc.filename,
|
||||
content_type=doc.content_type,
|
||||
uploaded_at=doc.uploaded_at,
|
||||
suggested_warranty_until=warranty,
|
||||
suggested_price_cents=price,
|
||||
suggested_price_candidates=candidates,
|
||||
**vorschlag.model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@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}")
|
||||
def get_item_document(
|
||||
item_id: int,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -540,12 +557,20 @@ class ItemDocumentOut(BaseModel):
|
||||
uploaded_at: datetime
|
||||
|
||||
|
||||
class ItemDocumentUploadOut(ItemDocumentOut):
|
||||
"""Antwort nach dem Upload – mit aus dem PDF geschätztem Garantieende und Preis."""
|
||||
class DocumentSuggestions(BaseModel):
|
||||
"""Aus einem Beleg-PDF geschätzte Werte (ohne den Beleg zu speichern)."""
|
||||
suggested_warranty_until: date | None = None
|
||||
suggested_price_cents: int | None = None
|
||||
# Alle plausiblen Preise (bester zuerst) – zur Auswahl, falls es mehrere gibt.
|
||||
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):
|
||||
@@ -606,6 +631,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -126,6 +126,57 @@ def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | N
|
||||
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
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -134,8 +185,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 +207,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)
|
||||
|
||||
62
backend/tests/test_location_needs.py
Normal file
62
backend/tests/test_location_needs.py
Normal 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) == []
|
||||
@@ -3,8 +3,10 @@
|
||||
from datetime import date
|
||||
|
||||
from app.services.warranty import (
|
||||
guess_acquired_on,
|
||||
guess_price_candidates,
|
||||
guess_price_cents,
|
||||
guess_shop,
|
||||
guess_warranty_until,
|
||||
)
|
||||
|
||||
@@ -60,19 +62,51 @@ 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("") == []
|
||||
|
||||
|
||||
# ---- 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
25
brand/README.md
Normal 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
11
brand/app-icon.svg
Normal 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 |
1
ios/AppIcon.icon/Assets/stock-rotation.svg
Normal file
1
ios/AppIcon.icon/Assets/stock-rotation.svg
Normal 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 |
@@ -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" : {
|
||||
|
||||
@@ -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")
|
||||
@@ -200,6 +207,25 @@ actor APIClient {
|
||||
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 {
|
||||
let request = try makeRequest("/items/\(itemId)/documents/\(docId)")
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -105,6 +105,9 @@ struct ItemEditView: View {
|
||||
@State private var suggWarranty: String?
|
||||
@State private var suggPrice: 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 busyDoc = false
|
||||
@State private var error: String?
|
||||
@@ -161,9 +164,10 @@ struct ItemEditView: View {
|
||||
}
|
||||
|
||||
Section("Belege (Rechnung/Garantieschein)") {
|
||||
if suggWarranty != nil || suggPrice != nil {
|
||||
if hatVorschlag {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
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 suggPriceCandidates.count > 1 {
|
||||
// Mehrere Beträge gefunden – Auswahl anbieten.
|
||||
@@ -179,8 +183,13 @@ struct ItemEditView: View {
|
||||
} else if let p = suggPrice {
|
||||
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 {
|
||||
Button("Übernehmen") { applySuggestion() }
|
||||
Button("Übernehmen") { Task { await applySuggestion() } }
|
||||
Spacer()
|
||||
Button("Verwerfen") { verwerfeVorschlag() }
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -301,9 +310,13 @@ struct ItemEditView: View {
|
||||
do {
|
||||
let res = try await APIClient.shared.uploadItemDocument(
|
||||
itemId: item.id, data: data, filename: filename, contentType: contentType)
|
||||
suggWarranty = res.suggestedWarrantyUntil
|
||||
suggPrice = res.suggestedPriceCents
|
||||
suggPriceCandidates = res.suggestedPriceCandidates
|
||||
let s = res.suggestions
|
||||
suggWarranty = s.suggestedWarrantyUntil
|
||||
suggPrice = s.suggestedPriceCents
|
||||
suggPriceCandidates = s.suggestedPriceCandidates
|
||||
suggAcquired = s.suggestedAcquiredOn
|
||||
suggShopId = s.suggestedShopId
|
||||
suggShopName = s.suggestedShopName
|
||||
await reloadDocuments()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
@@ -331,9 +344,23 @@ struct ItemEditView: View {
|
||||
} 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 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()
|
||||
}
|
||||
|
||||
@@ -341,6 +368,9 @@ struct ItemEditView: View {
|
||||
suggWarranty = nil
|
||||
suggPrice = nil
|
||||
suggPriceCandidates = []
|
||||
suggAcquired = nil
|
||||
suggShopId = nil
|
||||
suggShopName = nil
|
||||
}
|
||||
|
||||
/// Eingabe in Hauptwährungseinheit → Rappen/Cent.
|
||||
@@ -383,6 +413,15 @@ struct ItemAddSheet: View {
|
||||
@State private var hasWarranty = false
|
||||
@State private var warranty = Date()
|
||||
@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 error: String?
|
||||
|
||||
@@ -406,6 +445,37 @@ struct ItemAddSheet: View {
|
||||
} footer: {
|
||||
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) } }
|
||||
Section {
|
||||
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy)
|
||||
@@ -414,20 +484,83 @@ struct ItemAddSheet: View {
|
||||
.navigationTitle("Einzelstücke anlegen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.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 {
|
||||
locations = (try? await APIClient.shared.locations()) ?? []
|
||||
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 {
|
||||
busy = true; defer { busy = false }; error = nil
|
||||
do {
|
||||
_ = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
|
||||
count: count, locationId: locationId, shopId: shopId,
|
||||
var sid = 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,
|
||||
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()
|
||||
dismiss()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -631,25 +712,46 @@ struct Item: Codable, Identifiable, Hashable {
|
||||
}
|
||||
|
||||
/// Antwort nach dem Beleg-Upload – mit den aus dem PDF geschätzten Werten.
|
||||
struct ItemDocumentUpload: Codable {
|
||||
let id: Int
|
||||
let suggestedWarrantyUntil: String?
|
||||
let suggestedPriceCents: Int?
|
||||
let suggestedPriceCandidates: [Int]
|
||||
/// Aus einem Beleg-PDF geschätzte Werte (Analyse ohne Speichern).
|
||||
struct DocumentSuggestions: Codable {
|
||||
var suggestedWarrantyUntil: String? = nil
|
||||
var suggestedPriceCents: Int? = nil
|
||||
var suggestedPriceCandidates: [Int] = []
|
||||
var suggestedAcquiredOn: String? = nil
|
||||
var suggestedShopId: Int? = nil
|
||||
var suggestedShopName: String? = nil
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case suggestedWarrantyUntil = "suggested_warranty_until"
|
||||
case suggestedPriceCents = "suggested_price_cents"
|
||||
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 {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(Int.self, forKey: .id)
|
||||
suggestedWarrantyUntil = try c.decodeIfPresent(String.self, forKey: .suggestedWarrantyUntil)
|
||||
suggestedPriceCents = try c.decodeIfPresent(Int.self, forKey: .suggestedPriceCents)
|
||||
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 warrantyUntil: String?
|
||||
let note: String?
|
||||
var priceCents: Int? = nil
|
||||
var currency: String? = nil
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case count, note
|
||||
case count, note, currency
|
||||
case locationId = "location_id"
|
||||
case shopId = "shop_id"
|
||||
case acquiredOn = "acquired_on"
|
||||
case warrantyUntil = "warranty_until"
|
||||
case priceCents = "price_cents"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,12 @@ export const api = {
|
||||
deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }),
|
||||
// Belege je Einzelstück (Rechnung/Garantieschein). Der Upload liefert einen
|
||||
// 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) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
@@ -185,9 +191,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}` : ""}`),
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ function QrImg({ text, size = 60 }) {
|
||||
|
||||
const EMPTY_ADD = {
|
||||
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 [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen
|
||||
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;
|
||||
|
||||
async function load() {
|
||||
@@ -53,23 +57,91 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
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) {
|
||||
e.preventDefault();
|
||||
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,
|
||||
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,
|
||||
warranty_until: addForm.warranty_until || 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();
|
||||
toast("Einzelstück(e) angelegt.");
|
||||
} 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.
|
||||
function savePrice(item, value) {
|
||||
const roh = value.trim().replace(",", ".");
|
||||
@@ -83,12 +155,17 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
try {
|
||||
const res = await api.uploadItemDocument(item.id, file);
|
||||
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({
|
||||
itemId: item.id,
|
||||
date: res.suggested_warranty_until || null,
|
||||
priceCents: res.suggested_price_cents ?? null,
|
||||
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.");
|
||||
@@ -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 = ""; }} />
|
||||
)}
|
||||
</label>
|
||||
{suggestion && suggestion.itemId === it.id
|
||||
&& (suggestion.date || suggestion.priceCents != null) && (
|
||||
{suggestion && suggestion.itemId === it.id && (
|
||||
<div className="alert ok" style={{ marginTop: "var(--sp-2)", flexWrap: "wrap" }}>
|
||||
<Icon name="check" size={16} />
|
||||
<span>
|
||||
Im Beleg erkannt
|
||||
{suggestion.date ? `: Garantie bis ${suggestion.date}` : ""}
|
||||
{suggestion.acquiredOn ? `: gekauft ${suggestion.acquiredOn}` : ""}
|
||||
{suggestion.date ? `${suggestion.acquiredOn ? "," : ":"} Garantie bis ${suggestion.date}` : ""}
|
||||
</span>
|
||||
{suggestion.priceCents != null && (
|
||||
(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>
|
||||
)
|
||||
)}
|
||||
{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" }}
|
||||
onClick={() => {
|
||||
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);
|
||||
}}>
|
||||
onClick={() => applySuggestion(it)}>
|
||||
Übernehmen
|
||||
</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 })} />
|
||||
</label>
|
||||
</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">
|
||||
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.
|
||||
Ein Beleg wird an das erste angelegte Stück gehängt.
|
||||
</p>
|
||||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||
</form>
|
||||
|
||||
85
web/src/components/LocationMinStock.jsx
Normal file
85
web/src/components/LocationMinStock.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user