Compare commits
52 Commits
48d47f1d39
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e2dd3e3eb | ||
|
|
68feb43b3e | ||
|
|
ad3100462b | ||
|
|
e9738e28e7 | ||
|
|
e33c01ec37 | ||
|
|
9a2dc3a482 | ||
|
|
e9a2b70728 | ||
|
|
eddbb3710e | ||
|
|
01c93d8b2c | ||
|
|
e00eef275c | ||
|
|
302e7625e5 | ||
|
|
3de168b598 | ||
|
|
158175f693 | ||
|
|
37bc7663af | ||
|
|
71d52917e4 | ||
|
|
788ef2caf2 | ||
|
|
9f3225113e | ||
|
|
f33054c534 | ||
|
|
0537a2b53f | ||
|
|
54261b98c0 | ||
|
|
eaacfd03e5 | ||
|
|
df65d9583c | ||
|
|
86e0edace8 | ||
|
|
ecc1570100 | ||
|
|
28785b0a10 | ||
|
|
f2c657ff31 | ||
|
|
e214bb76a9 | ||
|
|
da5934948e | ||
|
|
63ee9608af | ||
|
|
6ddf6024f7 | ||
|
|
80aa857bb0 | ||
|
|
d680614526 | ||
|
|
ade3c86747 | ||
|
|
749848360b | ||
|
|
69adc6d910 | ||
|
|
6895d8ec1a | ||
|
|
6f4f8871c3 | ||
|
|
7696f21eaa | ||
|
|
1648d1b917 | ||
|
|
648b8294b8 | ||
|
|
9cc3e325bb | ||
|
|
91adee5333 | ||
|
|
d05727779b | ||
|
|
30e570095f | ||
|
|
2f6c88ce08 | ||
|
|
9b0771ae0e | ||
|
|
1cddb4432c | ||
|
|
55db1d1363 | ||
|
|
3e10e254c8 | ||
|
|
f3bab15362 | ||
|
|
4ed33e221f | ||
|
|
f981f41d94 |
@@ -9,10 +9,58 @@ from fastapi import HTTPException, status
|
|||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .models import Barcode, Category, CategoryTracking, Item, Lot, Product, ProductImage
|
from .models import (
|
||||||
|
Barcode,
|
||||||
|
BaseUnit,
|
||||||
|
Category,
|
||||||
|
CategoryTracking,
|
||||||
|
Item,
|
||||||
|
Lot,
|
||||||
|
Product,
|
||||||
|
ProductImage,
|
||||||
|
)
|
||||||
from .schemas import BarcodeOut, LocationMinStockOut, ProductOut
|
from .schemas import BarcodeOut, LocationMinStockOut, ProductOut
|
||||||
from .services.conversion import KIND_OF_BASE, display_unit_info
|
from .services.conversion import (
|
||||||
from .services.stock import current_stock
|
BASE_OF_KIND,
|
||||||
|
KIND_OF_BASE,
|
||||||
|
display_unit_info,
|
||||||
|
zweit_faktor,
|
||||||
|
)
|
||||||
|
from .services.min_stock import UEBERALL_NAME, lies_ueberall
|
||||||
|
from .services.stock import current_stock, location_subtree_stock_base
|
||||||
|
|
||||||
|
|
||||||
|
def _min_anzeige(
|
||||||
|
product: Product, min_base: float, disp_name: str, disp_factor: float
|
||||||
|
) -> tuple[float, str]:
|
||||||
|
"""Mindestbestand (Basiseinheiten) in der ERFASSTEN Einheit + deren Name.
|
||||||
|
|
||||||
|
Die Erfassungseinheit darf seit der Zweiteinheit artfremd sein („500 g" an
|
||||||
|
einem stueckweise gefuehrten Artikel). ``zweit_faktor`` ist 1,0, solange sie
|
||||||
|
zur eigenen Art gehoert – dieselbe Formel deckt also beide Faelle ab.
|
||||||
|
"""
|
||||||
|
if product.min_stock_in_packages and product.package_size:
|
||||||
|
return min_base / product.package_size, "Pkg"
|
||||||
|
einheit = product.min_stock_unit
|
||||||
|
if einheit is not None:
|
||||||
|
bruecke = zweit_faktor(product, BASE_OF_KIND[einheit.kind])
|
||||||
|
if bruecke is not None:
|
||||||
|
return min_base * bruecke / einheit.factor, einheit.name
|
||||||
|
# Bruecke nachtraeglich entfernt: lieber die Artikeleinheit zeigen als
|
||||||
|
# eine Zahl in einer Einheit, die es fuer diesen Artikel nicht mehr gibt.
|
||||||
|
return min_base / disp_factor, disp_name
|
||||||
|
|
||||||
|
|
||||||
|
def _zweit_felder(out, product: Product) -> None:
|
||||||
|
"""Abgeleitete Zweiteinheit-Felder setzen, damit die Oberflaechen nicht
|
||||||
|
selbst dividieren muessen."""
|
||||||
|
if not product.secondary_base:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ziel = BaseUnit(product.secondary_base)
|
||||||
|
except ValueError:
|
||||||
|
return # unbekannter Altwert – dann eben keine Bruecke
|
||||||
|
out.secondary_factor = zweit_faktor(product, ziel)
|
||||||
|
|
||||||
|
|
||||||
def product_tracking(db: Session, product: Product) -> str:
|
def product_tracking(db: Session, product: Product) -> str:
|
||||||
@@ -53,6 +101,7 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
|
|||||||
for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all()
|
for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all()
|
||||||
]
|
]
|
||||||
out.category_name = product.category.name if product.category else None
|
out.category_name = product.category.name if product.category else None
|
||||||
|
out.group_name = product.group.name if product.group else None
|
||||||
# Bild-Version = Epoch der letzten Bildänderung (identisch zum ETag der
|
# Bild-Version = Epoch der letzten Bildänderung (identisch zum ETag der
|
||||||
# Bild-Route), damit der Client nur geänderte Bilder neu lädt. None = kein Bild.
|
# Bild-Route), damit der Client nur geänderte Bilder neu lädt. None = kein Bild.
|
||||||
bild_ts = (
|
bild_ts = (
|
||||||
@@ -68,25 +117,33 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
|
|||||||
out.unit_name = name
|
out.unit_name = name
|
||||||
out.unit_factor = factor
|
out.unit_factor = factor
|
||||||
|
|
||||||
# Mindestbestand in der Einheit anzeigen, in der er erfasst wurde.
|
# „Ueberall" (Ort NULL) ist der frueher separate Gesamt-Mindestbestand.
|
||||||
if product.min_stock is not None:
|
# ``out.min_stock`` bleibt im Vertrag – Dashboards, Einkaufsliste und App
|
||||||
if product.min_stock_in_packages and product.package_size:
|
# lesen es unveraendert weiter, es kommt nur aus einer anderen Quelle.
|
||||||
out.min_stock_display = product.min_stock / product.package_size
|
out.min_stock = lies_ueberall(product.location_min_stocks)
|
||||||
out.min_stock_unit_label = "Pkg"
|
if out.min_stock is not None:
|
||||||
elif product.min_stock_unit is not None:
|
out.min_stock_display, out.min_stock_unit_label = _min_anzeige(
|
||||||
out.min_stock_display = product.min_stock / product.min_stock_unit.factor
|
product, out.min_stock, name, factor
|
||||||
out.min_stock_unit_label = product.min_stock_unit.name
|
)
|
||||||
else:
|
_zweit_felder(out, product)
|
||||||
out.min_stock_display = product.min_stock / factor
|
|
||||||
out.min_stock_unit_label = name
|
|
||||||
|
|
||||||
out.location_min_stocks = [
|
out.location_min_stocks = [
|
||||||
LocationMinStockOut(
|
LocationMinStockOut(
|
||||||
location_id=e.location_id,
|
location_id=e.location_id,
|
||||||
location_name=e.location.name if e.location else None,
|
location_name=UEBERALL_NAME if e.location_id is None else (
|
||||||
|
e.location.name if e.location else None
|
||||||
|
),
|
||||||
min_stock=e.min_stock,
|
min_stock=e.min_stock,
|
||||||
|
stock=(
|
||||||
|
out.stock
|
||||||
|
if e.location_id is None
|
||||||
|
else location_subtree_stock_base(db, product, e.location_id)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# „Ueberall" zuerst, danach nach Anlagereihenfolge.
|
||||||
|
for e in sorted(
|
||||||
|
product.location_min_stocks, key=lambda x: (x.location_id is not None, x.id)
|
||||||
)
|
)
|
||||||
for e in sorted(product.location_min_stocks, key=lambda x: x.id)
|
|
||||||
]
|
]
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -142,6 +199,7 @@ def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOu
|
|||||||
o.expired_count = expired.get(product.id, 0)
|
o.expired_count = expired.get(product.id, 0)
|
||||||
o.barcodes = [BarcodeOut.model_validate(b) for b in codes.get(product.id, [])]
|
o.barcodes = [BarcodeOut.model_validate(b) for b in codes.get(product.id, [])]
|
||||||
o.category_name = product.category.name if product.category else None
|
o.category_name = product.category.name if product.category else None
|
||||||
|
o.group_name = product.group.name if product.group else None
|
||||||
ts = imgs.get(product.id)
|
ts = imgs.get(product.id)
|
||||||
o.image_version = int(ts.timestamp()) if ts is not None else None
|
o.image_version = int(ts.timestamp()) if ts is not None else None
|
||||||
o.tracking = CategoryTracking(product_tracking(db, product))
|
o.tracking = CategoryTracking(product_tracking(db, product))
|
||||||
@@ -150,23 +208,28 @@ def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOu
|
|||||||
name, factor = display_unit_info(product)
|
name, factor = display_unit_info(product)
|
||||||
o.unit_name = name
|
o.unit_name = name
|
||||||
o.unit_factor = factor
|
o.unit_factor = factor
|
||||||
if product.min_stock is not None:
|
o.min_stock = lies_ueberall(product.location_min_stocks)
|
||||||
if product.min_stock_in_packages and product.package_size:
|
if o.min_stock is not None:
|
||||||
o.min_stock_display = product.min_stock / product.package_size
|
o.min_stock_display, o.min_stock_unit_label = _min_anzeige(
|
||||||
o.min_stock_unit_label = "Pkg"
|
product, o.min_stock, name, factor
|
||||||
elif product.min_stock_unit is not None:
|
)
|
||||||
o.min_stock_display = product.min_stock / product.min_stock_unit.factor
|
_zweit_felder(o, product)
|
||||||
o.min_stock_unit_label = product.min_stock_unit.name
|
|
||||||
else:
|
|
||||||
o.min_stock_display = product.min_stock / factor
|
|
||||||
o.min_stock_unit_label = name
|
|
||||||
o.location_min_stocks = [
|
o.location_min_stocks = [
|
||||||
LocationMinStockOut(
|
LocationMinStockOut(
|
||||||
location_id=e.location_id,
|
location_id=e.location_id,
|
||||||
location_name=e.location.name if e.location else None,
|
location_name=UEBERALL_NAME if e.location_id is None else (
|
||||||
|
e.location.name if e.location else None
|
||||||
|
),
|
||||||
min_stock=e.min_stock,
|
min_stock=e.min_stock,
|
||||||
|
stock=(
|
||||||
|
o.stock
|
||||||
|
if e.location_id is None
|
||||||
|
else location_subtree_stock_base(db, product, e.location_id)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for e in sorted(
|
||||||
|
product.location_min_stocks, key=lambda x: (x.location_id is not None, x.id)
|
||||||
)
|
)
|
||||||
for e in sorted(product.location_min_stocks, key=lambda x: x.id)
|
|
||||||
]
|
]
|
||||||
out.append(o)
|
out.append(o)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from .seed import (
|
|||||||
ensure_first_admin,
|
ensure_first_admin,
|
||||||
)
|
)
|
||||||
from .services.group_codes import backfill as backfill_group_codes
|
from .services.group_codes import backfill as backfill_group_codes
|
||||||
|
from .services.min_stock import migriere_mindestbestaende
|
||||||
from .services.stock import consolidate_duplicate_lots
|
from .services.stock import consolidate_duplicate_lots
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -144,6 +145,10 @@ def _ensure_schema() -> None:
|
|||||||
"REFERENCES units(id) ON DELETE SET NULL",
|
"REFERENCES units(id) ON DELETE SET NULL",
|
||||||
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
|
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
|
||||||
"REFERENCES units(id) ON DELETE SET NULL",
|
"REFERENCES units(id) ON DELETE SET NULL",
|
||||||
|
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS package_size DOUBLE PRECISION",
|
||||||
|
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS package_label VARCHAR(32)",
|
||||||
|
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
|
||||||
|
"NOT NULL DEFAULT FALSE",
|
||||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
|
||||||
"REFERENCES units(id) ON DELETE SET NULL",
|
"REFERENCES units(id) ON DELETE SET NULL",
|
||||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
|
||||||
@@ -188,6 +193,22 @@ def _ensure_schema() -> None:
|
|||||||
# Kaufpreis (in Rappen/Cent) und Währung am Einzelstück.
|
# Kaufpreis (in Rappen/Cent) und Währung am Einzelstück.
|
||||||
"ALTER TABLE items ADD COLUMN IF NOT EXISTS price_cents INTEGER",
|
"ALTER TABLE items ADD COLUMN IF NOT EXISTS price_cents INTEGER",
|
||||||
"ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)",
|
"ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)",
|
||||||
|
# Mindestbestände hängen nur noch an Lagerorten; location_id NULL ist
|
||||||
|
# „Überall". DROP NOT NULL ist auf einer bereits nullable Spalte ein
|
||||||
|
# No-Op, der Aufruf also wiederholbar.
|
||||||
|
"ALTER TABLE product_location_min_stock ALTER COLUMN location_id DROP NOT NULL",
|
||||||
|
"ALTER TABLE group_location_min_stock ALTER COLUMN location_id DROP NOT NULL",
|
||||||
|
# Postgres zählt NULLs in UNIQUE als verschieden – die vorhandene
|
||||||
|
# Beschränkung verhindert also keine zwei „Überall"-Zeilen. Teilindex.
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_prod_ueberall_min "
|
||||||
|
"ON product_location_min_stock (product_id) WHERE location_id IS NULL",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_group_ueberall_min "
|
||||||
|
"ON group_location_min_stock (group_id) WHERE location_id IS NULL",
|
||||||
|
# Zweiteinheit am Artikel („3 Stück ≙ 250 g") – Brücke zwischen den
|
||||||
|
# Einheiten-Arten. Leer = wie bisher, strikt getrennt.
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_base VARCHAR(16)",
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_count DOUBLE PRECISION",
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_amount DOUBLE PRECISION",
|
||||||
]
|
]
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
|
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
|
||||||
@@ -222,6 +243,9 @@ async def lifespan(app: FastAPI):
|
|||||||
ensure_first_admin(db)
|
ensure_first_admin(db)
|
||||||
# Codes bestehender Gruppen-Zuordnungen nachziehen.
|
# Codes bestehender Gruppen-Zuordnungen nachziehen.
|
||||||
backfill_group_codes(db)
|
backfill_group_codes(db)
|
||||||
|
# Gesamt-Mindestbestände zu „Überall"-Zeilen machen und alle Werte auf
|
||||||
|
# Basiseinheiten umstellen (einmalig, mit Merker in den Einstellungen).
|
||||||
|
migriere_mindestbestaende(db)
|
||||||
# Bereits vorhandene Dubletten (gleicher Artikel + MHD + Lagerort)
|
# Bereits vorhandene Dubletten (gleicher Artikel + MHD + Lagerort)
|
||||||
# einmalig zusammenfassen – ab jetzt geschieht das beim Umlagern selbst.
|
# einmalig zusammenfassen – ab jetzt geschieht das beim Umlagern selbst.
|
||||||
consolidate_duplicate_lots(db)
|
consolidate_duplicate_lots(db)
|
||||||
|
|||||||
@@ -6,16 +6,20 @@ from datetime import date, datetime, timezone
|
|||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean,
|
Boolean,
|
||||||
|
Column,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum,
|
Enum,
|
||||||
Float,
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
LargeBinary,
|
LargeBinary,
|
||||||
String,
|
String,
|
||||||
|
Table,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -119,6 +123,27 @@ class User(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
# Ober-/Untergruppen: eine Gruppe darf zu MEHREREN Obergruppen gehoeren, nicht
|
||||||
|
# nur zu einer. „Grillwurst" haengt unter „Wurst" UND unter „Grillgut" – mit
|
||||||
|
# einem Baum (ein Elternteil je Gruppe) waere genau das nicht abbildbar. Der
|
||||||
|
# Graph darf deshalb Rauten haben, aber keine Ringe (Pruefung in routers/groups).
|
||||||
|
group_parents = Table(
|
||||||
|
"group_parents",
|
||||||
|
Base.metadata,
|
||||||
|
Column(
|
||||||
|
"child_id", ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
),
|
||||||
|
# Eigener Index: der zusammengesetzte Primaerschluessel deckt nur die Suche
|
||||||
|
# nach child_id ab, gefragt wird aber genauso oft „wer haengt unter X?".
|
||||||
|
Column(
|
||||||
|
"parent_id",
|
||||||
|
ForeignKey("groups.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Group(Base):
|
class Group(Base):
|
||||||
__tablename__ = "groups"
|
__tablename__ = "groups"
|
||||||
|
|
||||||
@@ -129,6 +154,17 @@ class Group(Base):
|
|||||||
min_stock_unit_id: Mapped[int | None] = mapped_column(
|
min_stock_unit_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("units.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("units.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
# Gruppen-Gebinde als Richtwert: wie viele Basiseinheiten „1 Packung/Glas"
|
||||||
|
# der Gruppe zählt. Nötig, weil die Produkte einer Gruppe unterschiedlich
|
||||||
|
# große Packungen haben können (Pesto 99 g vs. 160 g) – die Gruppe legt einen
|
||||||
|
# gemeinsamen Richtwert fest. NULL = keiner definiert.
|
||||||
|
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
# true = ``min_stock`` (und die je-Ort-Werte) sind in diesem Gebinde erfasst,
|
||||||
|
# sonst in ``min_stock_unit`` (bzw. Basiseinheit).
|
||||||
|
min_stock_in_packages: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False
|
||||||
|
)
|
||||||
|
|
||||||
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()
|
||||||
@@ -136,6 +172,28 @@ class Group(Base):
|
|||||||
cascade="all, delete-orphan"
|
cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Obergruppen dieser Gruppe (mehrere moeglich) bzw. die Gruppen, die
|
||||||
|
# umgekehrt unter dieser haengen. Bestand und Mindestbestand einer Gruppe
|
||||||
|
# zaehlen immer den ganzen Untergraphen mit – siehe services/groups.py.
|
||||||
|
# ``lazy="selectin"``: /groups laedt alle Gruppen auf einmal – so faellt je
|
||||||
|
# Ebene EINE Nachladeabfrage an statt einer je Gruppe.
|
||||||
|
parents: Mapped[list["Group"]] = relationship(
|
||||||
|
"Group",
|
||||||
|
secondary=group_parents,
|
||||||
|
primaryjoin=lambda: Group.id == group_parents.c.child_id,
|
||||||
|
secondaryjoin=lambda: Group.id == group_parents.c.parent_id,
|
||||||
|
back_populates="children",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
children: Mapped[list["Group"]] = relationship(
|
||||||
|
"Group",
|
||||||
|
secondary=group_parents,
|
||||||
|
primaryjoin=lambda: Group.id == group_parents.c.parent_id,
|
||||||
|
secondaryjoin=lambda: Group.id == group_parents.c.child_id,
|
||||||
|
back_populates="parents",
|
||||||
|
lazy="selectin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Lagerorte tragen einen zufälligen 10-Zeichen-Code als ID statt einer
|
# Lagerorte tragen einen zufälligen 10-Zeichen-Code als ID statt einer
|
||||||
# fortlaufenden Zahl. So kollidieren Sicherung/Import zwischen zwei Instanzen
|
# fortlaufenden Zahl. So kollidieren Sicherung/Import zwischen zwei Instanzen
|
||||||
@@ -214,6 +272,18 @@ class Product(Base):
|
|||||||
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
# Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", …
|
# Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", …
|
||||||
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
# Zweiteinheit: Bruecke zwischen den Einheiten-ARTEN fuer DIESEN Artikel.
|
||||||
|
# „3 Stueck ≙ 250 g" – gespeichert als das eingegebene PAAR, damit beim
|
||||||
|
# naechsten Oeffnen genau das wieder dasteht und nicht „83,333 g je Stueck".
|
||||||
|
# ``secondary_count`` zaehlt in der Basiseinheit des Artikels,
|
||||||
|
# ``secondary_amount`` in ``secondary_base``. NULL = keine Bruecke, dann
|
||||||
|
# bleibt es bei der strikten Trennung der Arten (services/conversion.py).
|
||||||
|
#
|
||||||
|
# String statt Enum wie bei ``Category.tracking``: ein natives Postgres-Enum
|
||||||
|
# in der handgeschriebenen Migration nachzuziehen waere unnoetig heikel.
|
||||||
|
secondary_base: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
secondary_count: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
secondary_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
# Welche MHD-Genauigkeit bei diesem Produkt sinnvoll ist. Steuert nur die
|
# Welche MHD-Genauigkeit bei diesem Produkt sinnvoll ist. Steuert nur die
|
||||||
# Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt).
|
# Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt).
|
||||||
date_precision: Mapped[str] = mapped_column(
|
date_precision: Mapped[str] = mapped_column(
|
||||||
@@ -596,10 +666,18 @@ class ItemDocument(Base):
|
|||||||
|
|
||||||
|
|
||||||
class ProductLocationMinStock(Base):
|
class ProductLocationMinStock(Base):
|
||||||
"""Mindestbestand eines Produkts an EINEM Lagerort (in Artikeleinheiten).
|
"""Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||||||
|
|
||||||
Zusätzlich zum globalen ``Product.min_stock``: so laesst sich derselbe Artikel
|
Der einzige Ort, an dem Artikel-Mindestbestaende stehen. ``location_id``
|
||||||
an mehreren Orten getrennt fuehren (z.B. 5 zuhause, 3 im Ferienhaus).
|
NULL heisst „Ueberall" – egal wo, Hauptsache die Menge ist im Haus; das war
|
||||||
|
frueher das separate Feld ``Product.min_stock``. Damit ist „Ueberall" die
|
||||||
|
Wurzel ueber allen Lagerorten und wird von derselben Verrechnung erfasst wie
|
||||||
|
verschachtelte Orte (siehe routers/views.py).
|
||||||
|
|
||||||
|
Mengen stehen in BASISEINHEITEN (g/ml/Stueck), nicht in Artikeleinheiten:
|
||||||
|
sonst bedeutet ein gespeicherter Wert etwas anderes, sobald sich die
|
||||||
|
Packungsgroesse aendert. In welcher Einheit er angezeigt und eingetippt
|
||||||
|
wird, sagen ``Product.min_stock_unit_id`` und ``min_stock_in_packages``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "product_location_min_stock"
|
__tablename__ = "product_location_min_stock"
|
||||||
@@ -608,19 +686,35 @@ class ProductLocationMinStock(Base):
|
|||||||
product_id: Mapped[int] = mapped_column(
|
product_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
|
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
location_id: Mapped[str] = mapped_column(
|
location_id: Mapped[str | None] = mapped_column(
|
||||||
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
|
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True
|
||||||
)
|
)
|
||||||
# In Artikeleinheiten (Packungen/Stueck), wie in der Produktliste gezaehlt.
|
|
||||||
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
|
||||||
location: Mapped[Location] = relationship()
|
location: Mapped[Location | None] = relationship()
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint("product_id", "location_id", name="uq_prod_loc_min"),)
|
__table_args__ = (
|
||||||
|
UniqueConstraint("product_id", "location_id", name="uq_prod_loc_min"),
|
||||||
|
# Postgres zaehlt NULLs in UNIQUE als verschieden – der Constraint oben
|
||||||
|
# verhindert also KEINE zwei „Ueberall"-Zeilen. Dafuer ein Teilindex.
|
||||||
|
Index(
|
||||||
|
"uq_prod_ueberall_min",
|
||||||
|
"product_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("location_id IS NULL"),
|
||||||
|
sqlite_where=text("location_id IS NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GroupLocationMinStock(Base):
|
class GroupLocationMinStock(Base):
|
||||||
"""Mindestbestand einer Gruppe an EINEM Lagerort (in der Gruppen-Einheit)."""
|
"""Mindestbestand einer Gruppe an EINEM Lagerort (in Basiseinheiten).
|
||||||
|
|
||||||
|
``location_id`` NULL = „Ueberall", genau wie bei
|
||||||
|
:class:`ProductLocationMinStock`. Auch hier Basiseinheiten – nur so lassen
|
||||||
|
sich Ober- und Untergruppe gegeneinander verrechnen, wenn die eine in
|
||||||
|
Kilogramm und die andere in Glaesern erfasst ist.
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "group_location_min_stock"
|
__tablename__ = "group_location_min_stock"
|
||||||
|
|
||||||
@@ -628,11 +722,20 @@ class GroupLocationMinStock(Base):
|
|||||||
group_id: Mapped[int] = mapped_column(
|
group_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
|
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
location_id: Mapped[str] = mapped_column(
|
location_id: Mapped[str | None] = mapped_column(
|
||||||
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
|
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True
|
||||||
)
|
)
|
||||||
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
|
||||||
location: Mapped[Location] = relationship()
|
location: Mapped[Location | None] = relationship()
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint("group_id", "location_id", name="uq_group_loc_min"),)
|
__table_args__ = (
|
||||||
|
UniqueConstraint("group_id", "location_id", name="uq_group_loc_min"),
|
||||||
|
Index(
|
||||||
|
"uq_group_ueberall_min",
|
||||||
|
"group_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("location_id IS NULL"),
|
||||||
|
sqlite_where=text("location_id IS NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -24,22 +25,67 @@ _UNIT_TO_BASE: dict[str, tuple[str, float]] = {
|
|||||||
|
|
||||||
_QUANTITY_RE = re.compile(r"([\d]+(?:[.,]\d+)?)\s*(kg|mg|g|dl|cl|ml|l)\b", re.IGNORECASE)
|
_QUANTITY_RE = re.compile(r"([\d]+(?:[.,]\d+)?)\s*(kg|mg|g|dl|cl|ml|l)\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
#: „3 x 80 g", „6 × 1,5 l", „4 Stück à 125 g" – der Multiplikator steht vorn,
|
||||||
|
#: dazwischen darf ein Zählwort stehen. Bewusst eng gefasst: ein zu weites
|
||||||
|
#: Muster macht aus „12 Eier à 53 g" schnell Unsinn, und eine nicht erkannte
|
||||||
|
#: Stückzahl ist besser als eine falsch geratene.
|
||||||
|
_MULTI_RE = re.compile(
|
||||||
|
r"(?P<anzahl>\d+)\s*(?:st(?:ü|ue)ck|stk\.?|pcs?|st\.?)?\s*"
|
||||||
|
r"(?:[x×*]|à|a)\s*"
|
||||||
|
r"(?P<menge>\d+(?:[.,]\d+)?)\s*(?P<einheit>kg|mg|g|dl|cl|ml|l)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OffMenge(NamedTuple):
|
||||||
|
"""Was aus dem OFF-Feld ``quantity`` herauszulesen ist."""
|
||||||
|
base_unit: str # "gram" | "milliliter" | "piece"
|
||||||
|
package_size: float | None # Gesamtfuellmenge des Gebindes, in Basiseinheiten
|
||||||
|
stueck: float | None # zaehlbare Teile im Gebinde (nur bei „3 x 80 g")
|
||||||
|
einzelmenge: float | None # Fuellmenge EINES Teils (die 80 g)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gebinde(quantity: str | None) -> OffMenge:
|
||||||
|
"""Gesamtmenge UND einen etwaigen Multiplikator aus ``quantity`` lesen.
|
||||||
|
|
||||||
|
„3 x 80 g" ist nicht dasselbe wie „240 g": die Packung enthaelt drei
|
||||||
|
zaehlbare Riegel. Dieser Multiplikator ging bisher verloren, weil das alte
|
||||||
|
Muster den ERSTEN Zahl-Einheit-Treffer nahm – also die 80.
|
||||||
|
"""
|
||||||
|
if not quantity:
|
||||||
|
return OffMenge("piece", None, None, None)
|
||||||
|
|
||||||
|
multi = _MULTI_RE.search(quantity)
|
||||||
|
if multi is not None:
|
||||||
|
anzahl = float(multi.group("anzahl"))
|
||||||
|
menge = float(multi.group("menge").replace(",", "."))
|
||||||
|
base_unit, factor = _UNIT_TO_BASE[multi.group("einheit").lower()]
|
||||||
|
einzel = round(menge * factor, 3)
|
||||||
|
gesamt = round(anzahl * einzel, 3)
|
||||||
|
if anzahl > 0 and gesamt > 0:
|
||||||
|
return OffMenge(base_unit, gesamt, anzahl, einzel)
|
||||||
|
return OffMenge("piece", None, None, None)
|
||||||
|
|
||||||
|
einfach = _QUANTITY_RE.search(quantity)
|
||||||
|
if einfach is None:
|
||||||
|
return OffMenge("piece", None, None, None)
|
||||||
|
amount = float(einfach.group(1).replace(",", "."))
|
||||||
|
base_unit, factor = _UNIT_TO_BASE[einfach.group(2).lower()]
|
||||||
|
gesamt = round(amount * factor, 3)
|
||||||
|
return OffMenge(base_unit, gesamt if gesamt > 0 else None, None, None)
|
||||||
|
|
||||||
|
|
||||||
def parse_quantity(quantity: str | None) -> tuple[str, float | None]:
|
def parse_quantity(quantity: str | None) -> tuple[str, float | None]:
|
||||||
"""Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'.
|
"""Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'.
|
||||||
|
|
||||||
Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000),
|
Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000),
|
||||||
'1,5 l' -> ('milliliter', 1500), '6 Stück' -> ('piece', None).
|
'1,5 l' -> ('milliliter', 1500), '6 Stück' -> ('piece', None).
|
||||||
|
|
||||||
|
Der schmale Vertrag von frueher; die volle Auskunft (mit Multiplikator)
|
||||||
|
liefert ``parse_gebinde``.
|
||||||
"""
|
"""
|
||||||
if not quantity:
|
menge = parse_gebinde(quantity)
|
||||||
return "piece", None
|
return menge.base_unit, menge.package_size
|
||||||
match = _QUANTITY_RE.search(quantity)
|
|
||||||
if not match:
|
|
||||||
return "piece", None
|
|
||||||
amount = float(match.group(1).replace(",", "."))
|
|
||||||
base_unit, factor = _UNIT_TO_BASE[match.group(2).lower()]
|
|
||||||
package_size = round(amount * factor, 3)
|
|
||||||
return base_unit, (package_size if package_size > 0 else None)
|
|
||||||
|
|
||||||
|
|
||||||
def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
|
def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
|
||||||
@@ -85,16 +131,24 @@ def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
|
|||||||
categories = product.get("categories") or ""
|
categories = product.get("categories") or ""
|
||||||
category_tags = product.get("categories_tags") or []
|
category_tags = product.get("categories_tags") or []
|
||||||
|
|
||||||
base_unit, package_size = parse_quantity(product.get("quantity"))
|
menge = parse_gebinde(product.get("quantity"))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"barcode": barcode,
|
"barcode": barcode,
|
||||||
"name": name,
|
"name": name,
|
||||||
"brand": (product.get("brands") or "").strip() or None,
|
"brand": (product.get("brands") or "").strip() or None,
|
||||||
"image_url": product.get("image_front_url") or product.get("image_url") or None,
|
"image_url": product.get("image_front_url") or product.get("image_url") or None,
|
||||||
"base_unit": base_unit,
|
"base_unit": menge.base_unit,
|
||||||
"package_size": package_size,
|
"package_size": menge.package_size,
|
||||||
"quantity_text": product.get("quantity"),
|
"quantity_text": product.get("quantity"),
|
||||||
|
# „3 x 80 g": die Packung enthaelt drei zaehlbare Teile. Als VORSCHLAG
|
||||||
|
# fuer die Zweiteinheit mitgegeben („240 g ≙ 3 Stueck"), nicht gesetzt –
|
||||||
|
# bestaetigen soll es der Nutzer, OFF-Angaben sind nicht immer sauber.
|
||||||
|
"secondary_base": "piece" if menge.stueck else None,
|
||||||
|
"secondary_count": menge.package_size if menge.stueck else None,
|
||||||
|
"secondary_amount": menge.stueck,
|
||||||
|
# Nur fuer den Hinweistext „3 × 80 g".
|
||||||
|
"unit_amount": menge.einzelmenge,
|
||||||
"category_suggestion": categories.split(",")[0].strip() if categories else None,
|
"category_suggestion": categories.split(",")[0].strip() if categories else None,
|
||||||
"category_tags": category_tags,
|
"category_tags": category_tags,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from ..schemas import (
|
|||||||
from ..services.conversion import article_unit
|
from ..services.conversion import article_unit
|
||||||
from ..services.stock import current_stock
|
from ..services.stock import current_stock
|
||||||
from .settings import get_expiry_warning_days
|
from .settings import get_expiry_warning_days
|
||||||
|
from .views import einkaufsliste_artikel, einkaufsliste_gruppen
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
@@ -348,19 +349,10 @@ def stats(
|
|||||||
elif zustand == "soon":
|
elif zustand == "soon":
|
||||||
bald += 1
|
bald += 1
|
||||||
|
|
||||||
# Einkaufsbedarf: Produkte und Gruppen unter Mindestbestand.
|
# Einkaufsbedarf: so viele Zeilen, wie auf der Einkaufsliste stehen –
|
||||||
bedarf = 0
|
# inklusive der Verrechnung zwischen „Überall", Lagerorten und Unter-
|
||||||
for product in db.query(Product).filter(Product.min_stock.isnot(None), Product.min_stock > 0):
|
# gruppen. Frueher wurde hier eigenstaendig gezaehlt und wich deshalb ab.
|
||||||
if current_stock(db, product.id) < product.min_stock:
|
bedarf = len(einkaufsliste_artikel(db)) + len(einkaufsliste_gruppen(db))
|
||||||
bedarf += 1
|
|
||||||
for group in db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0):
|
|
||||||
unit = group.min_stock_unit
|
|
||||||
produkte = list(group.products)
|
|
||||||
summe = float(sum(current_stock(db, p.id) for p in produkte))
|
|
||||||
if unit is not None:
|
|
||||||
summe /= unit.factor or 1.0
|
|
||||||
if summe < group.min_stock:
|
|
||||||
bedarf += 1
|
|
||||||
|
|
||||||
return DashboardStats(
|
return DashboardStats(
|
||||||
products_in_stock=len(bestand_produkte),
|
products_in_stock=len(bestand_produkte),
|
||||||
@@ -386,17 +378,45 @@ def expiry_split(
|
|||||||
|
|
||||||
@router.get("/by-category", response_model=list[CategoryShare])
|
@router.get("/by-category", response_model=list[CategoryShare])
|
||||||
def by_category(
|
def by_category(
|
||||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
depth: int | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
) -> list[CategoryShare]:
|
) -> list[CategoryShare]:
|
||||||
"""Artikeleinheiten je Kategorie, zusätzlich nach Ablaufzustand aufgeteilt."""
|
"""Artikeleinheiten je Kategorie, zusätzlich nach Ablaufzustand aufgeteilt.
|
||||||
|
|
||||||
|
``depth`` fasst tiefe Unterkategorien zu ihrer Oberkategorie auf der
|
||||||
|
gewünschten Stufe zusammen (1 = oberste Ebene). Ohne ``depth`` zählt jede
|
||||||
|
Kategorie so, wie sie dem Artikel zugeordnet ist (feinste Auflösung).
|
||||||
|
"""
|
||||||
heute = date.today()
|
heute = date.today()
|
||||||
warnfrist = get_expiry_warning_days(db)
|
warnfrist = get_expiry_warning_days(db)
|
||||||
namen = {c.id: c.name for c in db.query(Category).all()}
|
kategorien = db.query(Category).all()
|
||||||
|
namen = {c.id: c.name for c in kategorien}
|
||||||
|
eltern = {c.id: c.parent_id for c in kategorien}
|
||||||
|
|
||||||
|
# Kategorie auf die gewünschte Tiefe hochrollen (Vorfahr auf Stufe ``depth``).
|
||||||
|
rollup_cache: dict[int | None, int | None] = {}
|
||||||
|
|
||||||
|
def rollup(cid: int | None) -> int | None:
|
||||||
|
if depth is None or cid is None:
|
||||||
|
return cid
|
||||||
|
if cid in rollup_cache:
|
||||||
|
return rollup_cache[cid]
|
||||||
|
pfad: list[int] = []
|
||||||
|
cur, gesehen = cid, set()
|
||||||
|
while cur is not None and cur not in gesehen:
|
||||||
|
pfad.append(cur)
|
||||||
|
gesehen.add(cur)
|
||||||
|
cur = eltern.get(cur)
|
||||||
|
pfad.reverse() # Wurzel zuerst
|
||||||
|
ziel = pfad[min(max(depth, 1) - 1, len(pfad) - 1)]
|
||||||
|
rollup_cache[cid] = ziel
|
||||||
|
return ziel
|
||||||
|
|
||||||
leer = lambda: {"article_units": 0.0, "ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0}
|
leer = lambda: {"article_units": 0.0, "ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0}
|
||||||
eimer: dict[int | None, dict] = defaultdict(leer)
|
eimer: dict[int | None, dict] = defaultdict(leer)
|
||||||
for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
||||||
topf = eimer[product.category_id]
|
topf = eimer[rollup(product.category_id)]
|
||||||
topf["article_units"] += menge
|
topf["article_units"] += menge
|
||||||
topf[zustand] += menge
|
topf[zustand] += menge
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,53 @@ from ..schemas import (
|
|||||||
LocationMinStockOut,
|
LocationMinStockOut,
|
||||||
ProductBarcodeOut,
|
ProductBarcodeOut,
|
||||||
)
|
)
|
||||||
from ..services.conversion import BASE_OF_KIND
|
from ..services import gruppen as gruppen_graph
|
||||||
from ..services.stock import current_stock
|
from ..services.conversion import group_min_context
|
||||||
|
from ..services.min_stock import UEBERALL_NAME, lies_ueberall, schreibe_ueberall
|
||||||
|
from ..services.stock import (
|
||||||
|
summe_bestand_gewichtet,
|
||||||
|
summe_bestand_im_subtree_gewichtet,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/groups", tags=["groups"])
|
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||||
|
|
||||||
|
|
||||||
|
def _obergruppen_setzen(db: Session, group: Group, parent_ids: list[int]) -> None:
|
||||||
|
"""Obergruppen einer Gruppe ersetzen – mit Ringschutz.
|
||||||
|
|
||||||
|
Ohne die Pruefung entstuende ein Ring, und jede Bestands- oder
|
||||||
|
Bedarfsrechnung liefe im Kreis. Gleiche Absicherung wie beim Umhaengen einer
|
||||||
|
Kategorie (routers/categories.py), nur ueber eine Nachfahren-MENGE, weil
|
||||||
|
Gruppen ein Graph und kein Baum sind.
|
||||||
|
"""
|
||||||
|
# Die Nachfahren aendern sich durch das Setzen von OBERgruppen nicht –
|
||||||
|
# deshalb einmal vor der Schleife bestimmen.
|
||||||
|
verboten = gruppen_graph.nachfahren_ids(group) | {group.id}
|
||||||
|
gewuenscht: list[Group] = []
|
||||||
|
for pid in dict.fromkeys(parent_ids): # Reihenfolge halten, Dubletten raus
|
||||||
|
if pid in verboten:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"Eine Gruppe kann nicht sich selbst oder einer ihrer "
|
||||||
|
"Untergruppen untergeordnet werden.",
|
||||||
|
)
|
||||||
|
eltern = db.get(Group, pid)
|
||||||
|
if eltern is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Obergruppe nicht gefunden")
|
||||||
|
gewuenscht.append(eltern)
|
||||||
|
group.parents = gewuenscht
|
||||||
|
|
||||||
|
|
||||||
def _group_to_out(db: Session, group: Group) -> GroupOut:
|
def _group_to_out(db: Session, group: Group) -> GroupOut:
|
||||||
out = GroupOut.model_validate(group)
|
out = GroupOut.model_validate(group)
|
||||||
|
# ``min_stock`` bleibt nach aussen in der Erfassungseinheit der Gruppe,
|
||||||
|
# gespeichert ist die „Ueberall"-Zeile aber in Basiseinheiten.
|
||||||
|
ueberall = lies_ueberall(group.location_min_stocks)
|
||||||
products = db.query(Product).filter(Product.group_id == group.id).all()
|
products = db.query(Product).filter(Product.group_id == group.id).all()
|
||||||
out.product_count = len(products)
|
out.parent_ids = [p.id for p in group.parents]
|
||||||
|
out.child_ids = sorted(c.id for c in group.children)
|
||||||
|
out.direct_product_count = len(products)
|
||||||
|
out.product_count = len(gruppen_graph.produkte(group))
|
||||||
# Zu welchem Artikel gehoert ein Code? Der Gruppen-Code entsteht beim
|
# Zu welchem Artikel gehoert ein Code? Der Gruppen-Code entsteht beim
|
||||||
# Zuordnen automatisch; die Herkunft soll trotzdem sichtbar bleiben.
|
# Zuordnen automatisch; die Herkunft soll trotzdem sichtbar bleiben.
|
||||||
artikel_zu_code: dict[str, Product] = {}
|
artikel_zu_code: dict[str, Product] = {}
|
||||||
@@ -67,25 +104,43 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
|
|||||||
)
|
)
|
||||||
out.product_barcodes = product_codes
|
out.product_barcodes = product_codes
|
||||||
|
|
||||||
|
# Bestand in derselben Einheit, in der auch der Mindestbestand erfasst ist
|
||||||
|
# (Gruppen-Gebinde ODER verwaltete Einheit) – so ist beides vergleichbar.
|
||||||
|
ctx = group_min_context(group)
|
||||||
|
# Gewichtet: artfremde Artikel zaehlen ueber ihre Zweiteinheit mit.
|
||||||
|
stock_base = summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren)
|
||||||
|
out.stock = round(stock_base / ctx.divisor, 3)
|
||||||
|
out.min_stock = None if ueberall is None else round(ueberall / ctx.divisor, 3)
|
||||||
unit = group.min_stock_unit
|
unit = group.min_stock_unit
|
||||||
if unit is not None:
|
if unit is not None:
|
||||||
# Bestand nur über Produkte gleicher Art, in der Gruppen-Einheit ausgedrückt.
|
|
||||||
base = BASE_OF_KIND[unit.kind]
|
|
||||||
matching = [p for p in products if p.base_unit == base]
|
|
||||||
stock_base = float(sum(current_stock(db, p.id) for p in matching))
|
|
||||||
out.stock = stock_base / unit.factor
|
|
||||||
out.min_stock_unit_name = unit.name
|
out.min_stock_unit_name = unit.name
|
||||||
|
out.min_stock_unit_factor = unit.factor
|
||||||
out.kind = unit.kind.value
|
out.kind = unit.kind.value
|
||||||
else:
|
|
||||||
out.stock = float(sum(current_stock(db, p.id) for p in products))
|
# Bestand je Lagerort (inkl. Unterorte) in derselben Einheit wie out.stock.
|
||||||
|
# Bestand je Lagerort in BASISEINHEITEN – wie ``min_stock`` in denselben
|
||||||
|
# Zeilen. Nur ``out.stock`` oben rechnet in der Erfassungseinheit.
|
||||||
|
def _loc_stock(loc_id: str | None) -> float:
|
||||||
|
# „Ueberall" (Ort NULL) zaehlt den Gesamtbestand – inkl. Chargen, die
|
||||||
|
# (noch) an keinem Lagerort liegen.
|
||||||
|
total = (
|
||||||
|
stock_base
|
||||||
|
if loc_id is None
|
||||||
|
else summe_bestand_im_subtree_gewichtet(db, ctx.matching, loc_id, ctx.faktoren)
|
||||||
|
)
|
||||||
|
return round(total, 3)
|
||||||
|
|
||||||
out.location_min_stocks = [
|
out.location_min_stocks = [
|
||||||
LocationMinStockOut(
|
LocationMinStockOut(
|
||||||
location_id=e.location_id,
|
location_id=e.location_id,
|
||||||
location_name=e.location.name if e.location else None,
|
location_name=UEBERALL_NAME if e.location_id is None else (
|
||||||
|
e.location.name if e.location else None
|
||||||
|
),
|
||||||
min_stock=e.min_stock,
|
min_stock=e.min_stock,
|
||||||
|
stock=_loc_stock(e.location_id),
|
||||||
)
|
)
|
||||||
for e in sorted(group.location_min_stocks, key=lambda x: x.id)
|
# „Ueberall" zuerst, danach nach Anlagereihenfolge.
|
||||||
|
for e in sorted(group.location_min_stocks, key=lambda x: (x.location_id is not None, x.id))
|
||||||
]
|
]
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -105,7 +160,11 @@ def set_group_location_min_stock(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_admin),
|
||||||
) -> GroupOut:
|
) -> GroupOut:
|
||||||
"""Gruppen-Mindestbestände je Lagerort ersetzen (Menge 0 = Eintrag entfällt)."""
|
"""Gruppen-Mindestbestände je Lagerort ersetzen (Menge 0 = Eintrag entfällt).
|
||||||
|
|
||||||
|
``location_id = null`` ist „Überall" und damit ein Ort wie jeder andere –
|
||||||
|
er ersetzt den frueheren Gesamt-Mindestbestand. Mengen in Basiseinheiten.
|
||||||
|
"""
|
||||||
group = db.get(Group, group_id)
|
group = db.get(Group, group_id)
|
||||||
if group is None:
|
if group is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||||||
@@ -113,11 +172,12 @@ def set_group_location_min_stock(
|
|||||||
db.query(GroupLocationMinStock).filter(
|
db.query(GroupLocationMinStock).filter(
|
||||||
GroupLocationMinStock.group_id == group_id
|
GroupLocationMinStock.group_id == group_id
|
||||||
).delete()
|
).delete()
|
||||||
gesehen: set[int] = set()
|
# None (= Überall) ist ein eigener Schluessel in der Dublettenpruefung.
|
||||||
|
gesehen: set[str | None] = set()
|
||||||
for eintrag in payload:
|
for eintrag in payload:
|
||||||
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
|
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
|
||||||
continue
|
continue
|
||||||
if db.get(Location, eintrag.location_id) is None:
|
if eintrag.location_id is not None and db.get(Location, eintrag.location_id) is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||||
gesehen.add(eintrag.location_id)
|
gesehen.add(eintrag.location_id)
|
||||||
db.add(GroupLocationMinStock(
|
db.add(GroupLocationMinStock(
|
||||||
@@ -140,10 +200,21 @@ def create_group(
|
|||||||
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
|
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
|
||||||
group = Group(
|
group = Group(
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
min_stock=payload.min_stock,
|
|
||||||
min_stock_unit_id=payload.min_stock_unit_id,
|
min_stock_unit_id=payload.min_stock_unit_id,
|
||||||
|
package_size=payload.package_size,
|
||||||
|
package_label=payload.package_label,
|
||||||
|
min_stock_in_packages=payload.min_stock_in_packages,
|
||||||
)
|
)
|
||||||
db.add(group)
|
db.add(group)
|
||||||
|
# Erst flushen: die Ringpruefung in _obergruppen_setzen braucht group.id.
|
||||||
|
db.flush()
|
||||||
|
_obergruppen_setzen(db, group, payload.parent_ids)
|
||||||
|
# ``min_stock`` kommt in der Erfassungseinheit der Gruppe, gespeichert wird
|
||||||
|
# in Basiseinheiten – deshalb ueber den Divisor.
|
||||||
|
if payload.min_stock is not None:
|
||||||
|
schreibe_ueberall(
|
||||||
|
db, group, payload.min_stock * (group_min_context(group).divisor or 1.0)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(group)
|
db.refresh(group)
|
||||||
return _group_to_out(db, group)
|
return _group_to_out(db, group)
|
||||||
@@ -160,6 +231,12 @@ def update_group(
|
|||||||
if group is None:
|
if group is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||||||
data = payload.model_dump(exclude_unset=True)
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
# Muss RAUS, bevor unten stumpf jedes Feld per setattr gesetzt wird –
|
||||||
|
# ``parent_ids`` ist kein Modellattribut, sondern eine Beziehung.
|
||||||
|
obergruppen = data.pop("parent_ids", None)
|
||||||
|
# Ebenfalls kein Modellfeld mehr: der Mindestbestand ist die „Ueberall"-Zeile.
|
||||||
|
min_gesetzt = "min_stock" in data
|
||||||
|
min_wert = data.pop("min_stock", None)
|
||||||
if "name" in data and data["name"]:
|
if "name" in data and data["name"]:
|
||||||
clash = (
|
clash = (
|
||||||
db.query(Group)
|
db.query(Group)
|
||||||
@@ -168,8 +245,17 @@ def update_group(
|
|||||||
)
|
)
|
||||||
if clash:
|
if clash:
|
||||||
raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben")
|
raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben")
|
||||||
|
|
||||||
for field, value in data.items():
|
for field, value in data.items():
|
||||||
setattr(group, field, value)
|
setattr(group, field, value)
|
||||||
|
if obergruppen is not None:
|
||||||
|
_obergruppen_setzen(db, group, obergruppen)
|
||||||
|
# Die Erfassungseinheit umzustellen (Gebinde <-> verwaltete Einheit) braucht
|
||||||
|
# keine Umrechnung mehr: gespeichert wird in Basiseinheiten, der physische
|
||||||
|
# Bedarf bleibt dadurch von selbst gleich.
|
||||||
|
if min_gesetzt:
|
||||||
|
divisor = group_min_context(group).divisor or 1.0
|
||||||
|
schreibe_ueberall(db, group, None if min_wert is None else min_wert * divisor)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(group)
|
db.refresh(group)
|
||||||
return _group_to_out(db, group)
|
return _group_to_out(db, group)
|
||||||
@@ -290,5 +376,13 @@ def delete_group(
|
|||||||
group = db.get(Group, group_id)
|
group = db.get(Group, group_id)
|
||||||
if group is None:
|
if group is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||||||
|
# Untergruppen bleiben bestehen und ruecken NICHT nach oben: im Graphen
|
||||||
|
# waere unklar, an welchen der moeglicherweise mehreren Grosseltern sie
|
||||||
|
# sollten – ein automatisches Umhaengen wuerde stillschweigend neue
|
||||||
|
# Bestandssummen erzeugen. Sie verlieren nur die Verbindung. Kanten
|
||||||
|
# ausdruecklich loesen, weil SQLite Fremdschluessel nicht erzwingt.
|
||||||
|
group.parents.clear()
|
||||||
|
group.children.clear()
|
||||||
|
db.flush()
|
||||||
db.delete(group)
|
db.delete(group)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from ..models import (
|
|||||||
Barcode,
|
Barcode,
|
||||||
Category,
|
Category,
|
||||||
Group,
|
Group,
|
||||||
|
group_parents,
|
||||||
Location,
|
Location,
|
||||||
Lot,
|
Lot,
|
||||||
Movement,
|
Movement,
|
||||||
@@ -108,6 +109,10 @@ def reset_all(
|
|||||||
_delete_products(db)
|
_delete_products(db)
|
||||||
# Restliche EAN-Codes (die an Gruppen hängen) mitnehmen.
|
# Restliche EAN-Codes (die an Gruppen hängen) mitnehmen.
|
||||||
db.query(Barcode).delete(synchronize_session=False)
|
db.query(Barcode).delete(synchronize_session=False)
|
||||||
|
# Ober-/Untergruppen-Kanten ausdrücklich: ein Core-DELETE räumt die n:m-
|
||||||
|
# Zeilen nicht mit ab, und SQLite erzwingt keine Fremdschlüssel – sonst
|
||||||
|
# blieben verwaiste Kanten stehen und hefteten sich an neu vergebene IDs.
|
||||||
|
db.execute(group_parents.delete())
|
||||||
db.query(Group).delete(synchronize_session=False)
|
db.query(Group).delete(synchronize_session=False)
|
||||||
db.query(Category).delete(synchronize_session=False)
|
db.query(Category).delete(synchronize_session=False)
|
||||||
db.query(Location).delete(synchronize_session=False)
|
db.query(Location).delete(synchronize_session=False)
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import difflib
|
||||||
|
import re
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||||
@@ -9,6 +12,7 @@ from ..models import (
|
|||||||
Barcode,
|
Barcode,
|
||||||
BaseUnit,
|
BaseUnit,
|
||||||
Category,
|
Category,
|
||||||
|
CategoryTracking,
|
||||||
Group,
|
Group,
|
||||||
Location,
|
Location,
|
||||||
Movement,
|
Movement,
|
||||||
@@ -25,6 +29,9 @@ from ..schemas import (
|
|||||||
BarcodeCreate,
|
BarcodeCreate,
|
||||||
LocationMinStockIn,
|
LocationMinStockIn,
|
||||||
LookupResult,
|
LookupResult,
|
||||||
|
MatchCandidate,
|
||||||
|
MatchLine,
|
||||||
|
MatchRequest,
|
||||||
ProductCreate,
|
ProductCreate,
|
||||||
ProductOut,
|
ProductOut,
|
||||||
ProductUpdate,
|
ProductUpdate,
|
||||||
@@ -35,13 +42,88 @@ from ..schemas import (
|
|||||||
from ..services import images
|
from ..services import images
|
||||||
from ..services.categories import suggest_category
|
from ..services.categories import suggest_category
|
||||||
from .categories import descendant_ids
|
from .categories import descendant_ids
|
||||||
|
from .settings import get_receipt_match_threshold
|
||||||
from ..services.conversion import ConversionError, resolve_product_unit
|
from ..services.conversion import ConversionError, resolve_product_unit
|
||||||
from ..services.fields import FieldError, apply_field_values
|
from ..services.fields import FieldError, apply_field_values
|
||||||
from ..services.group_codes import detach as detach_group_code, sync as sync_group_code
|
from ..services.group_codes import detach as detach_group_code, sync as sync_group_code
|
||||||
|
from ..services.min_stock import schreibe_ueberall
|
||||||
from ..services.stock import removal_stats
|
from ..services.stock import removal_stats
|
||||||
|
|
||||||
router = APIRouter(prefix="/products", tags=["products"])
|
router = APIRouter(prefix="/products", tags=["products"])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Kassenzettel-Abgleich: eine OCR-Zeile den ähnlichsten Lebensmitteln zuordnen.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalisieren(text: str) -> str:
|
||||||
|
"""Klein, Umlaute aufgelöst, nur Buchstaben/Ziffern – Preise/Sonderzeichen weg."""
|
||||||
|
text = (text or "").lower().translate(_UMLAUTE)
|
||||||
|
return re.sub(r"[^a-z0-9]+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _match_score(zeile: str, name: str) -> int:
|
||||||
|
"""Ähnlichkeit 0–100 zwischen Kassenzeile und Produktname.
|
||||||
|
|
||||||
|
Kombiniert die Gesamt-Ähnlichkeit mit einem Token-/Präfix-Abgleich, damit
|
||||||
|
abgekürzte Kassennamen ("MÜHLEN SCHNITZ") auf den vollen Namen passen.
|
||||||
|
"""
|
||||||
|
a, b = _normalisieren(zeile), _normalisieren(name)
|
||||||
|
if not a or not b:
|
||||||
|
return 0
|
||||||
|
gesamt = difflib.SequenceMatcher(None, a, b).ratio()
|
||||||
|
a_tok, b_tok = a.split(), b.split()
|
||||||
|
bester = []
|
||||||
|
for t in a_tok:
|
||||||
|
m = 0.0
|
||||||
|
for u in b_tok:
|
||||||
|
if u.startswith(t) or t.startswith(u):
|
||||||
|
m = max(m, min(len(t), len(u)) / max(len(t), len(u)))
|
||||||
|
else:
|
||||||
|
m = max(m, difflib.SequenceMatcher(None, t, u).ratio())
|
||||||
|
bester.append(m)
|
||||||
|
token = sum(bester) / len(bester) if bester else 0.0
|
||||||
|
return round(100 * max(gesamt, token))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/match", response_model=list[MatchLine])
|
||||||
|
def match_receipt(
|
||||||
|
payload: MatchRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> list[MatchLine]:
|
||||||
|
"""Kassenzettel-Zeilen den ähnlichsten LEBENSMITTELN zuordnen (Score 0–100).
|
||||||
|
Gegenstände (auch Verbrauchsgegenstände) bleiben außen vor; je Zeile die besten
|
||||||
|
Treffer über dem Schwellwert (aus Request oder Einstellung)."""
|
||||||
|
schwelle = payload.threshold if payload.threshold is not None else get_receipt_match_threshold(db)
|
||||||
|
schwelle = max(0, min(100, schwelle))
|
||||||
|
lebensmittel = [
|
||||||
|
p
|
||||||
|
for p in db.query(Product).options(joinedload(Product.category)).all()
|
||||||
|
if product_tracking(db, p) != CategoryTracking.object.value
|
||||||
|
]
|
||||||
|
ergebnis: list[MatchLine] = []
|
||||||
|
for zeile in payload.lines:
|
||||||
|
text = (zeile or "").strip()
|
||||||
|
treffer = [(p, _match_score(text, p.name)) for p in lebensmittel]
|
||||||
|
treffer = sorted(
|
||||||
|
(ps for ps in treffer if ps[1] >= schwelle),
|
||||||
|
key=lambda ps: ps[1],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
ergebnis.append(MatchLine(
|
||||||
|
text=text,
|
||||||
|
candidates=[
|
||||||
|
MatchCandidate(
|
||||||
|
product_id=p.id, name=p.name, brand=p.brand, score=s,
|
||||||
|
package_size=p.package_size, base_unit=p.base_unit.value,
|
||||||
|
)
|
||||||
|
for p, s in treffer[:5]
|
||||||
|
],
|
||||||
|
))
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ProductOut])
|
@router.get("", response_model=list[ProductOut])
|
||||||
def list_products(
|
def list_products(
|
||||||
@@ -59,6 +141,7 @@ def list_products(
|
|||||||
query = db.query(Product).options(
|
query = db.query(Product).options(
|
||||||
# Relationen vorab laden, damit products_to_out_bulk kein N+1 auslöst.
|
# Relationen vorab laden, damit products_to_out_bulk kein N+1 auslöst.
|
||||||
joinedload(Product.category),
|
joinedload(Product.category),
|
||||||
|
joinedload(Product.group),
|
||||||
joinedload(Product.shop),
|
joinedload(Product.shop),
|
||||||
joinedload(Product.display_unit),
|
joinedload(Product.display_unit),
|
||||||
joinedload(Product.min_stock_unit),
|
joinedload(Product.min_stock_unit),
|
||||||
@@ -352,6 +435,28 @@ def _store_product_image_bg(product_id: int, url: str) -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _pruefe_zweiteinheit(base_unit, basis, anzahl, menge) -> None:
|
||||||
|
"""Die Zweiteinheit muss vollstaendig und auf eine ANDERE Art zeigen.
|
||||||
|
|
||||||
|
Eine halbe Bruecke waere schlimmer als keine: sie saehe im Formular richtig
|
||||||
|
aus, wuerde aber nirgends greifen (``zweit_faktor`` liefert dann None).
|
||||||
|
"""
|
||||||
|
gesetzt = [x for x in (basis, anzahl, menge) if x is not None]
|
||||||
|
if not gesetzt:
|
||||||
|
return
|
||||||
|
if len(gesetzt) != 3:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Zweiteinheit unvollständig: Art, Anzahl und Menge gehören zusammen.",
|
||||||
|
)
|
||||||
|
if basis == base_unit:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Die Zweiteinheit muss eine ANDERE Art sein als die Einheit des "
|
||||||
|
"Artikels – sonst gibt es nichts umzurechnen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||||
def create_product(
|
def create_product(
|
||||||
payload: ProductCreate,
|
payload: ProductCreate,
|
||||||
@@ -374,6 +479,9 @@ def create_product(
|
|||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
if payload.shop_id is not None and db.get(Shop, payload.shop_id) is None:
|
if payload.shop_id is not None and db.get(Shop, payload.shop_id) is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||||
|
_pruefe_zweiteinheit(
|
||||||
|
base_unit, payload.secondary_base, payload.secondary_count, payload.secondary_amount
|
||||||
|
)
|
||||||
product = Product(
|
product = Product(
|
||||||
barcode=payload.barcode or None,
|
barcode=payload.barcode or None,
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
@@ -383,10 +491,12 @@ def create_product(
|
|||||||
display_unit_id=display_unit_id,
|
display_unit_id=display_unit_id,
|
||||||
package_size=payload.package_size,
|
package_size=payload.package_size,
|
||||||
package_label=payload.package_label or None,
|
package_label=payload.package_label or None,
|
||||||
|
secondary_base=payload.secondary_base.value if payload.secondary_base else None,
|
||||||
|
secondary_count=payload.secondary_count,
|
||||||
|
secondary_amount=payload.secondary_amount,
|
||||||
date_precision=payload.date_precision.value,
|
date_precision=payload.date_precision.value,
|
||||||
group_id=payload.group_id,
|
group_id=payload.group_id,
|
||||||
category_id=payload.category_id,
|
category_id=payload.category_id,
|
||||||
min_stock=payload.min_stock,
|
|
||||||
min_stock_unit_id=payload.min_stock_unit_id,
|
min_stock_unit_id=payload.min_stock_unit_id,
|
||||||
min_stock_in_packages=bool(payload.min_stock_in_packages),
|
min_stock_in_packages=bool(payload.min_stock_in_packages),
|
||||||
shop_id=payload.shop_id,
|
shop_id=payload.shop_id,
|
||||||
@@ -397,6 +507,8 @@ def create_product(
|
|||||||
)
|
)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
db.flush() # product.id fuer den Gruppen-Code
|
db.flush() # product.id fuer den Gruppen-Code
|
||||||
|
# Der Mindestbestand ist kein Artikelfeld mehr, sondern die „Ueberall"-Zeile.
|
||||||
|
schreibe_ueberall(db, product, payload.min_stock)
|
||||||
sync_group_code(db, product)
|
sync_group_code(db, product)
|
||||||
try:
|
try:
|
||||||
apply_field_values(db, product, payload.field_values)
|
apply_field_values(db, product, payload.field_values)
|
||||||
@@ -449,6 +561,27 @@ def update_product(
|
|||||||
product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id)
|
product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id)
|
||||||
except ConversionError as exc:
|
except ConversionError as exc:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
|
# ``min_stock`` ist kein Artikelfeld mehr, sondern die „Ueberall"-Zeile.
|
||||||
|
# Der Vertrag nach aussen bleibt aber gleich (Basiseinheiten), damit
|
||||||
|
# aeltere App-Versionen und das Web-Formular unveraendert weiterlaufen.
|
||||||
|
ueberall_gesetzt = "min_stock" in data
|
||||||
|
ueberall_wert = data.pop("min_stock", None)
|
||||||
|
# Zweiteinheit: Enum -> String, und nur vollstaendig oder gar nicht.
|
||||||
|
if "secondary_base" in data:
|
||||||
|
_pruefe_zweiteinheit(
|
||||||
|
data.get("base_unit") or product.base_unit,
|
||||||
|
data["secondary_base"],
|
||||||
|
data.get("secondary_count", product.secondary_count),
|
||||||
|
data.get("secondary_amount", product.secondary_amount),
|
||||||
|
)
|
||||||
|
data["secondary_base"] = (
|
||||||
|
data["secondary_base"].value if data["secondary_base"] else None
|
||||||
|
)
|
||||||
|
# Bruecke entfernt: die beiden Zahlen muessen mit, sonst bliebe eine
|
||||||
|
# halbe Angabe stehen, die nirgends greift.
|
||||||
|
if data["secondary_base"] is None:
|
||||||
|
data["secondary_count"] = None
|
||||||
|
data["secondary_amount"] = None
|
||||||
if data.get("min_stock_in_packages") is None:
|
if data.get("min_stock_in_packages") is None:
|
||||||
data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL
|
data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL
|
||||||
if data.get("date_precision") is None:
|
if data.get("date_precision") is None:
|
||||||
@@ -457,6 +590,8 @@ def update_product(
|
|||||||
data["date_precision"] = data["date_precision"].value
|
data["date_precision"] = data["date_precision"].value
|
||||||
for field, value in data.items():
|
for field, value in data.items():
|
||||||
setattr(product, field, value)
|
setattr(product, field, value)
|
||||||
|
if ueberall_gesetzt:
|
||||||
|
schreibe_ueberall(db, product, ueberall_wert)
|
||||||
if field_values is not None:
|
if field_values is not None:
|
||||||
try:
|
try:
|
||||||
apply_field_values(db, product, field_values)
|
apply_field_values(db, product, field_values)
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ from ..schemas import SettingOut
|
|||||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
EXPIRY_WARNING_KEY = "expiry_warning_days"
|
EXPIRY_WARNING_KEY = "expiry_warning_days"
|
||||||
|
# Ab wie viel Prozent Übereinstimmung ein Artikel beim Kassenzettel-Scan als
|
||||||
|
# Treffer vorgeschlagen wird.
|
||||||
|
RECEIPT_THRESHOLD_KEY = "receipt_match_threshold"
|
||||||
|
RECEIPT_THRESHOLD_DEFAULT = 45
|
||||||
|
|
||||||
|
|
||||||
def get_expiry_warning_days(db: Session) -> int:
|
def get_expiry_warning_days(db: Session) -> int:
|
||||||
@@ -22,6 +26,17 @@ def get_expiry_warning_days(db: Session) -> int:
|
|||||||
return get_settings().expiry_warning_days_default
|
return get_settings().expiry_warning_days_default
|
||||||
|
|
||||||
|
|
||||||
|
def get_receipt_match_threshold(db: Session) -> int:
|
||||||
|
"""Schwellwert (0–100) für Kassenzettel-Treffer; auf sinnvollen Bereich geklemmt."""
|
||||||
|
row = db.get(Setting, RECEIPT_THRESHOLD_KEY)
|
||||||
|
if row is None:
|
||||||
|
return RECEIPT_THRESHOLD_DEFAULT
|
||||||
|
try:
|
||||||
|
return max(0, min(100, int(row.value)))
|
||||||
|
except ValueError:
|
||||||
|
return RECEIPT_THRESHOLD_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SettingOut])
|
@router.get("", response_model=list[SettingOut])
|
||||||
def list_settings(
|
def list_settings(
|
||||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||||
@@ -29,6 +44,7 @@ def list_settings(
|
|||||||
rows = db.query(Setting).all()
|
rows = db.query(Setting).all()
|
||||||
known = {r.key: r.value for r in rows}
|
known = {r.key: r.value for r in rows}
|
||||||
known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db)))
|
known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db)))
|
||||||
|
known.setdefault(RECEIPT_THRESHOLD_KEY, str(get_receipt_match_threshold(db)))
|
||||||
return [SettingOut(key=k, value=v) for k, v in known.items()]
|
return [SettingOut(key=k, value=v) for k, v in known.items()]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ from ..database import get_db
|
|||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..services.master_data import export_master_data, import_master_data
|
from ..services.master_data import export_master_data, import_master_data
|
||||||
from ..services.dates import MONTH, clean_precision, normalize_best_before
|
from ..services.dates import MONTH, clean_precision, normalize_best_before
|
||||||
|
from ..services import gruppen as gruppen_graph
|
||||||
from ..services.group_codes import sync as sync_group_code
|
from ..services.group_codes import sync as sync_group_code
|
||||||
|
from ..services.min_stock import lies_ueberall, schreibe_ueberall
|
||||||
from ..models import (
|
from ..models import (
|
||||||
BaseUnit,
|
BaseUnit,
|
||||||
Category,
|
Category,
|
||||||
@@ -27,12 +29,14 @@ from ..models import (
|
|||||||
DatePrecision,
|
DatePrecision,
|
||||||
FieldDefinition,
|
FieldDefinition,
|
||||||
Group,
|
Group,
|
||||||
|
GroupLocationMinStock,
|
||||||
Item,
|
Item,
|
||||||
Location,
|
Location,
|
||||||
Lot,
|
Lot,
|
||||||
Movement,
|
Movement,
|
||||||
MovementType,
|
MovementType,
|
||||||
Product,
|
Product,
|
||||||
|
ProductLocationMinStock,
|
||||||
Shop,
|
Shop,
|
||||||
Unit,
|
Unit,
|
||||||
UnitKind,
|
UnitKind,
|
||||||
@@ -116,7 +120,10 @@ def export_stock_csv(
|
|||||||
product.group.name if product.group else "",
|
product.group.name if product.group else "",
|
||||||
_category_path(db, product.category),
|
_category_path(db, product.category),
|
||||||
_art_label(product),
|
_art_label(product),
|
||||||
product.min_stock if product.min_stock is not None else "",
|
# „Ueberall"-Bedarf (frueher der Gesamt-Mindestbestand am Artikel).
|
||||||
|
# Die CSV kennt nur diese eine Spalte; Ort-Bedarfe stehen im
|
||||||
|
# JSON-Backup.
|
||||||
|
_ueberall_csv(product),
|
||||||
]
|
]
|
||||||
lots = (
|
lots = (
|
||||||
db.query(Lot)
|
db.query(Lot)
|
||||||
@@ -159,7 +166,10 @@ def export_backup_json(
|
|||||||
loc_name = {loc.id: loc.name for loc in locations}
|
loc_name = {loc.id: loc.name for loc in locations}
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"version": 2,
|
# v3: Gruppen mit Obergruppen und Gebinde, Mindestbestaende je Ort
|
||||||
|
# (Ort null = „Ueberall") in Basiseinheiten. v2-Sicherungen bleiben
|
||||||
|
# lesbar – ihnen fehlen diese Listen einfach.
|
||||||
|
"version": 3,
|
||||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"exported_at_local": datetime.now().isoformat(timespec="seconds"),
|
"exported_at_local": datetime.now().isoformat(timespec="seconds"),
|
||||||
"units": [
|
"units": [
|
||||||
@@ -169,8 +179,13 @@ def export_backup_json(
|
|||||||
"groups": [
|
"groups": [
|
||||||
{
|
{
|
||||||
"name": g.name,
|
"name": g.name,
|
||||||
"min_stock": g.min_stock,
|
|
||||||
"min_stock_unit": g.min_stock_unit.name if g.min_stock_unit else None,
|
"min_stock_unit": g.min_stock_unit.name if g.min_stock_unit else None,
|
||||||
|
"package_size": g.package_size,
|
||||||
|
"package_label": g.package_label,
|
||||||
|
"min_stock_in_packages": g.min_stock_in_packages,
|
||||||
|
# Namen statt IDs: die sind zwischen zwei Instanzen nicht gleich.
|
||||||
|
"parents": sorted(p.name for p in g.parents),
|
||||||
|
"min_stocks": _min_stock_liste(g, loc_name),
|
||||||
}
|
}
|
||||||
for g in db.query(Group).order_by(Group.id).all()
|
for g in db.query(Group).order_by(Group.id).all()
|
||||||
],
|
],
|
||||||
@@ -222,9 +237,13 @@ def export_backup_json(
|
|||||||
"date_precision": p.date_precision,
|
"date_precision": p.date_precision,
|
||||||
"group": p.group.name if p.group else None,
|
"group": p.group.name if p.group else None,
|
||||||
"category": _category_path(db, p.category),
|
"category": _category_path(db, p.category),
|
||||||
"min_stock": p.min_stock,
|
|
||||||
"min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None,
|
"min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None,
|
||||||
"min_stock_in_packages": bool(p.min_stock_in_packages),
|
"min_stock_in_packages": bool(p.min_stock_in_packages),
|
||||||
|
# Zweiteinheit-Brücke („3 Stück ≙ 250 g"), in Basiseinheiten.
|
||||||
|
"secondary_base": p.secondary_base,
|
||||||
|
"secondary_count": p.secondary_count,
|
||||||
|
"secondary_amount": p.secondary_amount,
|
||||||
|
"min_stocks": _min_stock_liste(p, loc_name),
|
||||||
# Gegenstands-Felder:
|
# Gegenstands-Felder:
|
||||||
"shop": p.shop.name if p.shop else None,
|
"shop": p.shop.name if p.shop else None,
|
||||||
"product_url": p.product_url,
|
"product_url": p.product_url,
|
||||||
@@ -479,6 +498,55 @@ def _get_or_create_shop(db: Session, name: str | None, website: str | None = Non
|
|||||||
return shop
|
return shop
|
||||||
|
|
||||||
|
|
||||||
|
def _ueberall_csv(besitzer) -> float | str:
|
||||||
|
"""Der „Ueberall"-Mindestbestand fuer die CSV-Spalte (leer, wenn keiner)."""
|
||||||
|
wert = lies_ueberall(besitzer.location_min_stocks)
|
||||||
|
return "" if wert is None else wert
|
||||||
|
|
||||||
|
|
||||||
|
def _min_stock_liste(besitzer, loc_name: dict[str, str]) -> list[dict]:
|
||||||
|
"""Mindestbestaende eines Artikels/einer Gruppe je Ort, fuer die Sicherung.
|
||||||
|
|
||||||
|
Mengen in Basiseinheiten, Orte als Name; ``location = null`` ist „Ueberall".
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"location": None if e.location_id is None else loc_name.get(e.location_id),
|
||||||
|
"min_stock": e.min_stock,
|
||||||
|
}
|
||||||
|
for e in besitzer.location_min_stocks
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _min_stocks_einspielen(db: Session, besitzer, eintraege: list[dict] | None) -> None:
|
||||||
|
"""Mindestbestaende aus der Sicherung setzen – nur, wenn noch keine da sind.
|
||||||
|
|
||||||
|
Wie der ganze Import additiv: Vorhandenes wird nie ueberschrieben.
|
||||||
|
"""
|
||||||
|
if not eintraege or besitzer.location_min_stocks:
|
||||||
|
return
|
||||||
|
for eintrag in eintraege:
|
||||||
|
menge = eintrag.get("min_stock")
|
||||||
|
if menge is None or float(menge) <= 0:
|
||||||
|
continue
|
||||||
|
ort_name = eintrag.get("location")
|
||||||
|
ort = None
|
||||||
|
if ort_name:
|
||||||
|
ort = db.query(Location).filter(Location.name == ort_name).first()
|
||||||
|
if ort is None:
|
||||||
|
continue # Ort fehlt in dieser Instanz – Eintrag entfaellt
|
||||||
|
# Beziehung mitsetzen, nicht nur die ID: sonst liefert ``.location`` bis
|
||||||
|
# zum naechsten Commit None, obwohl der Ort feststeht.
|
||||||
|
if isinstance(besitzer, Product):
|
||||||
|
besitzer.location_min_stocks.append(ProductLocationMinStock(
|
||||||
|
product_id=besitzer.id, location=ort,
|
||||||
|
location_id=ort.id if ort else None, min_stock=float(menge)))
|
||||||
|
else:
|
||||||
|
besitzer.location_min_stocks.append(GroupLocationMinStock(
|
||||||
|
group_id=besitzer.id, location=ort,
|
||||||
|
location_id=ort.id if ort else None, min_stock=float(menge)))
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_group(db: Session, name: str | None) -> Group | None:
|
def _get_or_create_group(db: Session, name: str | None) -> Group | None:
|
||||||
name = (name or "").strip()
|
name = (name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
@@ -542,11 +610,13 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
|
|||||||
date_precision=clean_precision((row.get("mhd_genauigkeit") or "").strip() or None),
|
date_precision=clean_precision((row.get("mhd_genauigkeit") or "").strip() or None),
|
||||||
group_id=group.id if group else None,
|
group_id=group.id if group else None,
|
||||||
category_id=category.id if category else None,
|
category_id=category.id if category else None,
|
||||||
min_stock=_num(row.get("mindestbestand")),
|
|
||||||
source="import",
|
source="import",
|
||||||
)
|
)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
# Die CSV-Spalte „mindestbestand" (und das v2-Backup) meinen den Bedarf
|
||||||
|
# ohne Ortsangabe – das ist jetzt die „Ueberall"-Zeile. Basiseinheiten.
|
||||||
|
schreibe_ueberall(db, product, _num(row.get("mindestbestand")))
|
||||||
# Damit ein eingelesenes Backup denselben Stand erzeugt wie das Anlegen
|
# Damit ein eingelesenes Backup denselben Stand erzeugt wie das Anlegen
|
||||||
# ueber die Oberflaeche.
|
# ueber die Oberflaeche.
|
||||||
sync_group_code(db, product)
|
sync_group_code(db, product)
|
||||||
@@ -664,7 +734,19 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
|
|||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
for entry in data.get("groups", []):
|
for entry in data.get("groups", []):
|
||||||
_get_or_create_group(db, entry.get("name"))
|
gruppe = _get_or_create_group(db, entry.get("name"))
|
||||||
|
if gruppe is None:
|
||||||
|
continue
|
||||||
|
# Gebinde und Einheit nur nachtragen, wenn die Gruppe noch nackt ist –
|
||||||
|
# der Import ueberschreibt grundsaetzlich nichts Vorhandenes.
|
||||||
|
if gruppe.package_size is None and entry.get("package_size"):
|
||||||
|
gruppe.package_size = float(entry["package_size"])
|
||||||
|
gruppe.package_label = entry.get("package_label")
|
||||||
|
gruppe.min_stock_in_packages = bool(entry.get("min_stock_in_packages"))
|
||||||
|
if gruppe.min_stock_unit_id is None and entry.get("min_stock_unit"):
|
||||||
|
einheit = db.query(Unit).filter(Unit.name == entry["min_stock_unit"]).first()
|
||||||
|
if einheit is not None:
|
||||||
|
gruppe.min_stock_unit_id = einheit.id
|
||||||
for entry in data.get("locations", []):
|
for entry in data.get("locations", []):
|
||||||
_get_or_create_location(db, entry.get("name"))
|
_get_or_create_location(db, entry.get("name"))
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -678,6 +760,27 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
|
|||||||
if child and parent and child.parent_id is None and child.id != parent.id:
|
if child and parent and child.parent_id is None and child.id != parent.id:
|
||||||
child.parent_id = parent.id
|
child.parent_id = parent.id
|
||||||
|
|
||||||
|
# Zweiter Durchgang für die Gruppen: Ober-/Untergruppen und Mindestbestände
|
||||||
|
# lassen sich erst setzen, wenn alle Gruppen und Lagerorte existieren.
|
||||||
|
for entry in data.get("groups", []):
|
||||||
|
gruppe = db.query(Group).filter(Group.name == entry.get("name")).first()
|
||||||
|
if gruppe is None:
|
||||||
|
continue
|
||||||
|
if not gruppe.parents:
|
||||||
|
for eltern_name in entry.get("parents") or []:
|
||||||
|
eltern = db.query(Group).filter(Group.name == eltern_name).first()
|
||||||
|
# Selbstkante und Ringe abweisen: eine beschädigte oder
|
||||||
|
# manipulierte Datei darf keinen einschleusen, der danach jede
|
||||||
|
# Auswertung im Kreis laufen liesse.
|
||||||
|
if eltern is None or eltern.id == gruppe.id:
|
||||||
|
continue
|
||||||
|
if eltern.id in gruppen_graph.nachfahren_ids(gruppe):
|
||||||
|
errors.append(f"Gruppe {gruppe.name!r}: {eltern_name!r} wäre ein Ring")
|
||||||
|
continue
|
||||||
|
gruppe.parents.append(eltern)
|
||||||
|
_min_stocks_einspielen(db, gruppe, entry.get("min_stocks"))
|
||||||
|
db.flush()
|
||||||
|
|
||||||
# Kategorien samt Verwaltungsart (Backup v2). Ältere Backups ohne diese Liste
|
# Kategorien samt Verwaltungsart (Backup v2). Ältere Backups ohne diese Liste
|
||||||
# legen ihre Kategorien weiter über die Produktpfade an (Standard: food).
|
# legen ihre Kategorien weiter über die Produktpfade an (Standard: food).
|
||||||
for entry in data.get("categories", []):
|
for entry in data.get("categories", []):
|
||||||
@@ -733,6 +836,15 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
|
|||||||
"mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
|
"mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
|
||||||
}
|
}
|
||||||
product = _get_or_create_product(db, row, created_products)
|
product = _get_or_create_product(db, row, created_products)
|
||||||
|
# Mindestbestände je Ort (v3). Ältere Sicherungen haben nur den
|
||||||
|
# Gesamtwert, der oben schon über „mindestbestand" gesetzt wurde.
|
||||||
|
_min_stocks_einspielen(db, product, entry.get("min_stocks"))
|
||||||
|
# Zweiteinheit nur nachtragen, wenn der Artikel noch keine hat –
|
||||||
|
# der Import ueberschreibt grundsaetzlich nichts Vorhandenes.
|
||||||
|
if not product.secondary_base and entry.get("secondary_base"):
|
||||||
|
product.secondary_base = entry["secondary_base"]
|
||||||
|
product.secondary_count = entry.get("secondary_count")
|
||||||
|
product.secondary_amount = entry.get("secondary_amount")
|
||||||
# Gegenstands-Zusatzfelder – nur ergänzend, nichts überschreiben.
|
# Gegenstands-Zusatzfelder – nur ergänzend, nichts überschreiben.
|
||||||
shop_name = (entry.get("shop") or "").strip()
|
shop_name = (entry.get("shop") or "").strip()
|
||||||
if shop_name and product.shop_id is None:
|
if shop_name and product.shop_id is None:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import math
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterable
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from typing import Callable
|
from typing import Callable, TypeVar
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -14,11 +16,13 @@ from ..models import (
|
|||||||
Location,
|
Location,
|
||||||
Lot,
|
Lot,
|
||||||
Movement,
|
Movement,
|
||||||
|
PackageType,
|
||||||
Product,
|
Product,
|
||||||
ProductLocationMinStock,
|
ProductLocationMinStock,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
|
BaseUnit,
|
||||||
ExpiringItem,
|
ExpiringItem,
|
||||||
GroupShoppingItem,
|
GroupShoppingItem,
|
||||||
LocationContentEntry,
|
LocationContentEntry,
|
||||||
@@ -29,43 +33,282 @@ from ..schemas import (
|
|||||||
MovementOut,
|
MovementOut,
|
||||||
ShoppingItem,
|
ShoppingItem,
|
||||||
ShoppingListAll,
|
ShoppingListAll,
|
||||||
|
ShoppingNeed,
|
||||||
)
|
)
|
||||||
from ..services.conversion import BASE_OF_KIND, article_unit, display_unit_info
|
from ..services.conversion import (
|
||||||
|
BASE_OF_KIND,
|
||||||
|
article_unit,
|
||||||
|
display_unit_info,
|
||||||
|
group_min_context,
|
||||||
|
)
|
||||||
|
from ..services import gruppen as gruppen_graph
|
||||||
from ..services.stock import (
|
from ..services.stock import (
|
||||||
current_stock,
|
current_stock,
|
||||||
descendant_location_ids,
|
descendant_location_ids,
|
||||||
location_subtree_stock_base,
|
location_subtree_stock_base,
|
||||||
|
summe_bestand_gewichtet,
|
||||||
|
summe_bestand_im_subtree_gewichtet,
|
||||||
)
|
)
|
||||||
from .settings import get_expiry_warning_days
|
from .settings import get_expiry_warning_days
|
||||||
|
|
||||||
router = APIRouter(tags=["views"])
|
router = APIRouter(tags=["views"])
|
||||||
|
|
||||||
|
#: Schluesseltyp der Bedarfs-Verrechnung: ein Lagerort (bzw. None = „Ueberall")
|
||||||
|
#: oder ein Paar aus Gruppe und Ort.
|
||||||
|
K = TypeVar("K")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Bedarf lesbar aufbereiten (Gebinde als Leitangabe) --------------------
|
||||||
|
_UNIT_SHORT: dict[BaseUnit, str] = {
|
||||||
|
BaseUnit.piece: "Stk",
|
||||||
|
BaseUnit.gram: "g",
|
||||||
|
BaseUnit.milliliter: "ml",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _de_num(x: float) -> str:
|
||||||
|
"""Deutsche Kurzzahl ohne unnoetige Nullen: 500, 1, 1,4."""
|
||||||
|
r = round(float(x), 2)
|
||||||
|
if r == int(r):
|
||||||
|
return str(int(r))
|
||||||
|
return f"{r:.2f}".rstrip("0").rstrip(".").replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
|
def _package_plural(db: Session) -> dict[str, str]:
|
||||||
|
"""Einzahl -> Mehrzahl der Gebinde (Glas -> Gläser), aus der Gebinde-Tabelle."""
|
||||||
|
return {pt.singular: pt.plural for pt in db.query(PackageType).all()}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_need(
|
||||||
|
*,
|
||||||
|
deficit_base: float,
|
||||||
|
stock_base: float,
|
||||||
|
min_base: float,
|
||||||
|
factor: float,
|
||||||
|
singular: str,
|
||||||
|
is_package: bool,
|
||||||
|
base_unit: BaseUnit | None,
|
||||||
|
plural: dict[str, str],
|
||||||
|
) -> ShoppingNeed:
|
||||||
|
"""Rechnet Basiseinheiten in Gebinde/Artikeleinheiten um und baut die Texte.
|
||||||
|
|
||||||
|
``factor`` = Basiseinheiten je Gebinde (Packungsgröße bzw. Einheitenfaktor).
|
||||||
|
Bei zählbaren Packungen wird die Kaufmenge auf ganze Gebinde aufgerundet."""
|
||||||
|
f = factor or 1.0
|
||||||
|
count = math.ceil(deficit_base / f - 1e-9) if is_package else round(deficit_base / f, 3)
|
||||||
|
label = singular if abs(count) == 1 else plural.get(singular, singular)
|
||||||
|
text = f"{_de_num(count)} {label}".strip()
|
||||||
|
hint = ""
|
||||||
|
base_amount: float | None = None
|
||||||
|
if (is_package or f != 1.0) and base_unit is not None:
|
||||||
|
base_amount = round(deficit_base, 3)
|
||||||
|
hint = f"{_de_num(deficit_base)} {_UNIT_SHORT.get(base_unit, base_unit.value)}"
|
||||||
|
return ShoppingNeed(
|
||||||
|
text=text,
|
||||||
|
hint=hint,
|
||||||
|
count=count,
|
||||||
|
label=label,
|
||||||
|
singular=singular,
|
||||||
|
is_package=is_package,
|
||||||
|
base_amount=base_amount,
|
||||||
|
base_unit=base_unit if base_amount is not None else None,
|
||||||
|
stock=round(stock_base / f, 3),
|
||||||
|
min_stock=round(min_base / f, 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _artikel_bedarfe(
|
||||||
|
db: Session, ort_desc: dict[str, set[str]]
|
||||||
|
) -> dict[int, tuple[Product, dict, dict, dict]]:
|
||||||
|
"""Je Artikel Mindestbestand, Bestand und verrechneter Bedarf – je Ort.
|
||||||
|
|
||||||
|
Schluessel ``None`` ist „Ueberall" und liegt ueber allen Lagerorten. Alle
|
||||||
|
Mengen in Basiseinheiten.
|
||||||
|
"""
|
||||||
|
nach_artikel: dict[int, list[ProductLocationMinStock]] = defaultdict(list)
|
||||||
|
for e in db.query(ProductLocationMinStock).all():
|
||||||
|
if e.min_stock > 0:
|
||||||
|
nach_artikel[e.product_id].append(e)
|
||||||
|
|
||||||
|
ergebnis: dict[int, tuple[Product, dict, dict, dict]] = {}
|
||||||
|
for product_id, rows in nach_artikel.items():
|
||||||
|
product = db.get(Product, product_id)
|
||||||
|
if product is None:
|
||||||
|
continue
|
||||||
|
mins = {e.location_id: e.min_stock for e in rows}
|
||||||
|
bestand = {
|
||||||
|
loc: (
|
||||||
|
current_stock(db, product.id)
|
||||||
|
if loc is None
|
||||||
|
else location_subtree_stock_base(db, product, loc)
|
||||||
|
)
|
||||||
|
for loc in mins
|
||||||
|
}
|
||||||
|
needs = _netted_topups(mins, _ort_nachfahren(mins, ort_desc), bestand.__getitem__)
|
||||||
|
ergebnis[product_id] = (product, mins, bestand, needs)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def einkaufsliste_artikel(db: Session) -> list[ShoppingItem]:
|
||||||
|
"""Artikel, bei denen „Überall" etwas fehlt – egal wo im Haus.
|
||||||
|
|
||||||
|
Käufe für einzelne Lagerorte sind bereits abgezogen: derselbe Artikel steht
|
||||||
|
dadurch nicht mehr doppelt in der Liste (einmal „Gesamt", einmal je Ort).
|
||||||
|
"""
|
||||||
|
plural = _package_plural(db)
|
||||||
|
ort_desc = _ort_nachfahren_tabelle(db)
|
||||||
|
items: list[ShoppingItem] = []
|
||||||
|
for product, mins, bestand, needs in _artikel_bedarfe(db, ort_desc).values():
|
||||||
|
if None not in mins:
|
||||||
|
continue # nur Ort-Bedarfe, kein „Überall"
|
||||||
|
fehlt = needs.get(None, 0.0)
|
||||||
|
if fehlt <= 1e-9:
|
||||||
|
continue
|
||||||
|
factor, singular = article_unit(product)
|
||||||
|
items.append(
|
||||||
|
ShoppingItem(
|
||||||
|
product_id=product.id,
|
||||||
|
name=product.name,
|
||||||
|
base_unit=product.base_unit,
|
||||||
|
package_size=product.package_size,
|
||||||
|
stock=round(bestand[None], 3),
|
||||||
|
min_stock=mins[None],
|
||||||
|
deficit=round(fehlt, 3),
|
||||||
|
need=_build_need(
|
||||||
|
deficit_base=fehlt,
|
||||||
|
stock_base=bestand[None],
|
||||||
|
min_base=mins[None],
|
||||||
|
factor=factor,
|
||||||
|
singular=singular,
|
||||||
|
is_package=bool(product.package_size and product.package_size > 0),
|
||||||
|
base_unit=product.base_unit,
|
||||||
|
plural=plural,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
items.sort(key=lambda i: i.deficit, reverse=True)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shopping-list", response_model=list[ShoppingItem])
|
@router.get("/shopping-list", response_model=list[ShoppingItem])
|
||||||
def shopping_list(
|
def shopping_list(
|
||||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||||
) -> list[ShoppingItem]:
|
) -> list[ShoppingItem]:
|
||||||
"""Produkte, deren Bestand unter dem Mindestbestand liegt."""
|
return einkaufsliste_artikel(db)
|
||||||
items: list[ShoppingItem] = []
|
|
||||||
products = (
|
|
||||||
db.query(Product)
|
def _gruppen_bedarfe(db: Session, ort_desc: dict[str, set[str]]) -> tuple[dict, dict, dict, dict, dict]:
|
||||||
.filter(Product.min_stock.isnot(None), Product.min_stock > 0)
|
"""Je (Gruppe, Ort) Mindestbestand, Bestand und verrechneter Bedarf.
|
||||||
.all()
|
|
||||||
)
|
Hier wirken ZWEI Hierarchien zusammen: der Gruppen-Graph und der
|
||||||
for product in products:
|
Lagerort-Baum. „1 kg Grillwurst in die Küche" deckt auch „Wurst in Lemgo".
|
||||||
stock = current_stock(db, product.id)
|
Deshalb ist ein Schlüssel ein Paar, und ``(h, m)`` gilt als Nachfahre von
|
||||||
if stock < product.min_stock:
|
``(g, l)``, wenn h unter g und m unter l liegt (oder gleich ist).
|
||||||
items.append(
|
|
||||||
ShoppingItem(
|
Verrechnet wird nur, wo die zählenden Artikel der Untergruppe eine Teilmenge
|
||||||
product_id=product.id,
|
der Obergruppe sind: zählt „Wurst" in Kilogramm, ihre Untergruppe aber in
|
||||||
name=product.name,
|
Stück, kommt ein Kauf dort oben gar nicht an (siehe die Einheiten-Filterung
|
||||||
base_unit=product.base_unit,
|
in ``group_min_context``).
|
||||||
package_size=product.package_size,
|
|
||||||
stock=stock,
|
Alle Mengen in Basiseinheiten – nur darin lassen sich Gruppen mit
|
||||||
min_stock=product.min_stock,
|
verschiedenen Erfassungseinheiten überhaupt gegeneinander verrechnen.
|
||||||
deficit=product.min_stock - stock,
|
"""
|
||||||
)
|
eintraege = [e for e in db.query(GroupLocationMinStock).all() if e.min_stock > 0]
|
||||||
|
gruppen: dict[int, Group] = {}
|
||||||
|
for e in eintraege:
|
||||||
|
if e.group_id not in gruppen:
|
||||||
|
g = db.get(Group, e.group_id)
|
||||||
|
if g is not None:
|
||||||
|
gruppen[e.group_id] = g
|
||||||
|
|
||||||
|
ctxs = {gid: group_min_context(g) for gid, g in gruppen.items()}
|
||||||
|
artikel = {gid: {p.id for p in c.matching} for gid, c in ctxs.items()}
|
||||||
|
gruppen_desc = {
|
||||||
|
gid: {
|
||||||
|
h.id
|
||||||
|
for h in gruppen_graph.teilgraph(g)
|
||||||
|
if h.id != gid
|
||||||
|
and h.id in artikel
|
||||||
|
# Nur zwischen Gruppen verrechnen, die in DERSELBEN Basiseinheit
|
||||||
|
# zaehlen. Frueher folgte das nebenbei aus der Teilmengen-Bedingung:
|
||||||
|
# Artikel verschiedener Arten waren zwangslaeufig disjunkt. Seit die
|
||||||
|
# Zweiteinheit einen Stueck-Artikel auch in einer Gramm-Gruppe
|
||||||
|
# mitzaehlen laesst, gilt das nicht mehr – und ein Bedarf in Stueck
|
||||||
|
# duerfte keinen Bedarf in Gramm decken (siehe _netted_topups:
|
||||||
|
# „Alle Mengen muessen in DERSELBEN Einheit vorliegen").
|
||||||
|
and ctxs[h.id].base_unit == ctxs[gid].base_unit
|
||||||
|
and artikel[h.id] <= artikel[gid]
|
||||||
|
}
|
||||||
|
for gid, g in gruppen.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
minima: dict[tuple[int, str | None], float] = {}
|
||||||
|
bestand: dict[tuple[int, str | None], float] = {}
|
||||||
|
for e in eintraege:
|
||||||
|
ctx = ctxs.get(e.group_id)
|
||||||
|
if ctx is None:
|
||||||
|
continue
|
||||||
|
schluessel = (e.group_id, e.location_id)
|
||||||
|
minima[schluessel] = e.min_stock
|
||||||
|
bestand[schluessel] = (
|
||||||
|
summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren)
|
||||||
|
if e.location_id is None
|
||||||
|
else summe_bestand_im_subtree_gewichtet(
|
||||||
|
db, ctx.matching, e.location_id, ctx.faktoren)
|
||||||
|
)
|
||||||
|
|
||||||
|
alle_orte = {loc for (_, loc) in minima if loc is not None}
|
||||||
|
|
||||||
|
def nachfahren(k: tuple[int, str | None]) -> set[tuple[int, str | None]]:
|
||||||
|
gid, loc = k
|
||||||
|
unten = gruppen_desc.get(gid, set()) | {gid}
|
||||||
|
# „Überall" (None) liegt über allen Lagerorten.
|
||||||
|
orte = (alle_orte if loc is None else ort_desc.get(loc, set())) | {loc}
|
||||||
|
return {(h, m) for h in unten for m in orte if (h, m) in minima} - {k}
|
||||||
|
|
||||||
|
needs = _netted_topups(minima, nachfahren, bestand.__getitem__)
|
||||||
|
return gruppen, ctxs, minima, bestand, needs
|
||||||
|
|
||||||
|
|
||||||
|
def einkaufsliste_gruppen(db: Session) -> list[GroupShoppingItem]:
|
||||||
|
"""Gruppen, bei denen „Überall" etwas fehlt.
|
||||||
|
|
||||||
|
Gruppen-Bestand = Summe der Artikelbestände der Gruppe UND ihrer
|
||||||
|
Untergruppen (Basiseinheiten). Käufe für Untergruppen und für einzelne
|
||||||
|
Lagerorte sind bereits abgezogen.
|
||||||
|
"""
|
||||||
|
plural = _package_plural(db)
|
||||||
|
ort_desc = _ort_nachfahren_tabelle(db)
|
||||||
|
gruppen, ctxs, minima, bestand, needs = _gruppen_bedarfe(db, ort_desc)
|
||||||
|
|
||||||
|
items: list[GroupShoppingItem] = []
|
||||||
|
for (gid, loc), fehlt in needs.items():
|
||||||
|
if loc is not None or fehlt <= 1e-9:
|
||||||
|
continue
|
||||||
|
ctx = ctxs[gid]
|
||||||
|
schluessel = (gid, None)
|
||||||
|
items.append(
|
||||||
|
GroupShoppingItem(
|
||||||
|
group_id=gid,
|
||||||
|
name=gruppen[gid].name,
|
||||||
|
stock=round(bestand[schluessel] / ctx.divisor, 3),
|
||||||
|
min_stock=round(minima[schluessel] / ctx.divisor, 3),
|
||||||
|
deficit=round(fehlt / ctx.divisor, 3),
|
||||||
|
unit_name=ctx.label,
|
||||||
|
product_count=len(ctx.matching),
|
||||||
|
subgroup_count=len(gruppen_graph.nachfahren_ids(gruppen[gid])),
|
||||||
|
need=_build_need(
|
||||||
|
deficit_base=fehlt,
|
||||||
|
stock_base=bestand[schluessel],
|
||||||
|
min_base=minima[schluessel],
|
||||||
|
factor=ctx.divisor,
|
||||||
|
singular=ctx.label,
|
||||||
|
is_package=ctx.is_package,
|
||||||
|
base_unit=ctx.base_unit,
|
||||||
|
plural=plural,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
items.sort(key=lambda i: i.deficit, reverse=True)
|
items.sort(key=lambda i: i.deficit, reverse=True)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@@ -74,123 +317,136 @@ def shopping_list(
|
|||||||
def group_shopping_list(
|
def group_shopping_list(
|
||||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||||
) -> list[GroupShoppingItem]:
|
) -> list[GroupShoppingItem]:
|
||||||
"""Gruppen, deren Gesamtbestand unter dem Gruppen-Mindestbestand liegt.
|
return einkaufsliste_gruppen(db)
|
||||||
|
|
||||||
Gruppen-Bestand = Summe der Produktbestände in der Gruppe (in Basiseinheiten).
|
|
||||||
Sinnvoll, wenn die Produkte einer Gruppe dieselbe Basiseinheit teilen.
|
|
||||||
"""
|
|
||||||
items: list[GroupShoppingItem] = []
|
|
||||||
groups = (
|
|
||||||
db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all()
|
|
||||||
)
|
|
||||||
for group in groups:
|
|
||||||
unit = group.min_stock_unit
|
|
||||||
if unit is not None:
|
|
||||||
base = BASE_OF_KIND[unit.kind]
|
|
||||||
products = [p for p in group.products if p.base_unit == base]
|
|
||||||
stock = float(sum(current_stock(db, p.id) for p in products)) / unit.factor
|
|
||||||
unit_name = unit.name
|
|
||||||
else:
|
|
||||||
products = list(group.products)
|
|
||||||
stock = float(sum(current_stock(db, p.id) for p in products))
|
|
||||||
unit_name = ""
|
|
||||||
if stock < group.min_stock:
|
|
||||||
items.append(
|
|
||||||
GroupShoppingItem(
|
|
||||||
group_id=group.id,
|
|
||||||
name=group.name,
|
|
||||||
stock=stock,
|
|
||||||
min_stock=group.min_stock,
|
|
||||||
deficit=group.min_stock - stock,
|
|
||||||
unit_name=unit_name,
|
|
||||||
product_count=len(products),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
items.sort(key=lambda i: i.deficit, reverse=True)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def _netted_topups(
|
def _netted_topups(
|
||||||
db: Session, locs_min: dict[str, float], stock_of: Callable[[str], float]
|
minima: dict[K, float],
|
||||||
) -> dict[str, float]:
|
nachfahren: Callable[[K], set[K]],
|
||||||
"""Bedarf je Ort mit verschachtelten Orten verrechnet: Was in einen Unterort
|
stock_of: Callable[[K], float],
|
||||||
gekauft wird, liegt auch im Subtree des Oberorts und deckt dessen Bedarf mit.
|
) -> dict[K, float]:
|
||||||
``topup(ort)`` ist die je Ort ZUSÄTZLICH nötige Menge – über die Käufe in den
|
"""Bedarfe entlang ihrer Hierarchie verrechnen.
|
||||||
Unterorten hinaus. So kostet „Lemgo braucht 5, Küche braucht 2" bei je 1 fehlend
|
|
||||||
nur 1 (in die Küche), nicht 2."""
|
|
||||||
locs = list(locs_min)
|
|
||||||
# Nachkommen-Bedarfsorte je Ort (im Lagerort-Baum), memoisiert von unten nach oben.
|
|
||||||
desc = {
|
|
||||||
loc: [d for d in locs if d != loc and d in descendant_location_ids(db, loc)]
|
|
||||||
for loc in locs
|
|
||||||
}
|
|
||||||
memo: dict[str, float] = {}
|
|
||||||
|
|
||||||
def topup(loc: str) -> float:
|
Was fuer einen Nachfahren gekauft wird, liegt auch bei dessen Vorfahren und
|
||||||
if loc not in memo:
|
deckt deren Bedarf mit. ``topup(x)`` ist die ZUSAETZLICH noetige Menge –
|
||||||
committed = sum(topup(d) for d in desc[loc])
|
ueber die Kaeufe fuer die Nachfahren hinaus. So kostet „Lemgo braucht 5,
|
||||||
memo[loc] = max(0.0, locs_min[loc] - (stock_of(loc) + committed))
|
Kueche braucht 2" bei je 1 fehlend nur 1 (in die Kueche), nicht 2.
|
||||||
return memo[loc]
|
|
||||||
|
|
||||||
return {loc: topup(loc) for loc in locs}
|
``nachfahren`` liefert die TRANSITIVE Nachfahren-MENGE, nicht die direkten
|
||||||
|
Kinder. Beim Gruppen-Graphen ist eine Untergruppe ueber mehrere Wege
|
||||||
|
erreichbar – als Menge zaehlt sie in ``committed`` trotzdem nur einmal.
|
||||||
|
|
||||||
|
Alle Mengen muessen in DERSELBEN Einheit vorliegen (hier: Basiseinheiten),
|
||||||
|
sonst wird Aepfel mit Birnen verrechnet.
|
||||||
|
"""
|
||||||
|
schluessel = list(minima)
|
||||||
|
desc = {k: [d for d in schluessel if d != k and d in nachfahren(k)] for k in schluessel}
|
||||||
|
memo: dict[K, float] = {}
|
||||||
|
|
||||||
|
def topup(k: K) -> float:
|
||||||
|
if k not in memo:
|
||||||
|
# Vorbelegen schuetzt vor einem Ring in den Daten: der wuerde sonst
|
||||||
|
# endlos rekursieren (die API laesst keinen zu, ein Import schon).
|
||||||
|
memo[k] = 0.0
|
||||||
|
committed = sum(topup(d) for d in desc[k])
|
||||||
|
memo[k] = max(0.0, minima[k] - (stock_of(k) + committed))
|
||||||
|
return memo[k]
|
||||||
|
|
||||||
|
return {k: topup(k) for k in schluessel}
|
||||||
|
|
||||||
|
|
||||||
|
def _ort_nachfahren(
|
||||||
|
schluessel: Iterable[str | None], ort_desc: dict[str, set[str]]
|
||||||
|
) -> Callable[[str | None], set[str | None]]:
|
||||||
|
"""Nachfahren-Funktion fuer die Ort-Hierarchie eines Bedarfssatzes.
|
||||||
|
|
||||||
|
„Ueberall" (``None``) liegt ueber ALLEN Lagerorten. Dadurch verrechnet
|
||||||
|
dieselbe Faltung auch Ueberall gegen die einzelnen Orte – der frueher
|
||||||
|
getrennte Gesamt-Mindestbestand erzeugt keinen zweiten Eintrag mehr.
|
||||||
|
"""
|
||||||
|
orte = list(schluessel)
|
||||||
|
|
||||||
|
def nachfahren(k: str | None) -> set[str | None]:
|
||||||
|
if k is None:
|
||||||
|
return {d for d in orte if d is not None}
|
||||||
|
return {d for d in orte if d is not None and d in ort_desc.get(k, set())}
|
||||||
|
|
||||||
|
return nachfahren
|
||||||
|
|
||||||
|
|
||||||
|
def _ort_nachfahren_tabelle(db: Session) -> dict[str, set[str]]:
|
||||||
|
"""Unterorte je Lagerort, einmal je Anfrage statt je Artikel und Gruppe.
|
||||||
|
|
||||||
|
``descendant_location_ids`` geht je Ebene an die Datenbank; frueher wurde es
|
||||||
|
fuer jeden Bedarfssatz neu aufgerufen.
|
||||||
|
"""
|
||||||
|
return {loc.id: descendant_location_ids(db, loc.id) for loc in db.query(Location).all()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shopping-list/by-location", response_model=list[LocationNeeds])
|
@router.get("/shopping-list/by-location", response_model=list[LocationNeeds])
|
||||||
def shopping_list_by_location(
|
def shopping_list_by_location(
|
||||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||||
) -> list[LocationNeeds]:
|
) -> list[LocationNeeds]:
|
||||||
"""Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT
|
"""Bedarfe je Lagerort: Artikel und Gruppen, denen AN DIESEM ORT etwas fehlt.
|
||||||
unter dem dort hinterlegten Mindestbestand liegt."""
|
|
||||||
|
„Überall" gehört nicht hierher – das liefern ``/shopping-list`` und
|
||||||
|
``/shopping-list/groups``. Verrechnet wird über beide Listen hinweg, ein
|
||||||
|
Artikel steht also nur einmal drin.
|
||||||
|
"""
|
||||||
|
plural = _package_plural(db)
|
||||||
|
ort_desc = _ort_nachfahren_tabelle(db)
|
||||||
prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list)
|
prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list)
|
||||||
group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list)
|
group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list)
|
||||||
|
|
||||||
# Je Produkt alle Ort-Mindestbestände sammeln und hierarchisch verrechnen.
|
for product, mins, bestand, needs in _artikel_bedarfe(db, ort_desc).values():
|
||||||
prod_by_id: dict[int, list[ProductLocationMinStock]] = defaultdict(list)
|
|
||||||
for e in db.query(ProductLocationMinStock).all():
|
|
||||||
prod_by_id[e.product_id].append(e)
|
|
||||||
for product_id, entries in prod_by_id.items():
|
|
||||||
product = db.get(Product, product_id)
|
|
||||||
if product is None:
|
|
||||||
continue
|
|
||||||
faktor, label = article_unit(product)
|
faktor, label = article_unit(product)
|
||||||
faktor = faktor or 1.0
|
faktor = faktor or 1.0
|
||||||
locs_min = {e.location_id: e.min_stock for e in entries}
|
ist_gebinde = bool(product.package_size and product.package_size > 0)
|
||||||
bestand = {loc: location_subtree_stock_base(db, product, loc) / faktor for loc in locs_min}
|
for loc, need in needs.items():
|
||||||
for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items():
|
if loc is None or need <= 1e-9:
|
||||||
if need > 1e-9:
|
continue
|
||||||
prod_needs[loc].append(LocationNeedProduct(
|
prod_needs[loc].append(LocationNeedProduct(
|
||||||
product_id=product.id, name=product.name, unit_label=label,
|
product_id=product.id, name=product.name, unit_label=label,
|
||||||
stock=round(bestand[loc], 3), min_stock=locs_min[loc],
|
# Nach aussen weiterhin in Artikeleinheiten – gespeichert und
|
||||||
deficit=round(need, 3),
|
# gerechnet wird intern in Basiseinheiten.
|
||||||
))
|
stock=round(bestand[loc] / faktor, 3),
|
||||||
|
min_stock=round(mins[loc] / faktor, 3),
|
||||||
|
deficit=round(need / faktor, 3),
|
||||||
|
need=_build_need(
|
||||||
|
deficit_base=need,
|
||||||
|
stock_base=bestand[loc],
|
||||||
|
min_base=mins[loc],
|
||||||
|
factor=faktor,
|
||||||
|
singular=label,
|
||||||
|
is_package=ist_gebinde,
|
||||||
|
base_unit=product.base_unit,
|
||||||
|
plural=plural,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
# Je Gruppe genauso – Bestand je Ort ist die Summe der passenden Produkte im Subtree.
|
gruppen, ctxs, minima, bestand_g, needs_g = _gruppen_bedarfe(db, ort_desc)
|
||||||
group_by_id: dict[int, list[GroupLocationMinStock]] = defaultdict(list)
|
for (gid, loc), need in needs_g.items():
|
||||||
for e in db.query(GroupLocationMinStock).all():
|
if loc is None or need <= 1e-9:
|
||||||
group_by_id[e.group_id].append(e)
|
|
||||||
for group_id, entries in group_by_id.items():
|
|
||||||
group = db.get(Group, group_id)
|
|
||||||
if group is None:
|
|
||||||
continue
|
continue
|
||||||
unit = group.min_stock_unit
|
ctx = ctxs[gid]
|
||||||
if unit is not None:
|
schluessel = (gid, loc)
|
||||||
base = BASE_OF_KIND[unit.kind]
|
group_needs[loc].append(LocationNeedGroup(
|
||||||
matching = [p for p in group.products if p.base_unit == base]
|
group_id=gid, name=gruppen[gid].name, unit_name=ctx.label,
|
||||||
divisor, unit_name = unit.factor, unit.name
|
stock=round(bestand_g[schluessel] / ctx.divisor, 3),
|
||||||
else:
|
min_stock=round(minima[schluessel] / ctx.divisor, 3),
|
||||||
matching, divisor, unit_name = list(group.products), 1.0, ""
|
deficit=round(need / ctx.divisor, 3),
|
||||||
locs_min = {e.location_id: e.min_stock for e in entries}
|
subgroup_count=len(gruppen_graph.nachfahren_ids(gruppen[gid])),
|
||||||
bestand = {
|
need=_build_need(
|
||||||
loc: sum(location_subtree_stock_base(db, p, loc) for p in matching) / divisor
|
deficit_base=need,
|
||||||
for loc in locs_min
|
stock_base=bestand_g[schluessel],
|
||||||
}
|
min_base=minima[schluessel],
|
||||||
for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items():
|
factor=ctx.divisor,
|
||||||
if need > 1e-9:
|
singular=ctx.label,
|
||||||
group_needs[loc].append(LocationNeedGroup(
|
is_package=ctx.is_package,
|
||||||
group_id=group.id, name=group.name, unit_name=unit_name,
|
base_unit=ctx.base_unit,
|
||||||
stock=round(bestand[loc], 3), min_stock=locs_min[loc],
|
plural=plural,
|
||||||
deficit=round(need, 3),
|
),
|
||||||
))
|
))
|
||||||
|
|
||||||
loc_ids = set(prod_needs) | set(group_needs)
|
loc_ids = set(prod_needs) | set(group_needs)
|
||||||
namen = {
|
namen = {
|
||||||
|
|||||||
@@ -110,28 +110,49 @@ class UserUpdate(BaseModel):
|
|||||||
|
|
||||||
# ---- Groups ----
|
# ---- Groups ----
|
||||||
class LocationMinStockIn(BaseModel):
|
class LocationMinStockIn(BaseModel):
|
||||||
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Artikeleinheiten)."""
|
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Basiseinheiten).
|
||||||
location_id: str
|
|
||||||
|
``location_id = null`` heisst „Überall" – egal wo, Hauptsache die Menge ist
|
||||||
|
im Haus. Das ersetzt den frueheren separaten Gesamt-Mindestbestand.
|
||||||
|
"""
|
||||||
|
location_id: str | None = None
|
||||||
min_stock: float = Field(ge=0)
|
min_stock: float = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
class LocationMinStockOut(BaseModel):
|
class LocationMinStockOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
location_id: str
|
location_id: str | None = None
|
||||||
location_name: str | None = None
|
location_name: str | None = None # bei location_id = null: „Überall"
|
||||||
min_stock: float
|
min_stock: float
|
||||||
|
# Bestand AN DIESEM Ort (inkl. Unterorte), ebenfalls in Basiseinheiten –
|
||||||
|
# bei Artikeln wie bei Gruppen. Bei „Überall" der Gesamtbestand.
|
||||||
|
stock: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class GroupOut(BaseModel):
|
class GroupOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
min_stock: float | None = None
|
min_stock: float | None = None # in der aktuellen Erfassungseinheit (s.u.)
|
||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
|
# Gruppen-Gebinde (Richtwert) + ob der Mindestbestand darin erfasst ist:
|
||||||
|
package_size: float | None = None
|
||||||
|
package_label: str | None = None
|
||||||
|
min_stock_in_packages: bool = False
|
||||||
|
# Obergruppen (n:m – eine Gruppe darf unter mehreren haengen) und die
|
||||||
|
# direkten Untergruppen. Beide als IDs; ``_group_to_out`` setzt sie, weil
|
||||||
|
# ``Group.parents``/``children`` Objekte sind.
|
||||||
|
parent_ids: list[int] = []
|
||||||
|
child_ids: list[int] = []
|
||||||
# angereichert:
|
# angereichert:
|
||||||
|
# Artikel im gesamten Untergruppen-Graphen – passt zu ``stock``, das
|
||||||
|
# ebenfalls transitiv rechnet. ``direct_product_count`` sind die, die
|
||||||
|
# unmittelbar an dieser Gruppe haengen.
|
||||||
product_count: int = 0
|
product_count: int = 0
|
||||||
stock: float = 0.0 # Bestand in Basiseinheiten
|
direct_product_count: int = 0
|
||||||
min_stock_unit_name: str | None = None
|
stock: float = 0.0 # Bestand in der aktuellen Erfassungseinheit
|
||||||
|
min_stock_unit_name: str | None = None # Name der verwalteten Einheit (g/kg …)
|
||||||
|
min_stock_unit_factor: float | None = None # Basiseinheiten je verwalteter Einheit
|
||||||
kind: str | None = None # Art der Mindestbestand-Einheit
|
kind: str | None = None # Art der Mindestbestand-Einheit
|
||||||
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.
|
||||||
@@ -144,12 +165,21 @@ class GroupCreate(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
min_stock: float | None = Field(default=None, ge=0)
|
min_stock: float | None = Field(default=None, ge=0)
|
||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
|
package_size: float | None = Field(default=None, ge=0)
|
||||||
|
package_label: str | None = Field(default=None, max_length=32)
|
||||||
|
min_stock_in_packages: bool = False
|
||||||
|
parent_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
class GroupUpdate(BaseModel):
|
class GroupUpdate(BaseModel):
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
min_stock: float | None = Field(default=None, ge=0)
|
min_stock: float | None = Field(default=None, ge=0)
|
||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
|
package_size: float | None = Field(default=None, ge=0)
|
||||||
|
package_label: str | None = Field(default=None, max_length=32)
|
||||||
|
min_stock_in_packages: bool | None = None
|
||||||
|
# Nicht mitgeschickt = unveraendert, [] = alle Obergruppen entfernen.
|
||||||
|
parent_ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
# ---- Categories ----
|
# ---- Categories ----
|
||||||
@@ -285,6 +315,12 @@ class ProductBase(BaseModel):
|
|||||||
package_size: float | None = Field(default=None, gt=0)
|
package_size: float | None = Field(default=None, gt=0)
|
||||||
# Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …)
|
# Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …)
|
||||||
package_label: str | None = Field(default=None, max_length=32)
|
package_label: str | None = Field(default=None, max_length=32)
|
||||||
|
# Zweiteinheit: Bruecke zwischen den Einheiten-Arten („3 Stück ≙ 250 g").
|
||||||
|
# ``secondary_count`` zaehlt in der Basiseinheit des Artikels,
|
||||||
|
# ``secondary_amount`` in ``secondary_base``. Alle drei oder keines.
|
||||||
|
secondary_base: BaseUnit | None = None
|
||||||
|
secondary_count: float | None = Field(default=None, gt=0)
|
||||||
|
secondary_amount: float | None = Field(default=None, gt=0)
|
||||||
# Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
|
# Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
|
||||||
date_precision: DatePrecision = DatePrecision.day
|
date_precision: DatePrecision = DatePrecision.day
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
@@ -318,6 +354,12 @@ class ProductUpdate(BaseModel):
|
|||||||
unit_id: int | None = None
|
unit_id: int | None = None
|
||||||
package_size: float | None = Field(default=None, gt=0)
|
package_size: float | None = Field(default=None, gt=0)
|
||||||
package_label: str | None = Field(default=None, max_length=32)
|
package_label: str | None = Field(default=None, max_length=32)
|
||||||
|
# Zweiteinheit: Bruecke zwischen den Einheiten-Arten („3 Stück ≙ 250 g").
|
||||||
|
# ``secondary_count`` zaehlt in der Basiseinheit des Artikels,
|
||||||
|
# ``secondary_amount`` in ``secondary_base``. Alle drei oder keines.
|
||||||
|
secondary_base: BaseUnit | None = None
|
||||||
|
secondary_count: float | None = Field(default=None, gt=0)
|
||||||
|
secondary_amount: float | None = Field(default=None, gt=0)
|
||||||
date_precision: DatePrecision | None = None
|
date_precision: DatePrecision | None = None
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
@@ -342,8 +384,18 @@ class ProductOut(BaseModel):
|
|||||||
display_unit_id: int | None = None
|
display_unit_id: int | None = None
|
||||||
package_size: float | None
|
package_size: float | None
|
||||||
package_label: str | None = None
|
package_label: str | None = None
|
||||||
|
# Zweiteinheit-Brücke, so wie eingegeben („3 Stück ≙ 250 g").
|
||||||
|
secondary_base: BaseUnit | None = None
|
||||||
|
secondary_count: float | None = None
|
||||||
|
secondary_amount: float | None = None
|
||||||
|
# Abgeleitet: Zweit-Basiseinheiten je EINER Basiseinheit des Artikels –
|
||||||
|
# damit die Oberflächen nicht selbst dividieren müssen.
|
||||||
|
secondary_factor: float | None = None
|
||||||
date_precision: DatePrecision = DatePrecision.day
|
date_precision: DatePrecision = DatePrecision.day
|
||||||
group_id: int | None
|
group_id: int | None
|
||||||
|
# Name der Gruppe – wie category_name, damit die Listen danach filtern
|
||||||
|
# koennen, ohne die Gruppen zusaetzlich zu laden.
|
||||||
|
group_name: str | None = None
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
category_name: str | None = None
|
category_name: str | None = None
|
||||||
min_stock: float | None
|
min_stock: float | None
|
||||||
@@ -482,6 +534,29 @@ class LotSplit(BaseModel):
|
|||||||
location_id: str | None = None
|
location_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Kassenzettel-Abgleich ----
|
||||||
|
class MatchRequest(BaseModel):
|
||||||
|
"""Kassenzettel-Zeilen (OCR) → beste Lebensmittel-Treffer je Zeile."""
|
||||||
|
lines: list[str]
|
||||||
|
threshold: int | None = None # 0–100; ohne Angabe gilt die Einstellung
|
||||||
|
|
||||||
|
|
||||||
|
class MatchCandidate(BaseModel):
|
||||||
|
product_id: int
|
||||||
|
name: str
|
||||||
|
brand: str | None = None
|
||||||
|
score: int # 0–100, Ähnlichkeit zur Kassenzeile
|
||||||
|
# Für die Einheit beim Einlagern (ganze Gebinde bzw. Basiseinheit) – so muss
|
||||||
|
# die App den Artikel nicht noch einmal einzeln laden.
|
||||||
|
package_size: float | None = None
|
||||||
|
base_unit: str
|
||||||
|
|
||||||
|
|
||||||
|
class MatchLine(BaseModel):
|
||||||
|
text: str
|
||||||
|
candidates: list[MatchCandidate] = []
|
||||||
|
|
||||||
|
|
||||||
class CheckInResponse(BaseModel):
|
class CheckInResponse(BaseModel):
|
||||||
lot: LotOut
|
lot: LotOut
|
||||||
product_stock: float
|
product_stock: float
|
||||||
@@ -637,11 +712,29 @@ class ItemOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# ---- Views ----
|
# ---- Views ----
|
||||||
|
class ShoppingNeed(BaseModel):
|
||||||
|
"""Fehlmenge als Gebinde-Leitangabe – bei zaehlbaren Packungen (Glas/Dose)
|
||||||
|
auf ganze aufgerundet, weil man nur ganze Gebinde kauft – mit der
|
||||||
|
Basiseinheit als kleinem Hinweis. So bekommen auch API-Konsumenten direkt
|
||||||
|
„1 Glas (500 g)", ohne selbst Packungsgroessen umzurechnen."""
|
||||||
|
text: str # Leitangabe: "1 Glas", "4 Dosen", "500 g"
|
||||||
|
hint: str = "" # Basiseinheit klein: "500 g" / "2 l" – leer wenn identisch
|
||||||
|
count: float # aufgerundete Gebindezahl (zum Weiterrechnen)
|
||||||
|
label: str # zur count passendes, pluralisiertes Label
|
||||||
|
singular: str # Einzahl-Label ("Glas", "Gramm", …)
|
||||||
|
is_package: bool = False # zaehlbares Gebinde (Glas/Dose) vs. Basis-/Anzeigeeinheit
|
||||||
|
base_amount: float | None = None
|
||||||
|
base_unit: BaseUnit | None = None
|
||||||
|
stock: float # Bestand in derselben Leitangabe
|
||||||
|
min_stock: float # Mindestbestand in derselben Leitangabe
|
||||||
|
|
||||||
|
|
||||||
class ShoppingItem(BaseModel):
|
class ShoppingItem(BaseModel):
|
||||||
product_id: int
|
product_id: int
|
||||||
name: str
|
name: str
|
||||||
base_unit: BaseUnit
|
base_unit: BaseUnit
|
||||||
package_size: float | None = None
|
package_size: float | None = None
|
||||||
|
need: ShoppingNeed | None = None
|
||||||
stock: float
|
stock: float
|
||||||
min_stock: float
|
min_stock: float
|
||||||
deficit: float
|
deficit: float
|
||||||
@@ -671,6 +764,9 @@ class GroupShoppingItem(BaseModel):
|
|||||||
deficit: float
|
deficit: float
|
||||||
unit_name: str = ""
|
unit_name: str = ""
|
||||||
product_count: int
|
product_count: int
|
||||||
|
# Wie viele Untergruppen mitgezaehlt werden (0 = keine).
|
||||||
|
subgroup_count: int = 0
|
||||||
|
need: ShoppingNeed | None = None
|
||||||
|
|
||||||
|
|
||||||
class LocationNeedProduct(BaseModel):
|
class LocationNeedProduct(BaseModel):
|
||||||
@@ -681,6 +777,7 @@ class LocationNeedProduct(BaseModel):
|
|||||||
stock: float
|
stock: float
|
||||||
min_stock: float
|
min_stock: float
|
||||||
deficit: float
|
deficit: float
|
||||||
|
need: ShoppingNeed | None = None
|
||||||
|
|
||||||
|
|
||||||
class LocationNeedGroup(BaseModel):
|
class LocationNeedGroup(BaseModel):
|
||||||
@@ -690,6 +787,9 @@ class LocationNeedGroup(BaseModel):
|
|||||||
stock: float
|
stock: float
|
||||||
min_stock: float
|
min_stock: float
|
||||||
deficit: float
|
deficit: float
|
||||||
|
# Wie viele Untergruppen mitgezaehlt werden (0 = keine).
|
||||||
|
subgroup_count: int = 0
|
||||||
|
need: ShoppingNeed | None = None
|
||||||
|
|
||||||
|
|
||||||
class LocationNeeds(BaseModel):
|
class LocationNeeds(BaseModel):
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ Einheiten (z.B. Kilogramm, Liter, Pfund) rechnen über ihren Faktor dorthin um.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..models import BaseUnit, Product, Unit, UnitKind
|
from ..models import BaseUnit, Group, Product, Unit, UnitKind
|
||||||
|
from . import gruppen
|
||||||
|
|
||||||
BASE_OF_KIND: dict[UnitKind, BaseUnit] = {
|
BASE_OF_KIND: dict[UnitKind, BaseUnit] = {
|
||||||
UnitKind.count: BaseUnit.piece,
|
UnitKind.count: BaseUnit.piece,
|
||||||
@@ -51,6 +54,50 @@ def find_unit(db: Session, token: str) -> Unit | None:
|
|||||||
return db.query(Unit).filter(func.lower(Unit.name) == t).first()
|
return db.query(Unit).filter(func.lower(Unit.name) == t).first()
|
||||||
|
|
||||||
|
|
||||||
|
def zweit_faktor(product: Product, ziel: BaseUnit) -> float | None:
|
||||||
|
"""Wie viele ``ziel``-Basiseinheiten EINE Basiseinheit des Artikels wert ist.
|
||||||
|
|
||||||
|
Die Zweiteinheit am Artikel („3 Stück ≙ 250 g") ist die einzige Brücke
|
||||||
|
zwischen den sonst strikt getrennten Arten. Sie gilt in BEIDE Richtungen:
|
||||||
|
derselbe Eintrag macht aus einem Stück-Artikel 83,333 g je Stück und aus
|
||||||
|
einem Gramm-Artikel 0,012 Stück je Gramm.
|
||||||
|
|
||||||
|
Gleiche Art → 1.0. Ohne (oder mit unbrauchbarer) Brücke → ``None``; dann
|
||||||
|
bleibt es bei der strikten Trennung, und der Aufrufer entscheidet, was das
|
||||||
|
heisst – ablehnen beim Einlagern, aussortieren beim Gruppenbestand.
|
||||||
|
"""
|
||||||
|
if product.base_unit == ziel:
|
||||||
|
return 1.0
|
||||||
|
if product.secondary_base != ziel.value:
|
||||||
|
return None # keine Brücke in DIESE Art
|
||||||
|
anzahl = product.secondary_count # in der Basiseinheit des Artikels
|
||||||
|
menge = product.secondary_amount # in secondary_base
|
||||||
|
if not anzahl or not menge or anzahl <= 0 or menge <= 0:
|
||||||
|
return None
|
||||||
|
# „3 Stück ≙ 250 g" -> 1 Stück = 250/3 g. Bei einem Gramm-Artikel steht
|
||||||
|
# dieselbe Zeile als „250 g ≙ 3 Stück" da und liefert 3/250 Stück je Gramm.
|
||||||
|
return menge / anzahl
|
||||||
|
|
||||||
|
|
||||||
|
def in_artikel_basis(product: Product, menge: float, quell_base: BaseUnit) -> float | None:
|
||||||
|
"""Gegenrichtung: eine Menge in ``quell_base`` in die Artikel-Basiseinheit.
|
||||||
|
|
||||||
|
Bewusst erst multiplizieren, dann teilen. ``250 * 3 / 250`` ist exakt 3;
|
||||||
|
der Umweg ueber den Faktor (``250 / (250/3)``) ergibt 3,0000000000000004 –
|
||||||
|
und daran scheitert sonst „den ganzen Bestand auslagern" an der Pruefung in
|
||||||
|
``check_out``.
|
||||||
|
"""
|
||||||
|
if quell_base == product.base_unit:
|
||||||
|
return menge
|
||||||
|
if product.secondary_base != quell_base.value:
|
||||||
|
return None
|
||||||
|
anzahl = product.secondary_count
|
||||||
|
zweit = product.secondary_amount
|
||||||
|
if not anzahl or not zweit or anzahl <= 0 or zweit <= 0:
|
||||||
|
return None
|
||||||
|
return menge * anzahl / zweit
|
||||||
|
|
||||||
|
|
||||||
def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> float:
|
def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> float:
|
||||||
"""Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um."""
|
"""Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um."""
|
||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
@@ -68,10 +115,18 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) ->
|
|||||||
if unit is None:
|
if unit is None:
|
||||||
raise ConversionError(f"Unbekannte Einheit: {unit_token}")
|
raise ConversionError(f"Unbekannte Einheit: {unit_token}")
|
||||||
if unit.kind != kind_of_product(product):
|
if unit.kind != kind_of_product(product):
|
||||||
raise ConversionError(
|
# Artfremde Einheit: geht nur über die Zweiteinheit des Artikels.
|
||||||
f"Einheit '{unit.name}' passt nicht zur Art des Produkts "
|
# „250 g" bei einem in Stück geführten Artikel wird zu 3 Stück.
|
||||||
f"({BASE_LABEL[product.base_unit]})."
|
# Bewusst OHNE Runden – krumme Mengen sind erlaubt, sonst driftete
|
||||||
)
|
# der gebuchte Bestand von der tatsächlichen Entnahme weg.
|
||||||
|
ergebnis = in_artikel_basis(product, quantity * unit.factor, BASE_OF_KIND[unit.kind])
|
||||||
|
if ergebnis is None:
|
||||||
|
raise ConversionError(
|
||||||
|
f"Einheit '{unit.name}' passt nicht zur Art des Produkts "
|
||||||
|
f"({BASE_LABEL[product.base_unit]}). Mit einer Zweiteinheit am "
|
||||||
|
f"Artikel (z.B. „3 Stück ≙ 250 g\") ginge es."
|
||||||
|
)
|
||||||
|
return ergebnis
|
||||||
return quantity * unit.factor
|
return quantity * unit.factor
|
||||||
|
|
||||||
|
|
||||||
@@ -108,3 +163,68 @@ def article_units(product: Product, quantity_base: float) -> float:
|
|||||||
"""Rechnet eine Menge in Basiseinheiten in Artikeleinheiten um."""
|
"""Rechnet eine Menge in Basiseinheiten in Artikeleinheiten um."""
|
||||||
factor, _ = article_unit(product)
|
factor, _ = article_unit(product)
|
||||||
return quantity_base / factor if factor else quantity_base
|
return quantity_base / factor if factor else quantity_base
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMinContext(NamedTuple):
|
||||||
|
divisor: float # Basiseinheiten je Mindestbestand-Einheit
|
||||||
|
label: str # Einzahl-Label ("Glas", "Gramm", …)
|
||||||
|
is_package: bool # zaehlbares Gebinde (aufrunden) vs. Anzeige-/Basiseinheit
|
||||||
|
base_unit: BaseUnit | None
|
||||||
|
matching: list[Product] # Produkte, die zur Gruppen-Einheit zaehlen
|
||||||
|
# Artikel-ID -> Gruppen-Basiseinheiten je EINER Basiseinheit des Artikels.
|
||||||
|
# Fuer gleichartige Artikel 1,0; fuer artfremde der Zweiteinheit-Faktor.
|
||||||
|
# Bestandssummen muessen damit GEWICHTET werden (services/stock.py).
|
||||||
|
faktoren: dict[int, float]
|
||||||
|
|
||||||
|
|
||||||
|
def group_min_context(group: Group) -> GroupMinContext:
|
||||||
|
"""In welcher Einheit der Mindestbestand einer Gruppe zu lesen ist.
|
||||||
|
|
||||||
|
Eine Gruppe zaehlt in ihrer verwalteten Einheit (``min_stock_unit``) oder – wenn
|
||||||
|
ein Gruppen-Gebinde definiert und aktiv ist – in ganzen Packungen. Die
|
||||||
|
Packungsgroesse ist ein **Richtwert der Gruppe** (nicht die der einzelnen
|
||||||
|
Produkte), weil deren Packungen unterschiedlich gross sein koennen.
|
||||||
|
|
||||||
|
``matching`` umfasst die Artikel der Gruppe UND ihrer Untergruppen
|
||||||
|
(transitiv): „Wurst" zaehlt Wurst, Grillwurst, Salami und alles darunter.
|
||||||
|
Die Einheiten-Filterung der OBERgruppe gilt dabei fuer den ganzen
|
||||||
|
Teilgraphen.
|
||||||
|
|
||||||
|
Artfremde Artikel zaehlen mit, WENN sie eine Zweiteinheit in die Art der
|
||||||
|
Gruppe tragen: ein in Stueck gefuehrter Artikel mit „3 Stueck ≙ 250 g"
|
||||||
|
zaehlt in einer Kilogramm-Gruppe mit 83,333 g je Stueck. Ohne Bruecke bleibt
|
||||||
|
er wie bisher aussen vor. Die Umrechnung steht in ``faktoren``.
|
||||||
|
"""
|
||||||
|
alle = gruppen.produkte(group) # inkl. Untergruppen, ohne Dubletten
|
||||||
|
unit = group.min_stock_unit
|
||||||
|
if unit is not None:
|
||||||
|
base = BASE_OF_KIND[unit.kind]
|
||||||
|
matching = []
|
||||||
|
faktoren: dict[int, float] = {}
|
||||||
|
for p in alle:
|
||||||
|
f = zweit_faktor(p, base)
|
||||||
|
if f is not None:
|
||||||
|
matching.append(p)
|
||||||
|
faktoren[p.id] = f
|
||||||
|
base_unit: BaseUnit | None = base
|
||||||
|
else:
|
||||||
|
# Ohne verwaltete Einheit zaehlt die Gruppe roh weiter – dann werden
|
||||||
|
# Gramm und Stueck weiterhin ungefiltert addiert (unveraendert).
|
||||||
|
matching = alle
|
||||||
|
faktoren = {p.id: 1.0 for p in alle}
|
||||||
|
base_unit = None
|
||||||
|
if group.min_stock_in_packages and group.package_size and group.package_size > 0:
|
||||||
|
return GroupMinContext(
|
||||||
|
divisor=float(group.package_size),
|
||||||
|
label=group.package_label or "Packung",
|
||||||
|
is_package=True, base_unit=base_unit, matching=matching, faktoren=faktoren,
|
||||||
|
)
|
||||||
|
if unit is not None:
|
||||||
|
return GroupMinContext(
|
||||||
|
divisor=unit.factor or 1.0, label=unit.name, is_package=False,
|
||||||
|
base_unit=base_unit, matching=matching, faktoren=faktoren,
|
||||||
|
)
|
||||||
|
return GroupMinContext(
|
||||||
|
divisor=1.0, label="", is_package=False,
|
||||||
|
base_unit=None, matching=matching, faktoren=faktoren,
|
||||||
|
)
|
||||||
|
|||||||
71
backend/app/services/gruppen.py
Normal file
71
backend/app/services/gruppen.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""Der Gruppen-Graph: Ober- und Untergruppen.
|
||||||
|
|
||||||
|
Anders als Kategorien und Lagerorte bilden Gruppen KEINEN Baum: „Grillwurst"
|
||||||
|
haengt unter „Wurst" UND unter „Grillgut" – mit einem einzelnen ``parent_id``
|
||||||
|
waere genau das nicht abbildbar. Die Struktur ist deshalb ein gerichteter
|
||||||
|
azyklischer Graph.
|
||||||
|
|
||||||
|
Daraus folgt die eine Regel, an der hier alles haengt: Auswertungen laufen ueber
|
||||||
|
MENGEN, nicht ueber Baum-Walks. Eine ueber zwei Wege erreichbare Untergruppe
|
||||||
|
wuerde sonst doppelt zaehlen und den Bestand ihrer Obergruppe verfaelschen.
|
||||||
|
|
||||||
|
Jede Funktion schuetzt sich zusaetzlich mit einem ``gesehen``-Set gegen Ringe.
|
||||||
|
Ueber die API kann keiner entstehen (Pruefung in routers/groups.py), eine
|
||||||
|
eingespielte Sicherung kann aber einen mitbringen – und ein Ring wuerde jede
|
||||||
|
Auswertung endlos laufen lassen. Gleiche Vorsichtsmassnahme wie in
|
||||||
|
routers/categories.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..models import Group, Product
|
||||||
|
|
||||||
|
|
||||||
|
def teilgraph(group: Group) -> list[Group]:
|
||||||
|
"""Die Gruppe samt ALLEN Untergruppen (transitiv), jede genau einmal."""
|
||||||
|
gesehen: set[int] = set()
|
||||||
|
ergebnis: list[Group] = []
|
||||||
|
offen = [group]
|
||||||
|
while offen:
|
||||||
|
aktuell = offen.pop()
|
||||||
|
if aktuell.id in gesehen:
|
||||||
|
continue # ueber zwei Wege erreichbar ODER Ring in den Daten
|
||||||
|
gesehen.add(aktuell.id)
|
||||||
|
ergebnis.append(aktuell)
|
||||||
|
offen.extend(aktuell.children)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def nachfahren_ids(group: Group) -> set[int]:
|
||||||
|
"""IDs aller Untergruppen (transitiv, ohne die Gruppe selbst)."""
|
||||||
|
return {g.id for g in teilgraph(group)} - {group.id}
|
||||||
|
|
||||||
|
|
||||||
|
def vorfahren_ids(group: Group) -> set[int]:
|
||||||
|
"""IDs aller Obergruppen (transitiv, ohne die Gruppe selbst)."""
|
||||||
|
gesehen: set[int] = set()
|
||||||
|
offen = list(group.parents)
|
||||||
|
while offen:
|
||||||
|
aktuell = offen.pop()
|
||||||
|
if aktuell.id in gesehen:
|
||||||
|
continue
|
||||||
|
gesehen.add(aktuell.id)
|
||||||
|
offen.extend(aktuell.parents)
|
||||||
|
return gesehen
|
||||||
|
|
||||||
|
|
||||||
|
def produkte(group: Group) -> list[Product]:
|
||||||
|
"""Alle Artikel der Gruppe UND ihrer Untergruppen – jeder genau einmal.
|
||||||
|
|
||||||
|
Ein Artikel haengt an genau einer Gruppe (``Product.group_id``), die
|
||||||
|
Entdopplung greift also schon auf Gruppenebene. Der Artikel-Check bleibt
|
||||||
|
trotzdem stehen: er kostet nichts und macht die Absicht sichtbar.
|
||||||
|
"""
|
||||||
|
gesehen: set[int] = set()
|
||||||
|
ergebnis: list[Product] = []
|
||||||
|
for g in teilgraph(group):
|
||||||
|
for p in g.products:
|
||||||
|
if p.id not in gesehen:
|
||||||
|
gesehen.add(p.id)
|
||||||
|
ergebnis.append(p)
|
||||||
|
return ergebnis
|
||||||
151
backend/app/services/min_stock.py
Normal file
151
backend/app/services/min_stock.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Mindestbestaende: „Ueberall" als Ort und die einmalige Datenwanderung.
|
||||||
|
|
||||||
|
Mindestbestaende haengen ausschliesslich an Lagerorten. Der frueher separate
|
||||||
|
Gesamt-Mindestbestand (``Product.min_stock`` / ``Group.min_stock``) ist jetzt
|
||||||
|
eine Zeile mit ``location_id = NULL`` – „Ueberall": egal wo, Hauptsache die
|
||||||
|
Menge ist im Haus. Damit ist Ueberall die Wurzel ueber allen Lagerorten und
|
||||||
|
wird von derselben Verrechnung erfasst wie verschachtelte Orte.
|
||||||
|
|
||||||
|
Alle Mengen stehen in BASISEINHEITEN. Vorher waren es drei verschiedene
|
||||||
|
Einheiten nebeneinander (Gesamt in Basiseinheiten, je Ort in Artikeleinheiten,
|
||||||
|
Gruppen in der Gruppen-Erfassungseinheit) – das machte jede Umrechnung zur
|
||||||
|
Fehlerquelle und liess gespeicherte Werte ihre Bedeutung aendern, sobald sich
|
||||||
|
eine Packungsgroesse aenderte.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import (
|
||||||
|
Group,
|
||||||
|
GroupLocationMinStock,
|
||||||
|
Product,
|
||||||
|
ProductLocationMinStock,
|
||||||
|
Setting,
|
||||||
|
)
|
||||||
|
from .conversion import article_unit, group_min_context
|
||||||
|
|
||||||
|
#: Anzeigename des Ortes NULL – hier und in der Oberflaeche derselbe Begriff.
|
||||||
|
UEBERALL_NAME = "Überall"
|
||||||
|
|
||||||
|
#: Merker, dass die Datenwanderung gelaufen ist. Schritt 1 (Umrechnen der
|
||||||
|
#: vorhandenen Werte) waere sonst nicht wiederholungsfest.
|
||||||
|
MIGRATION_KEY = "min_stock_basis_v2"
|
||||||
|
|
||||||
|
|
||||||
|
def lies_ueberall(eintraege: Iterable) -> float | None:
|
||||||
|
"""Der „Ueberall"-Mindestbestand (Ort NULL) aus den Ort-Eintraegen.
|
||||||
|
|
||||||
|
Ersetzt die frueheren Spalten ``Product.min_stock``/``Group.min_stock``.
|
||||||
|
Ergebnis in Basiseinheiten; ``None`` = kein Ueberall-Bedarf hinterlegt.
|
||||||
|
"""
|
||||||
|
for e in eintraege:
|
||||||
|
if e.location_id is None:
|
||||||
|
return e.min_stock
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def schreibe_ueberall(db: Session, besitzer, wert: float | None) -> None:
|
||||||
|
"""Den „Ueberall"-Mindestbestand setzen oder (bei None/0) entfernen.
|
||||||
|
|
||||||
|
``besitzer`` ist ein ``Product`` oder eine ``Group`` – beide haben eine
|
||||||
|
``location_min_stocks``-Beziehung mit derselben Form. Menge in
|
||||||
|
Basiseinheiten.
|
||||||
|
"""
|
||||||
|
vorhanden = next(
|
||||||
|
(e for e in besitzer.location_min_stocks if e.location_id is None), None
|
||||||
|
)
|
||||||
|
if wert is None or wert <= 0:
|
||||||
|
if vorhanden is not None:
|
||||||
|
besitzer.location_min_stocks.remove(vorhanden)
|
||||||
|
return
|
||||||
|
if vorhanden is not None:
|
||||||
|
vorhanden.min_stock = wert
|
||||||
|
return
|
||||||
|
if isinstance(besitzer, Product):
|
||||||
|
neu = ProductLocationMinStock(
|
||||||
|
product_id=besitzer.id, location_id=None, min_stock=wert
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
neu = GroupLocationMinStock(
|
||||||
|
group_id=besitzer.id, location_id=None, min_stock=wert
|
||||||
|
)
|
||||||
|
besitzer.location_min_stocks.append(neu)
|
||||||
|
|
||||||
|
|
||||||
|
def migriere_mindestbestaende(db: Session) -> None:
|
||||||
|
"""Einmalig: Gesamt-Werte zu „Ueberall" machen, alles in Basiseinheiten.
|
||||||
|
|
||||||
|
Laeuft beim Start (siehe main.py) und tut beim zweiten Mal nichts mehr.
|
||||||
|
"""
|
||||||
|
if db.get(Setting, MIGRATION_KEY) is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Bestehende Ort-Zeilen umrechnen: Artikel- bzw. Gruppeneinheit ->
|
||||||
|
# Basiseinheiten. Der einzige Schritt, der bei doppelter Ausfuehrung
|
||||||
|
# falsche Werte erzeugen wuerde – daher der Merker unten.
|
||||||
|
for eintrag in db.query(ProductLocationMinStock).all():
|
||||||
|
product = db.get(Product, eintrag.product_id)
|
||||||
|
if product is None:
|
||||||
|
continue
|
||||||
|
faktor, _ = article_unit(product)
|
||||||
|
eintrag.min_stock = eintrag.min_stock * (faktor or 1.0)
|
||||||
|
|
||||||
|
for eintrag in db.query(GroupLocationMinStock).all():
|
||||||
|
group = db.get(Group, eintrag.group_id)
|
||||||
|
if group is None:
|
||||||
|
continue
|
||||||
|
eintrag.min_stock = eintrag.min_stock * (group_min_context(group).divisor or 1.0)
|
||||||
|
|
||||||
|
# 2. Artikel-Gesamtwerte werden zur Ueberall-Zeile. ``Product.min_stock``
|
||||||
|
# stand bereits in Basiseinheiten, also keine Umrechnung.
|
||||||
|
artikel = (
|
||||||
|
db.query(Product)
|
||||||
|
.filter(Product.min_stock.isnot(None), Product.min_stock > 0)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for product in artikel:
|
||||||
|
schon_da = (
|
||||||
|
db.query(ProductLocationMinStock)
|
||||||
|
.filter(
|
||||||
|
ProductLocationMinStock.product_id == product.id,
|
||||||
|
ProductLocationMinStock.location_id.is_(None),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if schon_da is None:
|
||||||
|
db.add(
|
||||||
|
ProductLocationMinStock(
|
||||||
|
product_id=product.id, location_id=None, min_stock=product.min_stock
|
||||||
|
)
|
||||||
|
)
|
||||||
|
product.min_stock = None
|
||||||
|
|
||||||
|
# 3. Gruppen-Gesamtwerte genauso – die stehen in der Erfassungseinheit der
|
||||||
|
# Gruppe und muessen deshalb ueber den Divisor.
|
||||||
|
gruppen = db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all()
|
||||||
|
for group in gruppen:
|
||||||
|
divisor = group_min_context(group).divisor or 1.0
|
||||||
|
schon_da = (
|
||||||
|
db.query(GroupLocationMinStock)
|
||||||
|
.filter(
|
||||||
|
GroupLocationMinStock.group_id == group.id,
|
||||||
|
GroupLocationMinStock.location_id.is_(None),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if schon_da is None:
|
||||||
|
db.add(
|
||||||
|
GroupLocationMinStock(
|
||||||
|
group_id=group.id,
|
||||||
|
location_id=None,
|
||||||
|
min_stock=group.min_stock * divisor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
group.min_stock = None
|
||||||
|
|
||||||
|
db.add(Setting(key=MIGRATION_KEY, value="done"))
|
||||||
|
db.commit()
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from sqlalchemy import asc, func
|
from sqlalchemy import asc, func
|
||||||
@@ -215,6 +216,90 @@ 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 _summe_je_artikel(
|
||||||
|
db: Session, produkte: Sequence[Product], orte: set[str] | None
|
||||||
|
) -> dict[int, float]:
|
||||||
|
"""Bestand JE ARTIKEL (Basiseinheiten) in zwei Abfragen.
|
||||||
|
|
||||||
|
``orte`` = None zaehlt alles (auch Chargen ohne Lagerort), sonst nur die
|
||||||
|
genannten Orte. Gruppiert wird nach Artikel, weil verschiedene Artikel
|
||||||
|
unterschiedliche Umrechnungsfaktoren haben koennen (Zweiteinheit).
|
||||||
|
"""
|
||||||
|
if not produkte:
|
||||||
|
return {}
|
||||||
|
# Gleiche Fallunterscheidung wie current_stock: Einzelstuecke zaehlen ihre
|
||||||
|
# Items, alle anderen summieren die Lot-Mengen.
|
||||||
|
einzel = [p.id for p in produkte if p.individual]
|
||||||
|
lots = [p.id for p in produkte if not p.individual]
|
||||||
|
je_artikel: dict[int, float] = {}
|
||||||
|
if lots:
|
||||||
|
abfrage = (
|
||||||
|
db.query(Lot.product_id, func.coalesce(func.sum(Lot.quantity), 0.0))
|
||||||
|
.filter(Lot.product_id.in_(lots))
|
||||||
|
)
|
||||||
|
if orte is not None:
|
||||||
|
abfrage = abfrage.filter(Lot.location_id.in_(orte))
|
||||||
|
for pid, menge in abfrage.group_by(Lot.product_id).all():
|
||||||
|
je_artikel[pid] = je_artikel.get(pid, 0.0) + float(menge or 0.0)
|
||||||
|
if einzel:
|
||||||
|
abfrage = (
|
||||||
|
db.query(Item.product_id, func.count(Item.id))
|
||||||
|
.filter(Item.product_id.in_(einzel))
|
||||||
|
)
|
||||||
|
if orte is not None:
|
||||||
|
abfrage = abfrage.filter(Item.location_id.in_(orte))
|
||||||
|
for pid, anzahl in abfrage.group_by(Item.product_id).all():
|
||||||
|
je_artikel[pid] = je_artikel.get(pid, 0.0) + float(anzahl or 0)
|
||||||
|
return je_artikel
|
||||||
|
|
||||||
|
|
||||||
|
def summe_bestand_gewichtet(
|
||||||
|
db: Session,
|
||||||
|
produkte: Sequence[Product],
|
||||||
|
faktoren: dict[int, float] | None = None,
|
||||||
|
) -> float:
|
||||||
|
"""Gesamtbestand mehrerer Artikel, je Artikel mit eigenem Faktor.
|
||||||
|
|
||||||
|
Der Faktor kommt aus der Zweiteinheit (``GroupMinContext.faktoren``): eine
|
||||||
|
Gruppe in Kilogramm rechnet einen in Stueck gefuehrten Artikel ueber
|
||||||
|
83,333 g je Stueck mit. Ohne Faktoren-Tabelle wird ungewichtet addiert.
|
||||||
|
"""
|
||||||
|
je_artikel = _summe_je_artikel(db, produkte, None)
|
||||||
|
if faktoren is None:
|
||||||
|
return float(sum(je_artikel.values()))
|
||||||
|
return float(sum(m * faktoren.get(pid, 1.0) for pid, m in je_artikel.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def summe_bestand_base(db: Session, produkte: Sequence[Product]) -> float:
|
||||||
|
"""Gesamtbestand mehrerer Artikel (Basiseinheiten), ungewichtet."""
|
||||||
|
return summe_bestand_gewichtet(db, produkte, None)
|
||||||
|
|
||||||
|
|
||||||
|
def summe_bestand_im_subtree_gewichtet(
|
||||||
|
db: Session,
|
||||||
|
produkte: Sequence[Product],
|
||||||
|
location_id: str,
|
||||||
|
faktoren: dict[int, float] | None = None,
|
||||||
|
) -> float:
|
||||||
|
"""Wie ``summe_bestand_gewichtet``, aber nur an einem Ort inkl. Unterorten.
|
||||||
|
|
||||||
|
Chargen ohne Lagerort liegen in keinem Subtree und zaehlen hier bewusst
|
||||||
|
nicht mit – im Gesamtbestand („Ueberall") dagegen schon.
|
||||||
|
"""
|
||||||
|
ids = {location_id} | descendant_location_ids(db, location_id)
|
||||||
|
je_artikel = _summe_je_artikel(db, produkte, ids)
|
||||||
|
if faktoren is None:
|
||||||
|
return float(sum(je_artikel.values()))
|
||||||
|
return float(sum(m * faktoren.get(pid, 1.0) for pid, m in je_artikel.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def summe_bestand_im_subtree_base(
|
||||||
|
db: Session, produkte: Sequence[Product], location_id: str
|
||||||
|
) -> float:
|
||||||
|
"""Ungewichtete Variante von ``summe_bestand_im_subtree_gewichtet``."""
|
||||||
|
return summe_bestand_im_subtree_gewichtet(db, produkte, location_id, None)
|
||||||
|
|
||||||
|
|
||||||
def location_stock_base(db: Session, product: Product, location_id: str) -> float:
|
def location_stock_base(db: Session, product: Product, location_id: str) -> float:
|
||||||
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||||||
|
|
||||||
|
|||||||
159
backend/tests/test_backup_gruppen.py
Normal file
159
backend/tests/test_backup_gruppen.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""Sicherung: Obergruppen und Mindestbestände je Ort überstehen den Umlauf.
|
||||||
|
|
||||||
|
Ohne diesen Weg gingen genau die Daten verloren, um die es beim Umbau ging –
|
||||||
|
die Kanten des Gruppen-Graphen und die „Überall"-Zeilen. Das JSON-Backup führt
|
||||||
|
Gruppen über ihren NAMEN, nicht über IDs: die sind zwischen zwei Instanzen
|
||||||
|
nicht gleich.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
BaseUnit,
|
||||||
|
Group,
|
||||||
|
GroupLocationMinStock,
|
||||||
|
Location,
|
||||||
|
Product,
|
||||||
|
ProductLocationMinStock,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.routers.transfer import _import_json, export_backup_json
|
||||||
|
|
||||||
|
|
||||||
|
@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 _sicherung(db, user) -> bytes:
|
||||||
|
return export_backup_json(db=db, _=user).body
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_nimmt_obergruppen_und_bedarfe_mit(db, user, monkeypatch):
|
||||||
|
keller = Location(name="Keller")
|
||||||
|
db.add(keller)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
wurst = Group(name="Wurst")
|
||||||
|
grillgut = Group(name="Grillgut")
|
||||||
|
db.add_all([wurst, grillgut])
|
||||||
|
db.flush()
|
||||||
|
grillwurst = Group(name="Grillwurst", parents=[wurst, grillgut])
|
||||||
|
db.add(grillwurst)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
p = Product(name="Bell Grillwurst", base_unit=BaseUnit.gram, group_id=grillwurst.id)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
db.add_all([
|
||||||
|
GroupLocationMinStock(group_id=wurst.id, location_id=None, min_stock=5000),
|
||||||
|
GroupLocationMinStock(group_id=grillwurst.id, location_id=keller.id, min_stock=2000),
|
||||||
|
ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=800),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
daten = json.loads(_sicherung(db, user))
|
||||||
|
assert daten["version"] == 3
|
||||||
|
nach_name = {g["name"]: g for g in daten["groups"]}
|
||||||
|
assert sorted(nach_name["Grillwurst"]["parents"]) == ["Grillgut", "Wurst"]
|
||||||
|
assert {"location": None, "min_stock": 5000} in nach_name["Wurst"]["min_stocks"]
|
||||||
|
assert {"location": "Keller", "min_stock": 2000} in nach_name["Grillwurst"]["min_stocks"]
|
||||||
|
artikel = daten["products"][0]
|
||||||
|
assert {"location": None, "min_stock": 800} in artikel["min_stocks"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wiederherstellen_baut_den_graphen_neu_auf(db, user):
|
||||||
|
"""Sicherung aus einer Instanz, Einspielen in eine leere zweite."""
|
||||||
|
keller = Location(name="Keller")
|
||||||
|
db.add(keller)
|
||||||
|
db.flush()
|
||||||
|
wurst = Group(name="Wurst")
|
||||||
|
grillgut = Group(name="Grillgut")
|
||||||
|
db.add_all([wurst, grillgut])
|
||||||
|
db.flush()
|
||||||
|
grillwurst = Group(name="Grillwurst", parents=[wurst, grillgut])
|
||||||
|
db.add(grillwurst)
|
||||||
|
db.flush()
|
||||||
|
p = Product(name="Bell Grillwurst", base_unit=BaseUnit.gram, group_id=grillwurst.id)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
db.add_all([
|
||||||
|
GroupLocationMinStock(group_id=wurst.id, location_id=None, min_stock=5000),
|
||||||
|
ProductLocationMinStock(product_id=p.id, location_id=keller.id, min_stock=800),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
inhalt = _sicherung(db, user)
|
||||||
|
|
||||||
|
# Zweite, leere Instanz.
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.seed import ensure_builtin_units
|
||||||
|
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
zweite = sessionmaker(bind=engine, autoflush=False, autocommit=False)()
|
||||||
|
ensure_builtin_units(zweite)
|
||||||
|
person = User(username="zwei", password_hash="x", role=Role.admin)
|
||||||
|
zweite.add(person)
|
||||||
|
zweite.commit()
|
||||||
|
|
||||||
|
_import_json(zweite, inhalt, person, "add")
|
||||||
|
|
||||||
|
neu_wurst = zweite.query(Group).filter(Group.name == "Wurst").one()
|
||||||
|
neu_grillwurst = zweite.query(Group).filter(Group.name == "Grillwurst").one()
|
||||||
|
assert sorted(g.name for g in neu_grillwurst.parents) == ["Grillgut", "Wurst"]
|
||||||
|
assert neu_wurst.location_min_stocks[0].location_id is None
|
||||||
|
assert neu_wurst.location_min_stocks[0].min_stock == pytest.approx(5000)
|
||||||
|
|
||||||
|
neu_artikel = zweite.query(Product).filter(Product.name == "Bell Grillwurst").one()
|
||||||
|
orte = {
|
||||||
|
(e.location.name if e.location else None): e.min_stock
|
||||||
|
for e in neu_artikel.location_min_stocks
|
||||||
|
}
|
||||||
|
assert orte == {"Keller": pytest.approx(800)}
|
||||||
|
zweite.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_aus_der_sicherung_wird_abgewiesen(db, user):
|
||||||
|
"""Eine beschädigte Datei darf keinen Ring einschleusen."""
|
||||||
|
daten = {
|
||||||
|
"version": 3,
|
||||||
|
"groups": [
|
||||||
|
{"name": "A", "parents": ["B"]},
|
||||||
|
{"name": "B", "parents": ["A"]},
|
||||||
|
],
|
||||||
|
"products": [],
|
||||||
|
}
|
||||||
|
ergebnis = _import_json(db, json.dumps(daten).encode(), user, "add")
|
||||||
|
|
||||||
|
a = db.query(Group).filter(Group.name == "A").one()
|
||||||
|
b = db.query(Group).filter(Group.name == "B").one()
|
||||||
|
# Eine der beiden Kanten greift, die andere wird als Ring abgewiesen.
|
||||||
|
assert not (a.parents and b.parents)
|
||||||
|
assert any("Ring" in f for f in ergebnis.get("errors", []))
|
||||||
|
|
||||||
|
|
||||||
|
def test_sicherung_nimmt_zweiteinheit_mit(db, user):
|
||||||
|
"""Ohne das ginge die Brücke beim Wiederherstellen stillschweigend verloren."""
|
||||||
|
p = Product(name="Bratwurst", base_unit=BaseUnit.piece,
|
||||||
|
secondary_base="gram", secondary_count=3, secondary_amount=250)
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
daten = json.loads(_sicherung(db, user))
|
||||||
|
artikel = daten["products"][0]
|
||||||
|
assert artikel["secondary_base"] == "gram"
|
||||||
|
assert artikel["secondary_count"] == 3
|
||||||
|
assert artikel["secondary_amount"] == 250
|
||||||
236
backend/tests/test_gruppen_hierarchie.py
Normal file
236
backend/tests/test_gruppen_hierarchie.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""Ober- und Untergruppen: Bestand, Verrechnung und Ringschutz.
|
||||||
|
|
||||||
|
Gruppen bilden einen gerichteten azyklischen Graphen, keinen Baum: „Grillwurst"
|
||||||
|
haengt unter „Wurst" UND unter „Grillgut". Der wichtigste Test hier ist deshalb
|
||||||
|
``test_zwei_wege_zaehlen_nur_einmal`` – ein Baum-Walk statt einer Mengenbildung
|
||||||
|
wuerde den Bestand einer ueber zwei Wege erreichbaren Untergruppe verdoppeln.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
BaseUnit,
|
||||||
|
Group,
|
||||||
|
GroupLocationMinStock,
|
||||||
|
Location,
|
||||||
|
Lot,
|
||||||
|
Product,
|
||||||
|
Role,
|
||||||
|
Unit,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.routers.groups import _group_to_out, create_group, delete_group, update_group
|
||||||
|
from app.routers.views import group_shopping_list, shopping_list_by_location
|
||||||
|
from app.schemas import GroupCreate, GroupUpdate
|
||||||
|
from app.services.min_stock import schreibe_ueberall
|
||||||
|
|
||||||
|
|
||||||
|
@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 _gramm(db) -> Unit:
|
||||||
|
return db.query(Unit).filter(Unit.name == "Gramm").one()
|
||||||
|
|
||||||
|
|
||||||
|
def _gruppe(db, name: str, *, eltern: list[Group] | None = None) -> Group:
|
||||||
|
g = Group(name=name, min_stock_unit=_gramm(db))
|
||||||
|
if eltern:
|
||||||
|
g.parents = list(eltern)
|
||||||
|
db.add(g)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(g)
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def _artikel(db, name: str, gruppe: Group, bestand: float = 0.0, ort=None) -> Product:
|
||||||
|
p = Product(name=name, base_unit=BaseUnit.gram, group=gruppe)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
if bestand:
|
||||||
|
db.add(Lot(product_id=p.id, quantity=bestand,
|
||||||
|
location_id=ort.id if ort is not None else None))
|
||||||
|
db.commit()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _ueberall(db, besitzer, menge_base: float) -> None:
|
||||||
|
schreibe_ueberall(db, besitzer, menge_base)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bestand_zaehlt_untergruppen_transitiv(db, user):
|
||||||
|
"""Bestand nur im Blatt – die Obergruppe zwei Ebenen darueber sieht ihn."""
|
||||||
|
wurst = _gruppe(db, "Wurst")
|
||||||
|
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst])
|
||||||
|
bratwurst = _gruppe(db, "Bratwurst", eltern=[grillwurst])
|
||||||
|
_artikel(db, "Bell Bratwurst", bratwurst, bestand=800)
|
||||||
|
|
||||||
|
assert _group_to_out(db, bratwurst).stock == pytest.approx(800)
|
||||||
|
assert _group_to_out(db, grillwurst).stock == pytest.approx(800)
|
||||||
|
assert _group_to_out(db, wurst).stock == pytest.approx(800)
|
||||||
|
# product_count zaehlt transitiv, direct_product_count nur die eigenen.
|
||||||
|
oben = _group_to_out(db, wurst)
|
||||||
|
assert oben.product_count == 1
|
||||||
|
assert oben.direct_product_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_zwei_wege_zaehlen_nur_einmal(db, user):
|
||||||
|
"""Grillwurst haengt unter Wurst UND Grillgut, beide unter Fleisch.
|
||||||
|
|
||||||
|
„Fleisch" erreicht Grillwurst ueber zwei Wege – der Bestand darf trotzdem
|
||||||
|
nur einmal zaehlen.
|
||||||
|
"""
|
||||||
|
fleisch = _gruppe(db, "Fleisch")
|
||||||
|
wurst = _gruppe(db, "Wurst", eltern=[fleisch])
|
||||||
|
grillgut = _gruppe(db, "Grillgut", eltern=[fleisch])
|
||||||
|
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst, grillgut])
|
||||||
|
_artikel(db, "Bell Grillwurst", grillwurst, bestand=500)
|
||||||
|
|
||||||
|
assert _group_to_out(db, grillwurst).stock == pytest.approx(500)
|
||||||
|
assert _group_to_out(db, wurst).stock == pytest.approx(500)
|
||||||
|
assert _group_to_out(db, grillgut).stock == pytest.approx(500)
|
||||||
|
# Der eigentliche Punkt: NICHT 1000.
|
||||||
|
assert _group_to_out(db, fleisch).stock == pytest.approx(500)
|
||||||
|
assert _group_to_out(db, fleisch).product_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_wird_abgelehnt(db, user):
|
||||||
|
"""Weder sich selbst noch einer eigenen Untergruppe unterordnen."""
|
||||||
|
wurst = _gruppe(db, "Wurst")
|
||||||
|
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst])
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as fehler:
|
||||||
|
update_group(group_id=wurst.id, payload=GroupUpdate(parent_ids=[grillwurst.id]),
|
||||||
|
db=db, _=user)
|
||||||
|
assert fehler.value.status_code == 409
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
update_group(group_id=wurst.id, payload=GroupUpdate(parent_ids=[wurst.id]),
|
||||||
|
db=db, _=user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_obergruppe_beim_anlegen_setzen(db, user):
|
||||||
|
"""parent_ids wirkt auch schon beim Anlegen (braucht die frische ID)."""
|
||||||
|
wurst = _gruppe(db, "Wurst")
|
||||||
|
out = create_group(
|
||||||
|
payload=GroupCreate(name="Salami", parent_ids=[wurst.id]), db=db, _=user
|
||||||
|
)
|
||||||
|
assert out.parent_ids == [wurst.id]
|
||||||
|
assert _group_to_out(db, wurst).child_ids == [out.id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bedarf_ober_und_untergruppe_wird_verrechnet(db, user):
|
||||||
|
"""Wurst 5 kg, Grillwurst 2 kg, 1 kg vorhanden.
|
||||||
|
|
||||||
|
Fuer Grillwurst fehlt 1 kg. Das deckt bei Wurst mit, dort fehlen also
|
||||||
|
5 - (1 vorhanden + 1 gekauft) = 3 kg – nicht 4.
|
||||||
|
"""
|
||||||
|
wurst = _gruppe(db, "Wurst")
|
||||||
|
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst])
|
||||||
|
_artikel(db, "Bell Grillwurst", grillwurst, bestand=1000)
|
||||||
|
_ueberall(db, wurst, 5000)
|
||||||
|
_ueberall(db, grillwurst, 2000)
|
||||||
|
|
||||||
|
nach_name = {i.name: i for i in group_shopping_list(db=db, _=user)}
|
||||||
|
assert nach_name["Grillwurst"].deficit == pytest.approx(1000)
|
||||||
|
assert nach_name["Wurst"].deficit == pytest.approx(3000)
|
||||||
|
assert nach_name["Wurst"].subgroup_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_untergruppen_kauf_deckt_obergruppe_ganz(db, user):
|
||||||
|
"""Deckt der Kauf fuer die Untergruppe alles, verschwindet die Obergruppe."""
|
||||||
|
wurst = _gruppe(db, "Wurst")
|
||||||
|
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst])
|
||||||
|
_artikel(db, "Bell Grillwurst", grillwurst)
|
||||||
|
_ueberall(db, wurst, 2000)
|
||||||
|
_ueberall(db, grillwurst, 2000)
|
||||||
|
|
||||||
|
namen = {i.name for i in group_shopping_list(db=db, _=user)}
|
||||||
|
assert namen == {"Grillwurst"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ueberlappung_bleibt_stehen(db, user):
|
||||||
|
"""Bekannte Grenze, absichtlich festgehalten.
|
||||||
|
|
||||||
|
„Wurst" und „Grillgut" sind keine Vorfahren voneinander, teilen sich aber
|
||||||
|
„Grillwurst". Ohne eigenen Mindestbestand auf der gemeinsamen Untergruppe
|
||||||
|
gibt es keinen Schluessel, ueber den verrechnet werden koennte – beide
|
||||||
|
Bedarfe stehen einzeln da, obwohl ein Kauf beide decken wuerde.
|
||||||
|
"""
|
||||||
|
wurst = _gruppe(db, "Wurst")
|
||||||
|
grillgut = _gruppe(db, "Grillgut")
|
||||||
|
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst, grillgut])
|
||||||
|
_artikel(db, "Bell Grillwurst", grillwurst)
|
||||||
|
_ueberall(db, wurst, 2000)
|
||||||
|
_ueberall(db, grillgut, 2000)
|
||||||
|
|
||||||
|
nach_name = {i.name: i for i in group_shopping_list(db=db, _=user)}
|
||||||
|
assert nach_name["Wurst"].deficit == pytest.approx(2000)
|
||||||
|
assert nach_name["Grillgut"].deficit == pytest.approx(2000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_einheitenfilter_gilt_auch_fuer_untergruppen(db, user):
|
||||||
|
"""Eine Obergruppe in Gramm zaehlt stueckweise Artikel der Untergruppe nicht."""
|
||||||
|
stueck = db.query(Unit).filter(Unit.name == "Stück").one()
|
||||||
|
wurst = _gruppe(db, "Wurst") # zaehlt in Gramm
|
||||||
|
dosen = Group(name="Wurstdosen", min_stock_unit=stueck, parents=[wurst])
|
||||||
|
db.add(dosen)
|
||||||
|
db.commit()
|
||||||
|
_artikel(db, "Wurst im Glas", wurst, bestand=500)
|
||||||
|
p = Product(name="Wurstdose", base_unit=BaseUnit.piece, group=dosen)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
db.add(Lot(product_id=p.id, quantity=4))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Die 4 Stueck zaehlen bei „Wurst" (Gramm) nicht mit.
|
||||||
|
assert _group_to_out(db, wurst).stock == pytest.approx(500)
|
||||||
|
assert _group_to_out(db, dosen).stock == pytest.approx(4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruppenbedarf_je_ort_verrechnet_beide_hierarchien(db, user):
|
||||||
|
"""Kauf fuer die Untergruppe im Unterort deckt die Obergruppe im Oberort."""
|
||||||
|
haus = Location(name="Haus")
|
||||||
|
db.add(haus)
|
||||||
|
db.flush()
|
||||||
|
kueche = Location(name="Küche", parent_id=haus.id)
|
||||||
|
db.add(kueche)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
wurst = _gruppe(db, "Wurst")
|
||||||
|
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst])
|
||||||
|
_artikel(db, "Bell Grillwurst", grillwurst, bestand=1000, ort=kueche)
|
||||||
|
db.add_all([
|
||||||
|
GroupLocationMinStock(group_id=wurst.id, location_id=haus.id, min_stock=5000),
|
||||||
|
GroupLocationMinStock(group_id=grillwurst.id, location_id=kueche.id, min_stock=2000),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)}
|
||||||
|
kueche_bedarf = {g.name: g for g in nach_ort["Küche"].groups}
|
||||||
|
haus_bedarf = {g.name: g for g in nach_ort["Haus"].groups}
|
||||||
|
assert kueche_bedarf["Grillwurst"].deficit == pytest.approx(1000)
|
||||||
|
# 5000 - (1000 vorhanden + 1000 fuer die Kueche gekauft)
|
||||||
|
assert haus_bedarf["Wurst"].deficit == pytest.approx(3000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_loeschen_einer_gruppe_mit_kindern(db, user):
|
||||||
|
"""Untergruppen bleiben bestehen und verlieren nur die Verbindung."""
|
||||||
|
wurst = _gruppe(db, "Wurst")
|
||||||
|
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst])
|
||||||
|
p = _artikel(db, "Bell Grillwurst", grillwurst, bestand=500)
|
||||||
|
|
||||||
|
delete_group(group_id=wurst.id, db=db, _=user)
|
||||||
|
|
||||||
|
db.refresh(grillwurst)
|
||||||
|
assert db.get(Group, wurst.id) is None
|
||||||
|
assert _group_to_out(db, grillwurst).parent_ids == []
|
||||||
|
db.refresh(p)
|
||||||
|
assert p.group_id == grillwurst.id
|
||||||
192
backend/tests/test_min_stock_ueberall.py
Normal file
192
backend/tests/test_min_stock_ueberall.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""Mindestbestände hängen nur noch an Lagerorten – „Überall" ist Ort NULL.
|
||||||
|
|
||||||
|
„Überall" (egal wo, Hauptsache im Haus) ersetzt den früheren Gesamt-Mindest-
|
||||||
|
bestand und liegt als Wurzel über allen Lagerorten. Dadurch verrechnet dieselbe
|
||||||
|
Faltung wie bei verschachtelten Orten auch Überall gegen Küche & Co. – ein
|
||||||
|
Artikel steht nicht mehr doppelt in der Einkaufsliste.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
BaseUnit,
|
||||||
|
Group,
|
||||||
|
GroupLocationMinStock,
|
||||||
|
Location,
|
||||||
|
Lot,
|
||||||
|
Product,
|
||||||
|
ProductLocationMinStock,
|
||||||
|
Role,
|
||||||
|
Setting,
|
||||||
|
Unit,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.crud import product_to_out
|
||||||
|
from app.routers.views import shopping_list, shopping_list_by_location
|
||||||
|
from app.services.min_stock import (
|
||||||
|
MIGRATION_KEY,
|
||||||
|
UEBERALL_NAME,
|
||||||
|
lies_ueberall,
|
||||||
|
migriere_mindestbestaende,
|
||||||
|
schreibe_ueberall,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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 _artikel(db, name="Gurken", **kwargs) -> Product:
|
||||||
|
# package_size=1 -> Artikeleinheit == Basiseinheit, macht die Rechnung klar.
|
||||||
|
p = Product(name=name, base_unit=BaseUnit.gram, package_size=1, **kwargs)
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_ueberall_zeile_wird_gelesen(db, user):
|
||||||
|
p = _artikel(db)
|
||||||
|
schreibe_ueberall(db, p, 500)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
out = product_to_out(db, p)
|
||||||
|
assert out.min_stock == pytest.approx(500)
|
||||||
|
(zeile,) = out.location_min_stocks
|
||||||
|
assert zeile.location_id is None
|
||||||
|
assert zeile.location_name == UEBERALL_NAME
|
||||||
|
assert lies_ueberall(p.location_min_stocks) == pytest.approx(500)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ueberall_auf_null_setzen_entfernt_die_zeile(db, user):
|
||||||
|
p = _artikel(db)
|
||||||
|
schreibe_ueberall(db, p, 500)
|
||||||
|
db.commit()
|
||||||
|
schreibe_ueberall(db, p, None)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert p.location_min_stocks == []
|
||||||
|
assert product_to_out(db, p).min_stock is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_zwei_ueberall_zeilen_werden_abgelehnt(db, user):
|
||||||
|
"""Der Teilindex ersetzt den UNIQUE-Schutz, den NULLs nicht bekommen."""
|
||||||
|
p = _artikel(db)
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=500))
|
||||||
|
db.commit()
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=900))
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
db.commit()
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ueberall_wird_gegen_lagerort_verrechnet(db, user):
|
||||||
|
"""Überall 5, Küche 2, je 1 vorhanden -> nicht 6 kaufen.
|
||||||
|
|
||||||
|
Für die Küche fehlt 1. Das liegt danach auch im Haus und deckt Überall mit:
|
||||||
|
dort fehlen 5 - (1 vorhanden + 1 gekauft) = 3.
|
||||||
|
"""
|
||||||
|
p = _artikel(db, "Gurken")
|
||||||
|
kueche = Location(name="Küche")
|
||||||
|
db.add(kueche)
|
||||||
|
db.flush()
|
||||||
|
db.add(Lot(product_id=p.id, quantity=1, location_id=kueche.id))
|
||||||
|
db.add_all([
|
||||||
|
ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=5),
|
||||||
|
ProductLocationMinStock(product_id=p.id, location_id=kueche.id, min_stock=2),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
(gesamt,) = shopping_list(db=db, _=user)
|
||||||
|
assert gesamt.deficit == pytest.approx(3)
|
||||||
|
|
||||||
|
nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)}
|
||||||
|
assert nach_ort["Küche"].products[0].deficit == pytest.approx(1)
|
||||||
|
# „Überall" gehört nicht in die Ortsliste.
|
||||||
|
assert UEBERALL_NAME not in nach_ort
|
||||||
|
|
||||||
|
|
||||||
|
def test_ueberall_zaehlt_chargen_ohne_ort_mit(db, user):
|
||||||
|
"""Eine Charge ohne Lagerort liegt in keinem Ort-Subtree, aber im Haus."""
|
||||||
|
p = _artikel(db, "Butter")
|
||||||
|
db.add(Lot(product_id=p.id, quantity=4, location_id=None))
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=5))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
(gesamt,) = shopping_list(db=db, _=user)
|
||||||
|
assert gesamt.stock == pytest.approx(4)
|
||||||
|
assert gesamt.deficit == pytest.approx(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Datenwanderung ----
|
||||||
|
|
||||||
|
def _gramm(db) -> Unit:
|
||||||
|
return db.query(Unit).filter(Unit.name == "Gramm").one()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_macht_gesamtwerte_zu_ueberall(db, user):
|
||||||
|
"""Alter Stand: min_stock am Artikel, Ort-Werte in Artikeleinheiten."""
|
||||||
|
p = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=150,
|
||||||
|
package_label="Glas", min_stock=600)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
keller = Location(name="Keller")
|
||||||
|
db.add(keller)
|
||||||
|
db.flush()
|
||||||
|
# 2 Gläser à 150 g – frueher so gespeichert (Artikeleinheiten).
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=keller.id, min_stock=2))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
migriere_mindestbestaende(db)
|
||||||
|
db.refresh(p)
|
||||||
|
|
||||||
|
assert p.min_stock is None # Quelle geleert
|
||||||
|
werte = {e.location_id: e.min_stock for e in p.location_min_stocks}
|
||||||
|
assert werte[None] == pytest.approx(600) # war schon Basiseinheiten
|
||||||
|
assert werte[keller.id] == pytest.approx(300) # 2 Gläser * 150 g
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_rechnet_gruppenwerte_ueber_den_divisor(db, user):
|
||||||
|
gruppe = Group(name="Pesto", min_stock=3, min_stock_unit=_gramm(db),
|
||||||
|
package_size=150, package_label="Glas", min_stock_in_packages=True)
|
||||||
|
db.add(gruppe)
|
||||||
|
db.flush()
|
||||||
|
keller = Location(name="Keller")
|
||||||
|
db.add(keller)
|
||||||
|
db.flush()
|
||||||
|
db.add(GroupLocationMinStock(group_id=gruppe.id, location_id=keller.id, min_stock=2))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
migriere_mindestbestaende(db)
|
||||||
|
db.refresh(gruppe)
|
||||||
|
|
||||||
|
assert gruppe.min_stock is None
|
||||||
|
werte = {e.location_id: e.min_stock for e in gruppe.location_min_stocks}
|
||||||
|
assert werte[None] == pytest.approx(450) # 3 Gläser * 150 g
|
||||||
|
assert werte[keller.id] == pytest.approx(300) # 2 Gläser * 150 g
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_laeuft_nur_einmal(db, user):
|
||||||
|
"""Der zweite Lauf darf die Werte nicht erneut hochrechnen."""
|
||||||
|
p = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=150, min_stock=600)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
keller = Location(name="Keller")
|
||||||
|
db.add(keller)
|
||||||
|
db.flush()
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=keller.id, min_stock=2))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
migriere_mindestbestaende(db)
|
||||||
|
migriere_mindestbestaende(db)
|
||||||
|
db.refresh(p)
|
||||||
|
|
||||||
|
werte = {e.location_id: e.min_stock for e in p.location_min_stocks}
|
||||||
|
assert werte[keller.id] == pytest.approx(300) # nicht 45000
|
||||||
|
assert db.get(Setting, MIGRATION_KEY) is not None
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from app.off import parse_quantity
|
from app.off import parse_gebinde, parse_quantity
|
||||||
|
|
||||||
|
|
||||||
def test_grams():
|
def test_grams():
|
||||||
@@ -22,3 +22,31 @@ def test_unparsable_is_piece():
|
|||||||
assert parse_quantity(None) == ("piece", None)
|
assert parse_quantity(None) == ("piece", None)
|
||||||
assert parse_quantity("6 Stück") == ("piece", None)
|
assert parse_quantity("6 Stück") == ("piece", None)
|
||||||
assert parse_quantity("") == ("piece", None)
|
assert parse_quantity("") == ("piece", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiplikator_wird_erkannt():
|
||||||
|
"""„3 x 80 g" ist nicht dasselbe wie „240 g": drei zaehlbare Teile."""
|
||||||
|
assert parse_gebinde("3 x 80 g") == ("gram", 240.0, 3.0, 80.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiplikator_liter():
|
||||||
|
assert parse_gebinde("6 × 1,5 l") == ("milliliter", 9000.0, 6.0, 1500.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiplikator_mit_a_akzent():
|
||||||
|
assert parse_gebinde("4 Stück à 125 g") == ("gram", 500.0, 4.0, 125.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_multiplikator_unveraendert():
|
||||||
|
assert parse_gebinde("500 g") == ("gram", 500.0, None, None)
|
||||||
|
assert parse_quantity("500 g") == ("gram", 500)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zaehlwort_ohne_muster_bleibt_grenze():
|
||||||
|
"""Bekannte Grenze: „12 Eier à 53 g" faellt auf die Einzelmenge zurueck.
|
||||||
|
|
||||||
|
Das Muster kennt nur eine kurze Liste von Zaehlwoertern. Ein generisches
|
||||||
|
Wort wuerde es zu weit oeffnen – lieber eine nicht erkannte Stueckzahl als
|
||||||
|
eine falsch geratene.
|
||||||
|
"""
|
||||||
|
assert parse_gebinde("12 Eier à 53 g").stueck is None
|
||||||
|
|||||||
60
backend/tests/test_receipt_match.py
Normal file
60
backend/tests/test_receipt_match.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Kassenzettel-Abgleich: OCR-Zeilen den ähnlichsten Lebensmitteln zuordnen."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import BaseUnit, Product, Role, User
|
||||||
|
from app.routers.products import match_receipt
|
||||||
|
from app.schemas import MatchRequest
|
||||||
|
|
||||||
|
|
||||||
|
@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_abgekuerzte_kassenzeile_trifft_lebensmittel(db, user):
|
||||||
|
p = Product(
|
||||||
|
name="Vegane Mühlen-Schnitzel auf Basis von Soja",
|
||||||
|
base_unit=BaseUnit.gram, package_size=180,
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
|
||||||
|
res = match_receipt(
|
||||||
|
MatchRequest(lines=["MÜHLEN SCHNITZ", "voelliger unsinn xyz"]),
|
||||||
|
db=db, _=user,
|
||||||
|
)
|
||||||
|
# Abgekürzte Kassenzeile findet den vollen Namen mit hohem Score.
|
||||||
|
assert res[0].text == "MÜHLEN SCHNITZ"
|
||||||
|
assert res[0].candidates
|
||||||
|
assert res[0].candidates[0].product_id == p.id
|
||||||
|
assert res[0].candidates[0].score >= 70
|
||||||
|
# Unsinnszeile bleibt ohne Treffer über dem Schwellwert.
|
||||||
|
assert res[1].candidates == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nur_lebensmittel_keine_gegenstaende(db, user):
|
||||||
|
# Einzelstück-Flag -> tracking "object" -> darf nicht vorgeschlagen werden.
|
||||||
|
obj = Product(name="Powerbank Anker", base_unit=BaseUnit.piece, individual=True)
|
||||||
|
db.add(obj)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
res = match_receipt(MatchRequest(lines=["POWERBANK ANKER"], threshold=10), db=db, _=user)
|
||||||
|
assert res[0].candidates == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_schwellwert_filtert(db, user):
|
||||||
|
p = Product(name="Basmati Reis", base_unit=BaseUnit.gram, package_size=1)
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Nur teilweise passende Zeile: bei sehr hohem Schwellwert kein Treffer.
|
||||||
|
hart = match_receipt(MatchRequest(lines=["reis lose"], threshold=99), db=db, _=user)
|
||||||
|
weich = match_receipt(MatchRequest(lines=["reis lose"], threshold=20), db=db, _=user)
|
||||||
|
assert hart[0].candidates == []
|
||||||
|
assert weich[0].candidates and weich[0].candidates[0].product_id == p.id
|
||||||
110
backend/tests/test_shopping_need.py
Normal file
110
backend/tests/test_shopping_need.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Einkaufsliste: Fehlmenge als Gebinde-Leitangabe (auf ganze Packungen
|
||||||
|
aufgerundet) mit der Basiseinheit als kleinem Hinweis – „3 Gläser (450 g)".
|
||||||
|
|
||||||
|
Produkte tragen ihre eigene Packungsgröße; Gruppen legen ein eigenes
|
||||||
|
Gruppen-Gebinde als Richtwert fest (weil ihre Produkte unterschiedlich große
|
||||||
|
Packungen haben können).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import BaseUnit, Group, PackageType, Product, Role, Unit, User
|
||||||
|
from app.routers.groups import _group_to_out, update_group
|
||||||
|
from app.routers.views import group_shopping_list, shopping_list
|
||||||
|
from app.schemas import GroupUpdate
|
||||||
|
from app.services.min_stock import schreibe_ueberall
|
||||||
|
|
||||||
|
|
||||||
|
@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 _gramm(db) -> Unit:
|
||||||
|
return db.query(Unit).filter(Unit.name == "Gramm").one()
|
||||||
|
|
||||||
|
|
||||||
|
def _ueberall(db, besitzer, menge_base: float) -> None:
|
||||||
|
"""Mindestbestand „Überall" setzen – Mengen immer in Basiseinheiten."""
|
||||||
|
schreibe_ueberall(db, besitzer, menge_base)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_produkt_bedarf_in_ganzen_gebinden(db, user):
|
||||||
|
"""500 g fehlen bei 195-g-Gläsern -> 3 Gläser (aufgerundet), 500 g als Hinweis."""
|
||||||
|
db.add(PackageType(singular="Glas", plural="Gläser"))
|
||||||
|
pesto = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195,
|
||||||
|
package_label="Glas")
|
||||||
|
db.add(pesto)
|
||||||
|
db.commit() # kein Bestand -> es fehlt der volle Mindestbestand
|
||||||
|
_ueberall(db, pesto, 500)
|
||||||
|
|
||||||
|
(item,) = shopping_list(db=db, _=user)
|
||||||
|
assert item.need is not None
|
||||||
|
assert item.need.is_package is True
|
||||||
|
assert item.need.count == 3 # ceil(500 / 195)
|
||||||
|
assert item.need.text == "3 Gläser"
|
||||||
|
assert item.need.hint == "500 g"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruppe_mit_gruppen_gebinde(db, user):
|
||||||
|
"""Gruppe mit eigenem Richt-Gebinde (1 Glas ≈ 150 g), Mindestbestand 3 Gläser."""
|
||||||
|
db.add(PackageType(singular="Glas", plural="Gläser"))
|
||||||
|
gruppe = Group(name="Pesto alla Genovese", min_stock_unit=_gramm(db),
|
||||||
|
package_size=150, package_label="Glas", min_stock_in_packages=True)
|
||||||
|
db.add(gruppe)
|
||||||
|
db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, package_size=99, group=gruppe))
|
||||||
|
db.commit() # kein Bestand -> es fehlen 3 Gläser (450 g)
|
||||||
|
_ueberall(db, gruppe, 3 * 150) # 3 Gläser à 150 g
|
||||||
|
|
||||||
|
(item,) = group_shopping_list(db=db, _=user)
|
||||||
|
assert item.need is not None
|
||||||
|
assert item.need.is_package is True
|
||||||
|
assert item.need.count == 3
|
||||||
|
assert item.need.text == "3 Gläser"
|
||||||
|
assert item.need.hint == "450 g" # 3 * 150 g
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruppe_ohne_gebinde_bleibt_basiseinheit(db, user):
|
||||||
|
"""Ohne aktives Gruppen-Gebinde bleibt es bei der verwalteten Einheit."""
|
||||||
|
gruppe = Group(name="Mehl", min_stock_unit=_gramm(db))
|
||||||
|
db.add(gruppe)
|
||||||
|
db.add(Product(name="Mehl 405", base_unit=BaseUnit.gram, group=gruppe))
|
||||||
|
db.commit()
|
||||||
|
_ueberall(db, gruppe, 500)
|
||||||
|
|
||||||
|
(item,) = group_shopping_list(db=db, _=user)
|
||||||
|
assert item.need is not None
|
||||||
|
assert item.need.is_package is False
|
||||||
|
assert item.need.text == "500 Gramm"
|
||||||
|
assert item.need.hint == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruppe_umschalten_haelt_physischen_bedarf(db, user):
|
||||||
|
"""Umschalten g -> Gebinde laesst den physischen Bedarf unveraendert.
|
||||||
|
|
||||||
|
Gespeichert wird in Basiseinheiten, deshalb bleiben die 500 g stehen und es
|
||||||
|
ist NUR die Anzeige, die auf ~3,33 Glaeser wechselt. Frueher musste der
|
||||||
|
gespeicherte Wert dafuer umgerechnet werden.
|
||||||
|
"""
|
||||||
|
gruppe = Group(name="Pesto", min_stock_unit=_gramm(db))
|
||||||
|
db.add(gruppe)
|
||||||
|
db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, group=gruppe))
|
||||||
|
db.commit()
|
||||||
|
_ueberall(db, gruppe, 500)
|
||||||
|
|
||||||
|
update_group(
|
||||||
|
group_id=gruppe.id,
|
||||||
|
payload=GroupUpdate(min_stock_in_packages=True, package_size=150, package_label="Glas"),
|
||||||
|
db=db, _=user,
|
||||||
|
)
|
||||||
|
db.refresh(gruppe)
|
||||||
|
assert gruppe.min_stock_in_packages is True
|
||||||
|
# Gespeichert unveraendert in Basiseinheiten …
|
||||||
|
assert gruppe.location_min_stocks[0].min_stock == pytest.approx(500)
|
||||||
|
# … angezeigt jetzt in Glaesern.
|
||||||
|
assert _group_to_out(db, gruppe).min_stock == pytest.approx(500 / 150, abs=0.01)
|
||||||
244
backend/tests/test_zweiteinheit.py
Normal file
244
backend/tests/test_zweiteinheit.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""Zweiteinheit am Artikel: die Brücke zwischen den Einheiten-Arten.
|
||||||
|
|
||||||
|
Der Artikel bleibt in seiner Basiseinheit geführt (Stück) und trägt zusätzlich
|
||||||
|
eine Äquivalenz zur anderen Art („3 Stück ≙ 250 g"). Erst dadurch zählt er in
|
||||||
|
einer Gramm-Gruppe mit und lässt sich in Gramm ein- und auslagern. Das Gebinde
|
||||||
|
bleibt davon unberührt.
|
||||||
|
|
||||||
|
Ohne Brücke bleibt alles beim Alten – das halten
|
||||||
|
``test_conversion.py::test_wrong_kind_rejected`` und
|
||||||
|
``test_gruppen_hierarchie.py::test_einheitenfilter_gilt_auch_fuer_untergruppen``
|
||||||
|
fest, die beide unverändert grün bleiben.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import BaseUnit, Group, Location, Lot, Product, Role, Unit, User
|
||||||
|
from app.routers.groups import _group_to_out
|
||||||
|
from app.services.conversion import (
|
||||||
|
ConversionError,
|
||||||
|
group_min_context,
|
||||||
|
in_artikel_basis,
|
||||||
|
to_base,
|
||||||
|
zweit_faktor,
|
||||||
|
)
|
||||||
|
from app.services.stock import (
|
||||||
|
check_out,
|
||||||
|
current_stock,
|
||||||
|
summe_bestand_gewichtet,
|
||||||
|
summe_bestand_im_subtree_gewichtet,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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 _gramm(db) -> Unit:
|
||||||
|
return db.query(Unit).filter(Unit.name == "Gramm").one()
|
||||||
|
|
||||||
|
|
||||||
|
def _kilo(db) -> Unit:
|
||||||
|
return db.query(Unit).filter(Unit.name == "Kilogramm").one()
|
||||||
|
|
||||||
|
|
||||||
|
def _riegel(db, *, bruecke: bool = True, bestand: float = 0.0, ort=None) -> Product:
|
||||||
|
"""Ein in STÜCK geführter Artikel, wahlweise mit „3 Stück ≙ 250 g"."""
|
||||||
|
p = Product(
|
||||||
|
name="Bratwurst",
|
||||||
|
base_unit=BaseUnit.piece,
|
||||||
|
secondary_base="gram" if bruecke else None,
|
||||||
|
secondary_count=3 if bruecke else None,
|
||||||
|
secondary_amount=250 if bruecke else None,
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
if bestand:
|
||||||
|
db.add(Lot(product_id=p.id, quantity=bestand,
|
||||||
|
location_id=ort.id if ort is not None else None))
|
||||||
|
db.commit()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Die Brücke selbst ----
|
||||||
|
|
||||||
|
def test_bruecke_beide_richtungen(db):
|
||||||
|
p = _riegel(db)
|
||||||
|
assert zweit_faktor(p, BaseUnit.gram) == pytest.approx(250 / 3)
|
||||||
|
assert zweit_faktor(p, BaseUnit.piece) == 1.0
|
||||||
|
# In eine DRITTE Art gibt es keine Antwort.
|
||||||
|
assert zweit_faktor(p, BaseUnit.milliliter) is None
|
||||||
|
# Gegenrichtung: 250 g sind exakt 3 Stück.
|
||||||
|
assert in_artikel_basis(p, 250, BaseUnit.gram) == pytest.approx(3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_bruecke_kein_faktor(db):
|
||||||
|
p = _riegel(db, bruecke=False)
|
||||||
|
assert zweit_faktor(p, BaseUnit.gram) is None
|
||||||
|
assert in_artikel_basis(p, 250, BaseUnit.gram) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unvollstaendige_bruecke_zaehlt_nicht(db):
|
||||||
|
"""Halbe oder widersprüchliche Angaben sind keine Brücke – und kein Absturz."""
|
||||||
|
p = _riegel(db)
|
||||||
|
p.secondary_count = 0
|
||||||
|
db.commit()
|
||||||
|
assert zweit_faktor(p, BaseUnit.gram) is None # keine Division durch Null
|
||||||
|
|
||||||
|
p.secondary_count = 3
|
||||||
|
p.secondary_amount = None
|
||||||
|
db.commit()
|
||||||
|
assert zweit_faktor(p, BaseUnit.gram) is None
|
||||||
|
|
||||||
|
# Brücke auf die EIGENE Art bringt nichts zu übersetzen.
|
||||||
|
p.secondary_base = "piece"
|
||||||
|
p.secondary_amount = 5
|
||||||
|
db.commit()
|
||||||
|
assert zweit_faktor(p, BaseUnit.gram) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Ein- und Auslagern ----
|
||||||
|
|
||||||
|
def test_einlagern_in_der_fremdeinheit(db):
|
||||||
|
p = _riegel(db)
|
||||||
|
assert to_base(db, p, 250, "Gramm") == pytest.approx(3)
|
||||||
|
assert to_base(db, p, 0.25, "kg") == pytest.approx(3)
|
||||||
|
# Die eigene Art bleibt unberührt.
|
||||||
|
assert to_base(db, p, 2, "Stück") == pytest.approx(2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_bruecke_bleibt_gesperrt(db):
|
||||||
|
p = _riegel(db, bruecke=False)
|
||||||
|
with pytest.raises(ConversionError):
|
||||||
|
to_base(db, p, 250, "Gramm")
|
||||||
|
|
||||||
|
|
||||||
|
def test_krumme_menge_wird_gebucht_nicht_gerundet(db):
|
||||||
|
"""100 g bei 83,33 g je Stück sind 1,2 Stück – genau das wird gebucht."""
|
||||||
|
p = _riegel(db)
|
||||||
|
assert to_base(db, p, 100, "Gramm") == pytest.approx(1.2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auslagern_trifft_den_bestand_genau(db, user):
|
||||||
|
"""Der ganze Bestand muss sich in der Fremdeinheit auslagern lassen.
|
||||||
|
|
||||||
|
Sichert das gespeicherte PAAR gegen einen gerundeten Faktor ab: der Umweg
|
||||||
|
ueber 250/3 ergaebe 3,0000000000000004 und liefe gegen die Bestandspruefung.
|
||||||
|
"""
|
||||||
|
p = _riegel(db, bestand=3)
|
||||||
|
check_out(db, p, 250, "Gramm", user) # darf nicht an „zu wenig Bestand" scheitern
|
||||||
|
db.commit()
|
||||||
|
assert current_stock(db, p.id) == pytest.approx(0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Gruppen ----
|
||||||
|
|
||||||
|
def _wurst_gruppe(db) -> Group:
|
||||||
|
g = Group(name="Wurst", min_stock_unit=_kilo(db))
|
||||||
|
db.add(g)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(g)
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruppe_in_kilogramm_zaehlt_stueck_artikel_mit(db):
|
||||||
|
"""6 Stück à 83,33 g sind 500 g – zusammen mit 500 g Salami genau 1 kg."""
|
||||||
|
gruppe = _wurst_gruppe(db)
|
||||||
|
riegel = _riegel(db, bestand=6)
|
||||||
|
riegel.group_id = gruppe.id
|
||||||
|
salami = Product(name="Salami", base_unit=BaseUnit.gram, group_id=gruppe.id)
|
||||||
|
db.add(salami)
|
||||||
|
db.flush()
|
||||||
|
db.add(Lot(product_id=salami.id, quantity=500))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert _group_to_out(db, gruppe).stock == pytest.approx(1.0) # in Kilogramm
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruppe_filtert_artikel_ohne_bruecke_weiter(db):
|
||||||
|
"""Ohne Zweiteinheit bleibt der Stück-Artikel aussen vor – wie bisher."""
|
||||||
|
gruppe = _wurst_gruppe(db)
|
||||||
|
riegel = _riegel(db, bruecke=False, bestand=6)
|
||||||
|
riegel.group_id = gruppe.id
|
||||||
|
salami = Product(name="Salami", base_unit=BaseUnit.gram, group_id=gruppe.id)
|
||||||
|
db.add(salami)
|
||||||
|
db.flush()
|
||||||
|
db.add(Lot(product_id=salami.id, quantity=500))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert _group_to_out(db, gruppe).stock == pytest.approx(0.5)
|
||||||
|
# ``product_count`` zaehlt weiterhin ALLE Artikel im Untergraphen; wer
|
||||||
|
# mitrechnet, steht in ``ctx.matching``.
|
||||||
|
assert [p.name for p in group_min_context(gruppe).matching] == ["Salami"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_faktoren_stehen_je_artikel_im_kontext(db):
|
||||||
|
gruppe = _wurst_gruppe(db)
|
||||||
|
riegel = _riegel(db, bestand=6)
|
||||||
|
riegel.group_id = gruppe.id
|
||||||
|
salami = Product(name="Salami", base_unit=BaseUnit.gram, group_id=gruppe.id)
|
||||||
|
db.add(salami)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
ctx = group_min_context(gruppe)
|
||||||
|
assert ctx.faktoren[riegel.id] == pytest.approx(250 / 3)
|
||||||
|
assert ctx.faktoren[salami.id] == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Gewichtete Summen ----
|
||||||
|
|
||||||
|
def test_gewichtete_summe_nutzt_je_artikel_eigenen_faktor(db):
|
||||||
|
"""Zwei Artikel, zwei verschiedene Faktoren – kein gemeinsamer."""
|
||||||
|
a = _riegel(db, bestand=6) # 6 Stück
|
||||||
|
b = Product(name="Salami", base_unit=BaseUnit.gram)
|
||||||
|
db.add(b)
|
||||||
|
db.flush()
|
||||||
|
db.add(Lot(product_id=b.id, quantity=500))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
faktoren = {a.id: 250 / 3, b.id: 1.0}
|
||||||
|
assert summe_bestand_gewichtet(db, [a, b], faktoren) == pytest.approx(1000)
|
||||||
|
# Ohne Faktoren wird roh addiert (6 + 500).
|
||||||
|
assert summe_bestand_gewichtet(db, [a, b], None) == pytest.approx(506)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gewichtete_summe_im_subtree(db):
|
||||||
|
haus = Location(name="Haus")
|
||||||
|
db.add(haus)
|
||||||
|
db.flush()
|
||||||
|
kueche = Location(name="Küche", parent_id=haus.id)
|
||||||
|
db.add(kueche)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
a = _riegel(db, bestand=6, ort=kueche)
|
||||||
|
# Zweite Charge ohne Ort: zaehlt im Gesamtbestand, aber in keinem Subtree.
|
||||||
|
db.add(Lot(product_id=a.id, quantity=3, location_id=None))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
faktoren = {a.id: 250 / 3}
|
||||||
|
assert summe_bestand_im_subtree_gewichtet(db, [a], haus.id, faktoren) == pytest.approx(500)
|
||||||
|
assert summe_bestand_gewichtet(db, [a], faktoren) == pytest.approx(750)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Mindestbestand in der Zweiteinheit ----
|
||||||
|
|
||||||
|
def test_mindestbestand_in_der_zweiteinheit_anzeigen(db):
|
||||||
|
"""„Mindestens 250 g" an einem stückweise geführten Artikel."""
|
||||||
|
from app.crud import product_to_out
|
||||||
|
from app.services.min_stock import schreibe_ueberall
|
||||||
|
|
||||||
|
p = _riegel(db)
|
||||||
|
p.min_stock_unit_id = _gramm(db).id
|
||||||
|
schreibe_ueberall(db, p, 3) # 3 Stück, in Basiseinheiten gespeichert
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
out = product_to_out(db, p)
|
||||||
|
assert out.min_stock == pytest.approx(3) # Speicherung unveraendert
|
||||||
|
assert out.min_stock_display == pytest.approx(250) # Anzeige in Gramm
|
||||||
|
assert out.min_stock_unit_label == "Gramm"
|
||||||
|
assert out.secondary_factor == pytest.approx(250 / 3)
|
||||||
@@ -8,6 +8,8 @@ Name `box`) in Weiß auf der Akzentfläche.
|
|||||||
- **`app-icon.svg`** – 1024×1024, vollflächig, feste Farbe (`#4d5cd4`), ohne
|
- **`app-icon.svg`** – 1024×1024, vollflächig, feste Farbe (`#4d5cd4`), ohne
|
||||||
abgerundete Ecken. Für App-Icon (iOS/macOS via Icon Composer oder Xcode
|
abgerundete Ecken. Für App-Icon (iOS/macOS via Icon Composer oder Xcode
|
||||||
„AppIcon", App Store). Die Rundung macht das System selbst.
|
„AppIcon", App Store). Die Rundung macht das System selbst.
|
||||||
|
- **`icons/`** – Quellgrafiken für Ein-/Auslagern und Korrektur. Derzeit nicht
|
||||||
|
im Einsatz; siehe `icons/README.md`.
|
||||||
- **Favicon / Web-Logo:** `../web/public/favicon.svg` – dasselbe Zeichen mit
|
- **Favicon / Web-Logo:** `../web/public/favicon.svg` – dasselbe Zeichen mit
|
||||||
abgerundeter Platte und Hell-/Dunkel-Umschaltung (für den Browser-Tab).
|
abgerundeter Platte und Hell-/Dunkel-Umschaltung (für den Browser-Tab).
|
||||||
|
|
||||||
|
|||||||
32
brand/icons/README.md
Normal file
32
brand/icons/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Icon-Quellen
|
||||||
|
|
||||||
|
> **Derzeit nicht im Einsatz.** Die Verlaufsansicht nutzt die Strich-Icons
|
||||||
|
> `checkin` / `checkout` / `edit` aus `web/src/components/Icon.jsx` – dieselben
|
||||||
|
> wie die Ein-/Auslagern-Knöpfe.
|
||||||
|
>
|
||||||
|
> Grund: Die beiden Kisten unterscheiden sich **nur in der Pfeilrichtung**. Bei
|
||||||
|
> 18–20 px war das nicht zu trennen; erkannt hat man sie allein an der Farbe,
|
||||||
|
> und die trug mit Grün/Orange eine Wertung, die es nicht geben soll –
|
||||||
|
> Auslagern ist kein Missstand. Die Dateien bleiben liegen, falls sie später
|
||||||
|
> woanders passen; größer dargestellt trennen sie sehr wohl.
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
| Datei | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `einlagern.svg` | Kiste, Pfeil **hinein** – Bewegungsart „Eingelagert" |
|
||||||
|
| `auslagern.svg` | Kiste, Pfeil **heraus** – Bewegungsart „Ausgelagert" |
|
||||||
|
| `korrektur.svg` | Stapel mit Stift – Bewegungsart „Korrektur" |
|
||||||
|
|
||||||
|
## Wieder einbauen
|
||||||
|
|
||||||
|
Es sind gefüllte Grafiken mit eigenem `viewBox`; die übrigen in `Icon.jsx` sind
|
||||||
|
Strichzeichnungen (`fill:none`, `stroke:currentColor`) und vertragen sich nicht
|
||||||
|
damit. Es braucht also wieder einen eigenen Zweig, der mit
|
||||||
|
`fill="currentColor"` rendert – inline, damit die Icons die Textfarbe annehmen
|
||||||
|
(die Originale tragen festes Schwarz).
|
||||||
|
|
||||||
|
Die Pfaddaten übernimmt man mit einem kurzen Skript, statt sie abzutippen:
|
||||||
|
`viewBox` aus dem `<svg>`-Tag lesen und alle `d="…"` der enthaltenen `<path>`
|
||||||
|
ausgeben. Ein Tippfehler in den Kurvendaten fällt sonst erst am gerenderten
|
||||||
|
Bild auf.
|
||||||
1
brand/icons/auslagern.svg
Normal file
1
brand/icons/auslagern.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m11.987 24c-.03 0-.061-.002-.09-.006l-8.243-1c-.377-.045-.66-.365-.66-.744v-6c0-.414.336-.75.75-.75s.75.336.75.75v5.336l6.743.817v-11.543c-.034-.236.044-.482.234-.655.221-.2.543-.258.813-.136.271.12.452.384.452.681v12.5c0 .215-.092.419-.253.562-.136.122-.313.188-.496.188z"/><path d="m12.007 24c-.183 0-.359-.066-.497-.188-.161-.143-.253-.347-.253-.562v-12.25c0-.414.336-.75.75-.75s.75.336.75.75v11.403l6.742-.817v-5.336c0-.414.336-.75.75-.75s.75.336.75.75v6c0 .379-.283.699-.659.744l-8.242 1c-.031.004-.061.006-.091.006z"/><path d="m22.25 17h-6.753c-.257 0-.495-.131-.633-.348l-3.494-5.5c-.155-.245-.156-.558-.002-.803.155-.244.44-.379.725-.344l8.246 1c.263.031.488.198.596.439l2.001 4.5c.103.232.082.5-.057.713s-.375.343-.629.343zm-6.341-1.5h5.187l-1.359-3.057-6.251-.758z"/><path d="m8.503 17h-6.753c-.254 0-.49-.129-.629-.342s-.159-.48-.057-.713l2.001-4.5c.107-.241.333-.408.596-.439l8.246-1c.288-.036.569.1.725.344.154.245.153.558-.002.803l-3.494 5.5c-.138.216-.376.347-.633.347zm-5.599-1.5h5.187l2.424-3.814-6.251.758z"/><path d="m12 8c-.414 0-.75-.336-.75-.75v-6.5c0-.414.336-.75.75-.75s.75.336.75.75v6.5c0 .414-.336.75-.75.75z"/><path d="m15.249 4c-.159 0-.32-.051-.456-.155l-2.793-2.149-2.793 2.149c-.328.252-.799.19-1.052-.138-.252-.328-.19-.799.138-1.052l3.25-2.5c.27-.207.645-.207.914 0l3.25 2.5c.328.253.39.724.138 1.052-.149.192-.37.293-.596.293z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
brand/icons/einlagern.svg
Normal file
1
brand/icons/einlagern.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m11.987 24c-.03 0-.061-.002-.09-.006l-8.243-1c-.377-.045-.66-.365-.66-.744v-6c0-.414.336-.75.75-.75s.75.336.75.75v5.336l6.743.817v-11.543c-.034-.236.044-.482.234-.655.221-.2.543-.258.813-.136.271.12.452.384.452.681v12.5c0 .215-.092.419-.253.562-.136.122-.313.188-.496.188z"/><path d="m12.007 24c-.183 0-.359-.066-.497-.188-.161-.143-.253-.347-.253-.562v-12.25c0-.414.336-.75.75-.75s.75.336.75.75v11.403l6.742-.817v-5.336c0-.414.336-.75.75-.75s.75.336.75.75v6c0 .379-.283.699-.659.744l-8.242 1c-.031.004-.061.006-.091.006z"/><path d="m22.25 17h-6.753c-.257 0-.495-.131-.633-.348l-3.494-5.5c-.155-.245-.156-.558-.002-.803.155-.244.439-.379.725-.344l8.246 1c.263.031.488.198.596.439l2.001 4.5c.103.232.082.5-.057.713s-.375.343-.629.343zm-6.341-1.5h5.187l-1.359-3.057-6.251-.758z"/><path d="m8.503 17h-6.753c-.254 0-.49-.129-.629-.342s-.159-.48-.057-.713l2.001-4.5c.107-.241.333-.408.596-.439l8.246-1c.286-.036.569.1.725.344.154.245.153.558-.002.803l-3.494 5.5c-.138.216-.376.347-.633.347zm-5.599-1.5h5.187l2.424-3.814-6.251.758z"/><path d="m12 8c-.414 0-.75-.336-.75-.75v-6.5c0-.414.336-.75.75-.75s.75.336.75.75v6.5c0 .414-.336.75-.75.75z"/><path d="m12 8c-.161 0-.322-.052-.457-.155l-3.25-2.5c-.328-.253-.39-.724-.138-1.052.253-.328.724-.391 1.052-.138l2.793 2.149 2.793-2.148c.328-.253.799-.19 1.052.138.252.328.19.799-.138 1.052l-3.25 2.5c-.135.102-.296.154-.457.154z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
brand/icons/korrektur.svg
Normal file
1
brand/icons/korrektur.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" enable-background="new 0 0 64 64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g fill="rgb(0,0,0)"><path d="m64 39.97-8.58-8.58-18.48 18.47-3.86 12.36 12.44-3.76zm-25.83 17.21 1.73-5.53 15.52-15.52 3.84 3.84-15.51 15.53z"/><path d="m26.74 34.89c10.1 0 20.29-2.02 24.6-5.92h2.14v-18.79c0-6.67-13.45-10.16-26.73-10.16-13.3.01-26.75 3.5-26.75 10.16v43.62c0 6.67 13.45 10.17 26.74 10.17v-3.35c-14.28 0-23.39-4.04-23.39-6.82v-9.35c4.82 3.28 14.14 4.97 23.39 4.97 1.9 0 3.72-.07 5.42-.2l-.26-3.34c-1.61.12-3.35.19-5.16.19-14.28 0-23.39-4.04-23.39-6.82v-9.35c4.82 3.29 14.14 4.99 23.39 4.99zm0-31.51c14.28 0 23.38 4.03 23.38 6.81s-9.11 6.81-23.38 6.81-23.39-4.04-23.39-6.82c0-2.77 9.11-6.8 23.39-6.8zm-23.39 12c.02.01.04.02.06.04.28.19.57.37.88.55.03.02.06.03.08.05.29.17.6.33.91.48.05.03.1.05.16.08 5.14 2.5 13.25 3.78 21.3 3.78s16.16-1.28 21.3-3.78c.05-.03.11-.05.16-.08.32-.16.62-.32.91-.48.03-.01.05-.03.08-.04.31-.18.6-.36.88-.55.02-.01.04-.02.06-.04v9.35c0 2.78-9.11 6.81-23.38 6.81s-23.4-4.05-23.4-6.82z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg id="Layer_1" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m4 6h2v12h-2zm4 12h2v-12h-2zm12-12h-2v12h2zm-9 12h3v-12h-3zm-9 1v-3h-2v3c0 1.654 1.346 3 3 3h3v-2h-3c-.551 0-1-.449-1-1zm20 0c0 .551-.449 1-1 1h-3v2h3c1.654 0 3-1.346 3-3v-3h-2zm-1-17h-3v2h3c.551 0 1 .449 1 1v3h2v-3c0-1.654-1.346-3-3-3zm-21 3v3h2v-3c0-.551.449-1 1-1h3v-2h-3c-1.654 0-3 1.346-3 3z"/><path d="m16 6h-1v12h1z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 458 B |
1
ios/AppIcon.icon/Assets/hexagon.svg
Normal file
1
ios/AppIcon.icon/Assets/hexagon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m12 24c-.5 0-1-.12-1.46-.37l-7.97-4.27c-.98-.52-1.58-1.54-1.58-2.64v-9.46c0-1.11.61-2.12 1.59-2.65l7.97-4.25c.91-.49 1.99-.49 2.9 0l7.96 4.25c.98.52 1.59 1.54 1.59 2.65v9.46c0 1.11-.61 2.12-1.58 2.64l-7.96 4.27c-.46.24-.96.37-1.46.37zm0-22c-.17 0-.35.04-.51.13l-7.96 4.25c-.33.17-.53.51-.53.88v9.46c0 .37.2.71.53.88l7.97 4.27c.32.17.7.17 1.02 0l7.96-4.27c.33-.17.53-.51.53-.88v-9.46c0-.37-.2-.71-.53-.88l-7.97-4.25c-.16-.08-.33-.13-.51-.13z"/></svg>
|
||||||
|
After Width: | Height: | Size: 576 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -2,7 +2,7 @@
|
|||||||
"fill-specializations" : [
|
"fill-specializations" : [
|
||||||
{
|
{
|
||||||
"value" : {
|
"value" : {
|
||||||
"automatic-gradient" : "display-p3:0.68468,0.62596,1.03953,1.00000",
|
"automatic-gradient" : "display-p3:0.49658,0.53906,0.88428,1.00000",
|
||||||
"orientation" : {
|
"orientation" : {
|
||||||
"start" : {
|
"start" : {
|
||||||
"x" : 0.5,
|
"x" : 0.5,
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
{
|
{
|
||||||
"appearance" : "dark",
|
"appearance" : "dark",
|
||||||
"value" : {
|
"value" : {
|
||||||
"automatic-gradient" : "display-p3:0.23587,0.21584,0.35184,1.00000",
|
"automatic-gradient" : "display-p3:0.24018,0.26161,0.43578,1.00000",
|
||||||
"orientation" : {
|
"orientation" : {
|
||||||
"start" : {
|
"start" : {
|
||||||
"x" : 0.5,
|
"x" : 0.5,
|
||||||
@@ -39,48 +39,20 @@
|
|||||||
"fill-specializations" : [
|
"fill-specializations" : [
|
||||||
{
|
{
|
||||||
"value" : {
|
"value" : {
|
||||||
"solid" : "display-p3:0.00000,0.00000,0.00000,1.00000"
|
"solid" : "display-p3:0.99316,0.99316,0.99316,1.00000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appearance" : "dark",
|
"appearance" : "dark",
|
||||||
"value" : {
|
"value" : {
|
||||||
"solid" : "display-p3:0.58245,0.53181,0.88393,1.00000"
|
"solid" : "display-p3:0.99316,0.99316,0.99316,1.00000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"glass" : true,
|
"image-name" : "hexagon.svg",
|
||||||
"image-name" : "stock-rotation.svg",
|
"name" : "hexagon",
|
||||||
"name" : "stock-rotation",
|
|
||||||
"position" : {
|
"position" : {
|
||||||
"scale" : 1.7,
|
"scale" : 1.18,
|
||||||
"translation-in-points" : [
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fill-specializations" : [
|
|
||||||
{
|
|
||||||
"appearance" : "dark",
|
|
||||||
"value" : {
|
|
||||||
"automatic-gradient" : "display-p3:0.42536,0.31875,0.88861,1.00000"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appearance" : "tinted",
|
|
||||||
"value" : {
|
|
||||||
"automatic-gradient" : "extended-gray:0.25000,1.00000"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"glass" : false,
|
|
||||||
"hidden" : true,
|
|
||||||
"image-name" : "barcode-gelesen.svg",
|
|
||||||
"name" : "barcode-gelesen",
|
|
||||||
"position" : {
|
|
||||||
"scale" : 1.4,
|
|
||||||
"translation-in-points" : [
|
"translation-in-points" : [
|
||||||
0,
|
0,
|
||||||
0
|
0
|
||||||
|
|||||||
@@ -301,6 +301,13 @@ actor APIClient {
|
|||||||
return try await send(request, as: StockResponse.self)
|
return try await send(request, as: StockResponse.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kassenzettel-Zeilen (OCR) den aehnlichsten Lebensmitteln zuordnen.
|
||||||
|
func matchReceipt(_ lines: [String], threshold: Int? = nil) async throws -> [MatchLine] {
|
||||||
|
var request = try makeRequest("/products/match", method: "POST")
|
||||||
|
try jsonBody(&request, MatchRequest(lines: lines, threshold: threshold))
|
||||||
|
return try await send(request, as: [MatchLine].self)
|
||||||
|
}
|
||||||
|
|
||||||
func checkOut(_ payload: CheckOutRequest) async throws -> StockResponse {
|
func checkOut(_ payload: CheckOutRequest) async throws -> StockResponse {
|
||||||
var request = try makeRequest("/stock/checkout", method: "POST")
|
var request = try makeRequest("/stock/checkout", method: "POST")
|
||||||
try jsonBody(&request, payload)
|
try jsonBody(&request, payload)
|
||||||
@@ -416,8 +423,9 @@ actor APIClient {
|
|||||||
try await send(try makeRequest("/dashboard/expiry-split"), as: ExpirySplit.self)
|
try await send(try makeRequest("/dashboard/expiry-split"), as: ExpirySplit.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func byCategory() async throws -> [CategoryShare] {
|
func byCategory(depth: Int? = nil) async throws -> [CategoryShare] {
|
||||||
try await send(try makeRequest("/dashboard/by-category"), as: [CategoryShare].self)
|
let suffix = depth.map { "?depth=\($0)" } ?? ""
|
||||||
|
return try await send(try makeRequest("/dashboard/by-category" + suffix), as: [CategoryShare].self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func timeline(days: Int, productId: Int? = nil) async throws -> [TimelinePoint] {
|
func timeline(days: Int, productId: Int? = nil) async throws -> [TimelinePoint] {
|
||||||
@@ -519,6 +527,20 @@ actor APIClient {
|
|||||||
try await sendNoContent(try makeRequest("/categories/\(id)", method: "DELETE"))
|
try await sendNoContent(try makeRequest("/categories/\(id)", method: "DELETE"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gruppe aendern (Name, Mindestbestand, Einheit, Obergruppen).
|
||||||
|
func updateGroup(id: Int, _ payload: GroupUpdateRequest) async throws -> GroupItem {
|
||||||
|
var request = try makeRequest("/groups/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: GroupItem.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mindestbestaende einer Gruppe je Lagerort ersetzen (Basiseinheiten).
|
||||||
|
func setGroupLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> GroupItem {
|
||||||
|
var request = try makeRequest("/groups/\(id)/location-min-stock", method: "PUT")
|
||||||
|
try jsonBody(&request, list)
|
||||||
|
return try await send(request, as: GroupItem.self)
|
||||||
|
}
|
||||||
|
|
||||||
func renameGroup(id: Int, name: String) async throws -> GroupItem {
|
func renameGroup(id: Int, name: String) async throws -> GroupItem {
|
||||||
var request = try makeRequest("/groups/\(id)", method: "PATCH")
|
var request = try makeRequest("/groups/\(id)", method: "PATCH")
|
||||||
try jsonBody(&request, RenameRequest(name: name))
|
try jsonBody(&request, RenameRequest(name: name))
|
||||||
|
|||||||
@@ -16,11 +16,21 @@ struct AppCard: Codable, Identifiable, Equatable {
|
|||||||
let id: UUID
|
let id: UUID
|
||||||
var type: String
|
var type: String
|
||||||
var productId: Int?
|
var productId: Int?
|
||||||
|
// Diagramm-Einstellungen (nur fuer bestimmte Diagramme relevant). Optional,
|
||||||
|
// damit aeltere gespeicherte Uebersichten ohne diese Schluessel weiter
|
||||||
|
// dekodieren. Bedeutung wie im Web-Editor:
|
||||||
|
var mitOhneMhd: Bool? // Ablauf-Ring: „Ohne MHD" einbeziehen (nil = ja)
|
||||||
|
var detail: Bool? // Kategorien-Ring: Detailansicht, alle einzeln (nil = nein)
|
||||||
|
var tiefe: Int? // Kategorien-Ring: Tiefe (nil = feinste Ebene)
|
||||||
|
|
||||||
init(id: UUID = UUID(), type: String, productId: Int? = nil) {
|
init(id: UUID = UUID(), type: String, productId: Int? = nil,
|
||||||
|
mitOhneMhd: Bool? = nil, detail: Bool? = nil, tiefe: Int? = nil) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.type = type
|
self.type = type
|
||||||
self.productId = productId
|
self.productId = productId
|
||||||
|
self.mitOhneMhd = mitOhneMhd
|
||||||
|
self.detail = detail
|
||||||
|
self.tiefe = tiefe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ struct AssignScanView: View {
|
|||||||
.font(.caption).padding(.top, 2)
|
.font(.caption).padding(.top, 2)
|
||||||
}
|
}
|
||||||
.padding().frame(maxWidth: .infinity)
|
.padding().frame(maxWidth: .infinity)
|
||||||
.background(Color.accentColor.opacity(0.15))
|
.background(Color.marke.opacity(0.15))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12)).padding(.horizontal)
|
.clipShape(RoundedRectangle(cornerRadius: 12)).padding(.horizontal)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
|
|||||||
@@ -108,3 +108,9 @@ enum BestBeforeText {
|
|||||||
return Calendar.current.date(from: komponenten)
|
return Calendar.current.date(from: komponenten)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// „1 Untergruppe" / „2 Untergruppen" – Ein-/Mehrzahl an einer Stelle.
|
||||||
|
func anzahlWort(_ n: Int, _ einzahl: String, _ mehrzahl: String) -> String {
|
||||||
|
"\(n) \(n == 1 ? einzahl : mehrzahl)"
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,11 +76,15 @@ private struct ProportionChart: View {
|
|||||||
struct ChartCardView: View {
|
struct ChartCardView: View {
|
||||||
let type: String
|
let type: String
|
||||||
let stand: Int
|
let stand: Int
|
||||||
|
// Aus den Web-Karten-Einstellungen (props): werden hier angewandt.
|
||||||
|
var detail: Bool = false
|
||||||
|
var tiefe: Int? = nil
|
||||||
|
var mitOhneMhd: Bool = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch type {
|
switch type {
|
||||||
case "expiry-donut": ExpiryDonutCard(stand: stand)
|
case "expiry-donut": ExpiryDonutCard(stand: stand, mitOhneMhd: mitOhneMhd)
|
||||||
case "category-donut": CategoryDonutCard(stand: stand)
|
case "category-donut": CategoryDonutCard(stand: stand, detail: detail, tiefe: tiefe)
|
||||||
case "category-bars": CategoryBarsCard(stand: stand)
|
case "category-bars": CategoryBarsCard(stand: stand)
|
||||||
case "stock-timeline": StockTimelineCard(stand: stand)
|
case "stock-timeline": StockTimelineCard(stand: stand)
|
||||||
case "activity-timeline": ActivityTimelineCard(stand: stand)
|
case "activity-timeline": ActivityTimelineCard(stand: stand)
|
||||||
@@ -122,14 +126,19 @@ private struct ChartLoader<Data, Inhalt: View>: View {
|
|||||||
|
|
||||||
struct ExpiryDonutCard: View {
|
struct ExpiryDonutCard: View {
|
||||||
let stand: Int
|
let stand: Int
|
||||||
|
// Web-Karte „Ohne MHD einbeziehen": aus, blendet den grauen Anteil aus.
|
||||||
|
var mitOhneMhd: Bool = true
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ChartLoader(laden: { try await APIClient.shared.expirySplit() }, stand: stand) { s in
|
ChartLoader(laden: { try await APIClient.shared.expirySplit() }, stand: stand) { s in
|
||||||
ProportionChart(segments: [
|
var segmente = [
|
||||||
Segment(label: "In Ordnung", value: s.ok, color: ExpiryColor.ok),
|
Segment(label: "In Ordnung", value: s.ok, color: ExpiryColor.ok),
|
||||||
Segment(label: "Bald ablaufend", value: s.soon, color: ExpiryColor.soon),
|
Segment(label: "Bald ablaufend", value: s.soon, color: ExpiryColor.soon),
|
||||||
Segment(label: "Abgelaufen", value: s.expired, color: ExpiryColor.expired),
|
Segment(label: "Abgelaufen", value: s.expired, color: ExpiryColor.expired),
|
||||||
Segment(label: "Ohne MHD", value: s.noDate, color: ExpiryColor.noDate),
|
]
|
||||||
], einheit: "AE")
|
if mitOhneMhd {
|
||||||
|
segmente.append(Segment(label: "Ohne MHD", value: s.noDate, color: ExpiryColor.noDate))
|
||||||
|
}
|
||||||
|
return ProportionChart(segments: segmente, einheit: "Artikeleinheiten")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,28 +147,48 @@ struct ExpiryDonutCard: View {
|
|||||||
|
|
||||||
struct CategoryDonutCard: View {
|
struct CategoryDonutCard: View {
|
||||||
let stand: Int
|
let stand: Int
|
||||||
|
// Web-Karte „Detailansicht": alle Kategorien einzeln statt „Übrige".
|
||||||
|
var detail: Bool = false
|
||||||
|
// Web-Karte „Kategorie-Tiefe": auf welche Ebene hochgerollt wird (nil = feinste).
|
||||||
|
var tiefe: Int? = nil
|
||||||
|
|
||||||
// Wiederholbare, ruhige Farbfolge fuer die Kategorien.
|
// Wiederholbare, ruhige Farbfolge fuer die Kategorien.
|
||||||
private static let palette: [Color] = [
|
private static let palette: [Color] = [
|
||||||
.blue, .green, .orange, .purple, .pink, .teal, .indigo, .brown,
|
.blue, .green, .orange, .purple, .pink, .teal, .indigo, .brown,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Ab dem Ende der Palette werden Farben ueber den goldenen Winkel erzeugt,
|
||||||
|
// damit auch viele Kategorien (Detailansicht) unterscheidbar bleiben.
|
||||||
|
private static func farbe(_ i: Int) -> Color {
|
||||||
|
if i < palette.count { return palette[i] }
|
||||||
|
let hue = (Double(i) * 0.61803398875).truncatingRemainder(dividingBy: 1)
|
||||||
|
return Color(hue: hue, saturation: 0.55, brightness: 0.78)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ChartLoader(laden: { try await APIClient.shared.byCategory() }, stand: stand) { liste in
|
ChartLoader(laden: { try await APIClient.shared.byCategory(depth: tiefe) }, stand: stand) { liste in
|
||||||
let sortiert = liste.filter { $0.articleUnits > 0 }
|
let sortiert = liste.filter { $0.articleUnits > 0 }
|
||||||
.sorted { $0.articleUnits > $1.articleUnits }
|
.sorted { $0.articleUnits > $1.articleUnits }
|
||||||
// Die groessten sechs einzeln, der Rest gebuendelt - sonst wird der
|
let segmente: [Segment]
|
||||||
// Ring unlesbar.
|
if detail {
|
||||||
let kopf = sortiert.prefix(6)
|
// Detailansicht: alle Kategorien einzeln, nichts wird gebuendelt.
|
||||||
let restWert = sortiert.dropFirst(6).reduce(0) { $0 + $1.articleUnits }
|
segmente = sortiert.enumerated().map { i, c in
|
||||||
var segmente = kopf.enumerated().map { i, c in
|
Segment(label: c.name, value: c.articleUnits, color: Self.farbe(i))
|
||||||
Segment(label: c.name, value: c.articleUnits,
|
}
|
||||||
color: Self.palette[i % Self.palette.count])
|
} else {
|
||||||
|
// Die groessten sechs einzeln, der Rest gebuendelt - sonst wird der
|
||||||
|
// Ring unlesbar.
|
||||||
|
let kopf = sortiert.prefix(6)
|
||||||
|
let restWert = sortiert.dropFirst(6).reduce(0) { $0 + $1.articleUnits }
|
||||||
|
var s = kopf.enumerated().map { i, c in
|
||||||
|
Segment(label: c.name, value: c.articleUnits, color: Self.farbe(i))
|
||||||
|
}
|
||||||
|
if restWert > 0 {
|
||||||
|
s.append(Segment(label: "Übrige", value: restWert, color: .gray))
|
||||||
|
}
|
||||||
|
segmente = s
|
||||||
}
|
}
|
||||||
if restWert > 0 {
|
return ProportionChart(segments: segmente, einheit: "Artikeleinheiten")
|
||||||
segmente.append(Segment(label: "Übrige", value: restWert, color: .gray))
|
|
||||||
}
|
|
||||||
return ProportionChart(segments: segmente, einheit: "AE")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,9 +267,9 @@ private struct LinienDiagramm: View {
|
|||||||
Chart(punkte) { p in
|
Chart(punkte) { p in
|
||||||
if let datum = DisplaySettings.parseTimestamp(p.at) {
|
if let datum = DisplaySettings.parseTimestamp(p.at) {
|
||||||
LineMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
|
LineMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
|
||||||
.foregroundStyle(Color.accentColor)
|
.foregroundStyle(Color.marke)
|
||||||
AreaMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
|
AreaMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
|
||||||
.foregroundStyle(Color.accentColor.opacity(0.12))
|
.foregroundStyle(Color.marke.opacity(0.12))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 170)
|
.frame(height: 170)
|
||||||
|
|||||||
@@ -171,8 +171,12 @@ struct CheckInFormView: View {
|
|||||||
|
|
||||||
/// Einheiten der passenden Art plus das Gebinde des Artikels.
|
/// Einheiten der passenden Art plus das Gebinde des Artikels.
|
||||||
private var unitOptions: [UnitOption] {
|
private var unitOptions: [UnitOption] {
|
||||||
|
// Bei hinterlegter Zweiteinheit auch die Einheiten der Gegenart – das
|
||||||
|
// Backend rechnet sie ueber die Bruecke am Artikel um.
|
||||||
|
var arten: Set<String> = [product.kind]
|
||||||
|
if product.zweitFaktor != nil, let k = product.zweitKind { arten.insert(k) }
|
||||||
var options = units
|
var options = units
|
||||||
.filter { $0.kind == product.kind }
|
.filter { arten.contains($0.kind) }
|
||||||
.map { UnitOption(value: $0.name, label: $0.name) }
|
.map { UnitOption(value: $0.name, label: $0.name) }
|
||||||
if let size = product.packageSize, size > 0 {
|
if let size = product.packageSize, size > 0 {
|
||||||
// Angezeigt wird "Glas"/"Dose", geschickt wird "package".
|
// Angezeigt wird "Glas"/"Dose", geschickt wird "package".
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Vision
|
||||||
|
import PhotosUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct CheckInView: View {
|
struct CheckInView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -337,3 +340,441 @@ struct CheckInView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Kassenzettel scannen (nur Lebensmittel)
|
||||||
|
|
||||||
|
private extension UIImage {
|
||||||
|
/// Orientierung auf `.up` normalisieren, damit `cgImage`-Pixel und `size`-Punkte
|
||||||
|
/// zusammenpassen (Kamerafotos sind sonst gedreht).
|
||||||
|
func nachObenGedreht() -> UIImage {
|
||||||
|
guard imageOrientation != .up else { return self }
|
||||||
|
let format = UIGraphicsImageRendererFormat.default()
|
||||||
|
format.scale = scale
|
||||||
|
return UIGraphicsImageRenderer(size: size, format: format).image { _ in
|
||||||
|
draw(in: CGRect(origin: .zero, size: size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auf ein Rechteck in Punkt-Koordinaten (size) zuschneiden.
|
||||||
|
func zugeschnitten(auf rect: CGRect) -> UIImage? {
|
||||||
|
let px = CGRect(x: rect.minX * scale, y: rect.minY * scale,
|
||||||
|
width: rect.width * scale, height: rect.height * scale)
|
||||||
|
guard let cg = cgImage?.cropping(to: px.integral) else { return nil }
|
||||||
|
return UIImage(cgImage: cg, scale: scale, orientation: .up)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OCR (on-device) auf einem Ausschnitt – deutsche Texterkennung, zeilenweise von
|
||||||
|
/// oben nach unten. Das Bild verlässt das Gerät nicht; nur die Textzeilen gehen zum
|
||||||
|
/// Server für den Abgleich.
|
||||||
|
private func ocrZeilen(_ cg: CGImage) async -> [String] {
|
||||||
|
await withCheckedContinuation { fortsetzung in
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
let anfrage = VNRecognizeTextRequest()
|
||||||
|
anfrage.recognitionLevel = .accurate
|
||||||
|
anfrage.usesLanguageCorrection = true
|
||||||
|
anfrage.recognitionLanguages = ["de-DE"]
|
||||||
|
try? VNImageRequestHandler(cgImage: cg, options: [:]).perform([anfrage])
|
||||||
|
let treffer = (anfrage.results as? [VNRecognizedTextObservation]) ?? []
|
||||||
|
let vonOben = treffer.sorted { $0.boundingBox.maxY > $1.boundingBox.maxY }
|
||||||
|
fortsetzung.resume(returning: vonOben.compactMap { $0.topCandidates(1).first?.string })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offensichtliche Nicht-Artikel (Preise, Summen, Kopf/Fuß) grob aussortieren.
|
||||||
|
private func plausibleArtikelZeilen(_ zeilen: [String]) -> [String] {
|
||||||
|
let muell = ["summe", "gesamt", "zwischensumme", "mwst", "ust", "eur", "bar",
|
||||||
|
"rueckgeld", "rückgeld", "kartenzahlung", "betrag", "total",
|
||||||
|
"kassenbon", "beleg", "datum", "uhr", "filiale", "kunde", "steuer"]
|
||||||
|
return zeilen.compactMap { roh in
|
||||||
|
let s = roh.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard s.filter({ $0.isLetter }).count >= 3 else { return nil }
|
||||||
|
let klein = s.lowercased()
|
||||||
|
return muell.contains(where: { klein.contains($0) }) ? nil : s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Menge aus einer Zeile lesen („2x", „2 Stk", führende Zahl), sonst 1.
|
||||||
|
private func mengeAusZeile(_ zeile: String) -> Double {
|
||||||
|
for muster in ["(\\d+)\\s*[xX]", "(\\d+)\\s*[sS][tT]", "^\\s*(\\d+)\\b"] {
|
||||||
|
if let r = zeile.range(of: muster, options: .regularExpression) {
|
||||||
|
let ziffern = zeile[r].filter { $0.isNumber }
|
||||||
|
if let n = Int(ziffern), n > 0, n < 100 { return Double(n) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einstieg: Foto → Bereich markieren → Zeilen prüfen → einlagern.
|
||||||
|
struct KassenzettelView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var bild: UIImage?
|
||||||
|
@State private var zeilen: [MatchLine]?
|
||||||
|
@State private var analysiere = false
|
||||||
|
@State private var kameraAn = false
|
||||||
|
@State private var galerie: PhotosPickerItem?
|
||||||
|
@State private var locations: [StorageLocation] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
inhalt
|
||||||
|
.navigationTitle("Kassenzettel")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Schließen") { dismiss() } } }
|
||||||
|
.task { locations = (try? await APIClient.shared.locations()) ?? [] }
|
||||||
|
.fullScreenCover(isPresented: $kameraAn) {
|
||||||
|
CameraPicker(isPresented: $kameraAn) { img in bild = img.nachObenGedreht(); zeilen = nil }
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
.onChange(of: galerie) { item in
|
||||||
|
guard let item else { return }
|
||||||
|
Task {
|
||||||
|
if let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
let ui = UIImage(data: data) { bild = ui.nachObenGedreht(); zeilen = nil }
|
||||||
|
galerie = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var inhalt: some View {
|
||||||
|
if analysiere {
|
||||||
|
VStack(spacing: 12) { ProgressView(); Text("Lese Artikel…").foregroundStyle(.secondary) }
|
||||||
|
} else if let zeilen {
|
||||||
|
KassenzettelPruefenView(match: zeilen, locations: locations) { dismiss() }
|
||||||
|
} else if let bild {
|
||||||
|
KassenzettelMarkierenView(bild: bild, onWeiter: starteErkennung) { self.bild = nil }
|
||||||
|
} else {
|
||||||
|
start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var start: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "doc.text.viewfinder").font(.system(size: 52)).foregroundStyle(.secondary)
|
||||||
|
Text("Kassenzettel fotografieren, den Artikelbereich markieren – die App liest die Zeilen und schlägt Lebensmittel vor.")
|
||||||
|
.multilineTextAlignment(.center).foregroundStyle(.secondary).padding(.horizontal)
|
||||||
|
Button { kameraAn = true } label: {
|
||||||
|
Label("Foto aufnehmen", systemImage: "camera").frame(maxWidth: .infinity)
|
||||||
|
}.buttonStyle(.borderedProminent)
|
||||||
|
PhotosPicker(selection: $galerie, matching: .images) {
|
||||||
|
Label("Aus Galerie wählen", systemImage: "photo").frame(maxWidth: .infinity)
|
||||||
|
}.buttonStyle(.bordered)
|
||||||
|
Spacer()
|
||||||
|
}.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func starteErkennung(_ ausschnitt: UIImage) {
|
||||||
|
analysiere = true
|
||||||
|
Task {
|
||||||
|
let erg = await erkenne(ausschnitt)
|
||||||
|
await MainActor.run { zeilen = erg; analysiere = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func erkenne(_ img: UIImage) async -> [MatchLine] {
|
||||||
|
guard let cg = img.cgImage else { return [] }
|
||||||
|
let roh = await ocrZeilen(cg)
|
||||||
|
let zeilen = plausibleArtikelZeilen(roh)
|
||||||
|
guard !zeilen.isEmpty else { return [] }
|
||||||
|
return (try? await APIClient.shared.matchReceipt(zeilen))
|
||||||
|
?? zeilen.map { MatchLine(text: $0, candidates: []) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rechteck über den Artikelblock ziehen; liefert den zugeschnittenen Bildausschnitt.
|
||||||
|
struct KassenzettelMarkierenView: View {
|
||||||
|
let bild: UIImage
|
||||||
|
var onWeiter: (UIImage) -> Void
|
||||||
|
var onNeu: () -> Void
|
||||||
|
|
||||||
|
@State private var rect: CGRect = .zero
|
||||||
|
@State private var startRect: CGRect?
|
||||||
|
@State private var fitRect: CGRect = .zero
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Ziehe das Rechteck über die Artikel. Am Griff unten rechts vergrößern.")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center).padding(.horizontal)
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
Image(uiImage: bild).resizable().scaledToFit()
|
||||||
|
.frame(width: geo.size.width, height: geo.size.height)
|
||||||
|
Rectangle().stroke(Color.marke, lineWidth: 2)
|
||||||
|
.background(Color.marke.opacity(0.15))
|
||||||
|
.frame(width: rect.width, height: rect.height)
|
||||||
|
.offset(x: rect.minX, y: rect.minY)
|
||||||
|
.gesture(zieh(resize: false))
|
||||||
|
Rectangle().fill(Color.marke)
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.offset(x: rect.maxX - 13, y: rect.maxY - 13)
|
||||||
|
.gesture(zieh(resize: true))
|
||||||
|
}
|
||||||
|
.onAppear { setzeFit(geo.size) }
|
||||||
|
.onChange(of: geo.size) { setzeFit($0) }
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button("Neues Foto", action: onNeu)
|
||||||
|
Spacer()
|
||||||
|
Button("Weiter") { weiter() }.buttonStyle(.borderedProminent)
|
||||||
|
}.padding(.horizontal)
|
||||||
|
}.padding(.vertical)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setzeFit(_ container: CGSize) {
|
||||||
|
let iw = bild.size.width, ih = bild.size.height
|
||||||
|
guard iw > 0, ih > 0, container.width > 0 else { return }
|
||||||
|
let s = min(container.width / iw, container.height / ih)
|
||||||
|
let w = iw * s, h = ih * s
|
||||||
|
fitRect = CGRect(x: (container.width - w) / 2, y: (container.height - h) / 2, width: w, height: h)
|
||||||
|
if rect == .zero { rect = fitRect.insetBy(dx: fitRect.width * 0.08, dy: fitRect.height * 0.12) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func zieh(resize: Bool) -> some Gesture {
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { v in
|
||||||
|
if startRect == nil { startRect = rect }
|
||||||
|
guard let s = startRect else { return }
|
||||||
|
var r = s
|
||||||
|
if resize {
|
||||||
|
r.size.width = max(48, s.width + v.translation.width)
|
||||||
|
r.size.height = max(48, s.height + v.translation.height)
|
||||||
|
} else {
|
||||||
|
r.origin.x = s.origin.x + v.translation.width
|
||||||
|
r.origin.y = s.origin.y + v.translation.height
|
||||||
|
}
|
||||||
|
rect = klemme(r)
|
||||||
|
}
|
||||||
|
.onEnded { _ in startRect = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func klemme(_ r: CGRect) -> CGRect {
|
||||||
|
guard fitRect.width > 0 else { return r }
|
||||||
|
var o = r
|
||||||
|
o.size.width = min(o.size.width, fitRect.width)
|
||||||
|
o.size.height = min(o.size.height, fitRect.height)
|
||||||
|
o.origin.x = min(max(o.origin.x, fitRect.minX), fitRect.maxX - o.width)
|
||||||
|
o.origin.y = min(max(o.origin.y, fitRect.minY), fitRect.maxY - o.height)
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
private func weiter() {
|
||||||
|
guard fitRect.width > 0 else { onWeiter(bild); return }
|
||||||
|
let s = bild.size.width / fitRect.width // Bildschirm-Einheiten → Bildpunkte
|
||||||
|
let crop = CGRect(x: (rect.minX - fitRect.minX) * s, y: (rect.minY - fitRect.minY) * s,
|
||||||
|
width: rect.width * s, height: rect.height * s)
|
||||||
|
onWeiter(bild.zugeschnitten(auf: crop) ?? bild)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct KassenzettelArtikel: Hashable { let id: Int; let name: String; let unit: String }
|
||||||
|
|
||||||
|
private struct KassenzettelZeile: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let text: String
|
||||||
|
let kandidaten: [MatchCandidate]
|
||||||
|
var artikel: KassenzettelArtikel?
|
||||||
|
var menge: Double
|
||||||
|
var ortId: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ZeilenRef: Identifiable { let id: UUID }
|
||||||
|
|
||||||
|
/// Prüfen-Liste: je Zeile Artikel (Auto-Treffer + eigene Suche), Menge, Lagerort.
|
||||||
|
struct KassenzettelPruefenView: View {
|
||||||
|
let match: [MatchLine]
|
||||||
|
let locations: [StorageLocation]
|
||||||
|
var onFertig: () -> Void
|
||||||
|
|
||||||
|
@State private var zeilen: [KassenzettelZeile] = []
|
||||||
|
@State private var sucheZiel: ZeilenRef?
|
||||||
|
@State private var scanZiel: ZeilenRef?
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var meldung: String?
|
||||||
|
|
||||||
|
private var zugeordnet: Int { zeilen.filter { $0.artikel != nil }.count }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if zeilen.isEmpty {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "doc.text.magnifyingglass").font(.system(size: 40)).foregroundStyle(.secondary)
|
||||||
|
Text("Keine Artikel erkannt.").font(.headline)
|
||||||
|
Text("Versuch es mit einem engeren Rechteck oder besserem Licht.")
|
||||||
|
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
||||||
|
}.padding()
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach($zeilen) { $z in
|
||||||
|
Section {
|
||||||
|
zeile($z)
|
||||||
|
} header: {
|
||||||
|
Text(z.text)
|
||||||
|
.textCase(nil).font(.footnote).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button { Task { await einlagern() } } label: {
|
||||||
|
HStack {
|
||||||
|
if busy { ProgressView().padding(.trailing, 4) }
|
||||||
|
Text("\(zugeordnet) Artikel einlagern")
|
||||||
|
}
|
||||||
|
}.disabled(busy || zugeordnet == 0)
|
||||||
|
} footer: {
|
||||||
|
Text("Nicht zugeordnete Zeilen werden übersprungen. Neue Artikel werden nicht angelegt.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear(perform: aufbauen)
|
||||||
|
.sheet(item: $sucheZiel) { ref in
|
||||||
|
NavigationStack {
|
||||||
|
ArtikelSucheSheet { produkt in setzeArtikel(ref.id, produkt: produkt); sucheZiel = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $scanZiel) { ref in
|
||||||
|
LocationScannerView(locations: locations) { loc in setzeOrt(ref.id, loc.id); scanZiel = nil }
|
||||||
|
}
|
||||||
|
.alert("Fertig", isPresented: Binding(get: { meldung != nil },
|
||||||
|
set: { if !$0 { meldung = nil; onFertig() } })) {
|
||||||
|
Button("OK") { }
|
||||||
|
} message: { Text(meldung ?? "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drei Zeilen (Rows) je Artikel: Auswahl, Menge, Lagerort. In einer eigenen
|
||||||
|
/// Section gruppiert – so ist klar, was zusammengehört, und nichts ist gequetscht.
|
||||||
|
@ViewBuilder private func zeile(_ z: Binding<KassenzettelZeile>) -> some View {
|
||||||
|
let zeile = z.wrappedValue
|
||||||
|
// Artikel wählen
|
||||||
|
Menu {
|
||||||
|
ForEach(zeile.kandidaten) { k in
|
||||||
|
Button("\(k.name) · \(k.score) %") {
|
||||||
|
z.wrappedValue.artikel = KassenzettelArtikel(id: k.productId, name: k.name, unit: k.checkInUnit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button { sucheZiel = ZeilenRef(id: zeile.id) } label: { Label("Suchen…", systemImage: "magnifyingglass") }
|
||||||
|
if zeile.artikel != nil {
|
||||||
|
Button(role: .destructive) { z.wrappedValue.artikel = nil } label: {
|
||||||
|
Label("Nicht zuordnen", systemImage: "xmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: zeile.artikel == nil ? "questionmark.circle" : "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(zeile.artikel == nil ? Color.secondary : Color.green)
|
||||||
|
Text(zeile.artikel?.name ?? "– Artikel wählen –")
|
||||||
|
.foregroundStyle(zeile.artikel == nil ? Color.secondary : Color.primary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Menge
|
||||||
|
Stepper("Menge: \(Int(zeile.menge))", value: z.menge, in: 1...99)
|
||||||
|
// Lagerort + QR-Scan
|
||||||
|
HStack {
|
||||||
|
Menu {
|
||||||
|
Button("– ohne –") { z.wrappedValue.ortId = nil }
|
||||||
|
ForEach(locations) { loc in Button(loc.name) { z.wrappedValue.ortId = loc.id } }
|
||||||
|
} label: {
|
||||||
|
Label(ortName(zeile.ortId), systemImage: "mappin.and.ellipse")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button { scanZiel = ZeilenRef(id: zeile.id) } label: {
|
||||||
|
Label("QR", systemImage: "qrcode.viewfinder")
|
||||||
|
}.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ortName(_ id: String?) -> String {
|
||||||
|
guard let id else { return "ohne Ort" }
|
||||||
|
return locations.first { $0.id == id }?.name ?? "Ort"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func aufbauen() {
|
||||||
|
guard zeilen.isEmpty else { return }
|
||||||
|
zeilen = match.map { m in
|
||||||
|
KassenzettelZeile(
|
||||||
|
text: m.text, kandidaten: m.candidates,
|
||||||
|
artikel: m.candidates.first.map {
|
||||||
|
KassenzettelArtikel(id: $0.productId, name: $0.name, unit: $0.checkInUnit)
|
||||||
|
},
|
||||||
|
menge: mengeAusZeile(m.text), ortId: nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setzeArtikel(_ id: UUID, produkt: Product) {
|
||||||
|
guard let i = zeilen.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
let unit = (produkt.packageSize ?? 0) > 0 ? "package" : produkt.baseUnit
|
||||||
|
zeilen[i].artikel = KassenzettelArtikel(id: produkt.id, name: produkt.name, unit: unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setzeOrt(_ id: UUID, _ ortId: String) {
|
||||||
|
guard let i = zeilen.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
zeilen[i].ortId = ortId
|
||||||
|
}
|
||||||
|
|
||||||
|
private func einlagern() async {
|
||||||
|
busy = true
|
||||||
|
var ein = 0, weg = 0
|
||||||
|
var fehler: String?
|
||||||
|
for z in zeilen {
|
||||||
|
guard let a = z.artikel else { weg += 1; continue }
|
||||||
|
let payload = BatchCheckInRequest(
|
||||||
|
productId: a.id, unit: a.unit,
|
||||||
|
lines: [CheckInLine(quantity: z.menge, bestBefore: nil,
|
||||||
|
bestBeforePrecision: "day", locationId: z.ortId)],
|
||||||
|
)
|
||||||
|
do { _ = try await APIClient.shared.checkInBatch(payload); ein += 1 }
|
||||||
|
catch { fehler = error.localizedDescription }
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
meldung = "\(ein) eingelagert, \(weg) übersprungen."
|
||||||
|
+ (fehler.map { " Fehler: \($0)" } ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Freie Artikelsuche (nur Lebensmittel) als Rückfall, wenn kein Auto-Treffer passt.
|
||||||
|
struct ArtikelSucheSheet: View {
|
||||||
|
var onPick: (Product) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var text = ""
|
||||||
|
@State private var treffer: [Product] = []
|
||||||
|
@State private var suchlauf = UUID()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(treffer) { p in
|
||||||
|
Button {
|
||||||
|
onPick(p); dismiss()
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(p.name).foregroundStyle(.primary)
|
||||||
|
if let b = p.brand, !b.isEmpty {
|
||||||
|
Text(b).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.searchable(text: $text, prompt: "Lebensmittel suchen")
|
||||||
|
.navigationTitle("Artikel suchen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||||||
|
.onChange(of: text) { q in
|
||||||
|
let lauf = UUID(); suchlauf = lauf
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||||
|
if suchlauf != lauf { return }
|
||||||
|
let liste = (try? await APIClient.shared.searchProducts(q)) ?? []
|
||||||
|
let food = liste.filter { $0.tracking != "object" }
|
||||||
|
await MainActor.run { if suchlauf == lauf { treffer = Array(food.prefix(25)) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,8 +136,12 @@ struct CheckOutFormView: View {
|
|||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
private var unitOptions: [UnitOption] {
|
private var unitOptions: [UnitOption] {
|
||||||
|
// Bei hinterlegter Zweiteinheit auch die Einheiten der Gegenart – das
|
||||||
|
// Backend rechnet sie ueber die Bruecke am Artikel um.
|
||||||
|
var arten: Set<String> = [product.kind]
|
||||||
|
if product.zweitFaktor != nil, let k = product.zweitKind { arten.insert(k) }
|
||||||
var options = units
|
var options = units
|
||||||
.filter { $0.kind == product.kind }
|
.filter { arten.contains($0.kind) }
|
||||||
.map { UnitOption(value: $0.name, label: $0.name) }
|
.map { UnitOption(value: $0.name, label: $0.name) }
|
||||||
if let size = product.packageSize, size > 0 {
|
if let size = product.packageSize, size > 0 {
|
||||||
// Angezeigt wird "Glas"/"Dose", geschickt wird "package".
|
// Angezeigt wird "Glas"/"Dose", geschickt wird "package".
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ struct QuickBookingCard: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(einlagern ? Color.accentColor : .orange)
|
.tint(einlagern ? Color.marke : .orange)
|
||||||
.disabled(!bereit)
|
.disabled(!bereit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,10 +171,10 @@ struct StatusCard: View {
|
|||||||
switch art {
|
switch art {
|
||||||
case "kpi-products":
|
case "kpi-products":
|
||||||
Kennzahl(wert: String(stats.productsInStock), label: "Artikel mit Bestand",
|
Kennzahl(wert: String(stats.productsInStock), label: "Artikel mit Bestand",
|
||||||
detail: "von \(stats.productsTotal)", tint: .accentColor)
|
detail: "von \(stats.productsTotal)", tint: .marke)
|
||||||
case "kpi-units":
|
case "kpi-units":
|
||||||
Kennzahl(wert: formatAmount(stats.articleUnits), label: "Artikeleinheiten",
|
Kennzahl(wert: formatAmount(stats.articleUnits), label: "Artikeleinheiten",
|
||||||
detail: "", tint: .accentColor)
|
detail: "", tint: .marke)
|
||||||
default:
|
default:
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Kennzahl(wert: String(stats.expiringSoon), label: "Bald ablaufend",
|
Kennzahl(wert: String(stats.expiringSoon), label: "Bald ablaufend",
|
||||||
@@ -295,7 +295,7 @@ struct ExpiryCard: View {
|
|||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(item.daysLeft < 0 ? "abgelaufen" : "\(item.daysLeft) Tage")
|
Text(item.restlaufzeitText)
|
||||||
.font(.caption).bold()
|
.font(.caption).bold()
|
||||||
.foregroundStyle(item.daysLeft < 0 ? .red : .orange)
|
.foregroundStyle(item.daysLeft < 0 ? .red : .orange)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,10 @@ struct MirrorOverview: View {
|
|||||||
ForEach(karten) { karte in
|
ForEach(karten) { karte in
|
||||||
Section {
|
Section {
|
||||||
DashboardCardView(type: karte.type, productId: karte.props?.productId,
|
DashboardCardView(type: karte.type, productId: karte.props?.productId,
|
||||||
stand: stand) { stand += 1 }
|
stand: stand,
|
||||||
|
detail: karte.props?.detail ?? false,
|
||||||
|
tiefe: karte.props?.tiefe.flatMap { Int($0) },
|
||||||
|
mitOhneMhd: karte.props?.mitOhneMhd ?? true) { stand += 1 }
|
||||||
} header: {
|
} header: {
|
||||||
Text(CardCatalog.titel(karte.type))
|
Text(CardCatalog.titel(karte.type))
|
||||||
}
|
}
|
||||||
@@ -174,6 +177,11 @@ struct DashboardCardView: View {
|
|||||||
let type: String
|
let type: String
|
||||||
let productId: Int?
|
let productId: Int?
|
||||||
let stand: Int
|
let stand: Int
|
||||||
|
// Aus den Web-Karten-Einstellungen (props). Standardwerte = bisheriges Verhalten,
|
||||||
|
// damit die lokale App-Übersicht unverändert bleibt.
|
||||||
|
var detail: Bool = false
|
||||||
|
var tiefe: Int? = nil
|
||||||
|
var mitOhneMhd: Bool = true
|
||||||
let onChange: () -> Void
|
let onChange: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -198,7 +206,8 @@ struct DashboardCardView: View {
|
|||||||
ProductTimelineChart(productId: productId, stand: stand)
|
ProductTimelineChart(productId: productId, stand: stand)
|
||||||
case "expiry-donut", "category-donut", "category-bars",
|
case "expiry-donut", "category-donut", "category-bars",
|
||||||
"stock-timeline", "activity-timeline":
|
"stock-timeline", "activity-timeline":
|
||||||
ChartCardView(type: type, stand: stand)
|
ChartCardView(type: type, stand: stand,
|
||||||
|
detail: detail, tiefe: tiefe, mitOhneMhd: mitOhneMhd)
|
||||||
default:
|
default:
|
||||||
Text("Unbekannte Karte „\(type)“.")
|
Text("Unbekannte Karte „\(type)“.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
|||||||
373
ios/Sources/GroupViews.swift
Normal file
373
ios/Sources/GroupViews.swift
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Gruppen: Stammdaten, Obergruppen und Mindestbestände.
|
||||||
|
//
|
||||||
|
// Gruppen bilden einen Graphen, keinen Baum: eine Gruppe darf unter MEHREREN
|
||||||
|
// Obergruppen hängen („Grillwurst" unter „Wurst" UND unter „Grillgut").
|
||||||
|
// `TreeMasterView` (MasterDataViews.swift) kann nur einen Elternteil und würde
|
||||||
|
// dieselbe Gruppe doppelt anzeigen – deshalb hier eine eigene, flache Ansicht
|
||||||
|
// statt der Baumdarstellung der Lagerorte.
|
||||||
|
|
||||||
|
/// Gruppenliste mit „unter: …" als Untertitel.
|
||||||
|
struct GroupsView: View {
|
||||||
|
@State private var groups: [GroupItem] = []
|
||||||
|
@State private var busy = true
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var editing: GroupItem?
|
||||||
|
@State private var anlegen = false
|
||||||
|
@State private var loeschen: GroupItem?
|
||||||
|
|
||||||
|
private var sortiert: [GroupItem] {
|
||||||
|
groups.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if !busy && groups.isEmpty {
|
||||||
|
Text("Noch keine Gruppen.").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach(sortiert) { g in
|
||||||
|
Button { editing = g } label: { zeile(g) }
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.swipeActions {
|
||||||
|
Button(role: .destructive) { loeschen = g } label: {
|
||||||
|
Label("Löschen", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Gruppen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button { anlegen = true } label: { Image(systemName: "plus") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay { if busy && groups.isEmpty { ProgressView() } }
|
||||||
|
.refreshable { await load() }
|
||||||
|
.task { await load() }
|
||||||
|
.sheet(item: $editing) { g in
|
||||||
|
NavigationStack { GroupEditor(item: g, all: groups) { Task { await load() } } }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $anlegen) {
|
||||||
|
NavigationStack { GroupEditor(item: nil, all: groups) { Task { await load() } } }
|
||||||
|
}
|
||||||
|
.confirmationDialog("Gruppe löschen?", isPresented: Binding(
|
||||||
|
get: { loeschen != nil }, set: { if !$0 { loeschen = nil } }
|
||||||
|
), titleVisibility: .visible) {
|
||||||
|
Button("Löschen", role: .destructive) {
|
||||||
|
if let g = loeschen { Task { await entfernen(g) } }
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) { loeschen = nil }
|
||||||
|
} message: {
|
||||||
|
Text("Die Artikel bleiben erhalten und verlieren nur ihre Zuordnung. "
|
||||||
|
+ "Untergruppen bleiben ebenfalls bestehen – sie rücken nicht nach oben.")
|
||||||
|
}
|
||||||
|
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: { Text(error ?? "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func zeile(_ g: GroupItem) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(g.name).foregroundStyle(.primary)
|
||||||
|
Text(untertitel(g)).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func untertitel(_ g: GroupItem) -> String {
|
||||||
|
let map = Dictionary(groups.map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
|
||||||
|
let eltern = (g.parentIds ?? []).compactMap { map[$0] }
|
||||||
|
var teile = [eltern.isEmpty ? "oberste Ebene" : "unter: " + eltern.joined(separator: ", ")]
|
||||||
|
teile.append("\(g.productCount ?? 0) Artikel")
|
||||||
|
if let n = g.childIds?.count, n > 0 {
|
||||||
|
teile.append(anzahlWort(n, "Untergruppe", "Untergruppen"))
|
||||||
|
}
|
||||||
|
return teile.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do { groups = try await APIClient.shared.groups() }
|
||||||
|
catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func entfernen(_ g: GroupItem) async {
|
||||||
|
loeschen = nil
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.deleteGroup(id: g.id)
|
||||||
|
await load()
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gruppe anlegen/ändern: Name, Obergruppen, Einheit und Mindestbestände je Ort.
|
||||||
|
struct GroupEditor: View {
|
||||||
|
let item: GroupItem?
|
||||||
|
let all: [GroupItem]
|
||||||
|
var done: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private struct MinRow: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var locationId: String?
|
||||||
|
var amount: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var parentIds: Set<Int> = []
|
||||||
|
@State private var rows: [MinRow] = []
|
||||||
|
@State private var units: [Unit] = []
|
||||||
|
@State private var unitId: Int?
|
||||||
|
@State private var locations: [StorageLocation] = []
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
/// Basiseinheiten je Erfassungseinheit – gespeichert wird in Basiseinheiten.
|
||||||
|
private var faktor: Double {
|
||||||
|
let f = item?.minFaktor ?? 1
|
||||||
|
return f == 0 ? 1 : f
|
||||||
|
}
|
||||||
|
private var einheit: String { item?.minEinheit ?? "" }
|
||||||
|
|
||||||
|
/// Auswahlmenge für Obergruppen: alles außer der Gruppe selbst und ihren
|
||||||
|
/// Untergruppen – sonst entstünde ein Ring.
|
||||||
|
private var moeglicheEltern: [GroupItem] {
|
||||||
|
guard let item else { return all }
|
||||||
|
let verboten = Self.nachfahren(of: item.id, in: all).union([item.id])
|
||||||
|
return all.filter { !verboten.contains($0.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var elternText: String {
|
||||||
|
let map = Dictionary(all.map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
|
||||||
|
let liste = parentIds.compactMap { map[$0] }.sorted()
|
||||||
|
return liste.isEmpty ? "– keine –" : liste.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mengenTitel: String {
|
||||||
|
einheit.isEmpty ? "Mindestbestand" : "Mindestbestand (in \(einheit))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Name") {
|
||||||
|
TextField("z.B. Wurst", text: $name)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
GroupParentPicker(auswahl: $parentIds, moeglich: moeglicheEltern)
|
||||||
|
} label: {
|
||||||
|
LabeledContent("Obergruppen", value: elternText)
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Eine Gruppe darf unter mehreren Obergruppen hängen – „Grillwurst“ "
|
||||||
|
+ "etwa unter „Wurst“ und unter „Grillgut“. Bestand und Mindestbestand "
|
||||||
|
+ "der Obergruppe zählen sie dann mit.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if item != nil {
|
||||||
|
Section("Einheit") {
|
||||||
|
Picker("Einheit", selection: $unitId) {
|
||||||
|
Text("– Basiseinheit –").tag(Int?.none)
|
||||||
|
ForEach(units) { u in Text(u.name).tag(Int?.some(u.id)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach($rows) { $row in
|
||||||
|
mengenZeile($row)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
rows.append(MinRow(locationId: UEBERALL_ID, amount: ""))
|
||||||
|
} label: {
|
||||||
|
Label("Mindestbestand hinzufügen", systemImage: "plus")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(mengenTitel)
|
||||||
|
} footer: {
|
||||||
|
Text("„Überall“ heißt: egal wo, Hauptsache im Haus – Käufe für einen "
|
||||||
|
+ "Lagerort decken das mit ab.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(item == nil ? "Gruppe anlegen" : "Gruppe ändern")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
|
||||||
|
.disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func mengenZeile(_ row: Binding<MinRow>) -> some View {
|
||||||
|
HStack {
|
||||||
|
Picker("Ort", selection: row.locationId) {
|
||||||
|
Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID))
|
||||||
|
ForEach(locations) { l in
|
||||||
|
Text(l.path(in: locations)).tag(String?.some(l.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextField("Menge", text: row.amount)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 70)
|
||||||
|
Button(role: .destructive) {
|
||||||
|
let id = row.wrappedValue.id
|
||||||
|
rows.removeAll { $0.id == id }
|
||||||
|
} label: { Image(systemName: "trash") }
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
name = item?.name ?? ""
|
||||||
|
parentIds = Set(item?.parentIds ?? [])
|
||||||
|
unitId = item?.minStockUnitId
|
||||||
|
units = (try? await APIClient.shared.units()) ?? []
|
||||||
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
|
rows = (item?.locationMinStocks ?? []).map {
|
||||||
|
MinRow(locationId: $0.locationId ?? UEBERALL_ID,
|
||||||
|
amount: formatAmount($0.minStock / faktor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
let sauber = name.trimmingCharacters(in: .whitespaces)
|
||||||
|
do {
|
||||||
|
guard let item else {
|
||||||
|
_ = try await APIClient.shared.createGroup(NewGroupRequest(
|
||||||
|
name: sauber, minStock: nil, minStockUnitId: unitId,
|
||||||
|
parentIds: Array(parentIds)))
|
||||||
|
done()
|
||||||
|
dismiss()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = try await APIClient.shared.updateGroup(id: item.id, GroupUpdateRequest(
|
||||||
|
name: sauber, minStockUnitId: unitId, parentIds: Array(parentIds)))
|
||||||
|
var liste: [LocationMinStockIn] = []
|
||||||
|
var gesehen: Set<String> = []
|
||||||
|
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)
|
||||||
|
// Eingabe in der Erfassungseinheit → Basiseinheiten.
|
||||||
|
liste.append(LocationMinStockIn(
|
||||||
|
locationId: loc == UEBERALL_ID ? nil : loc,
|
||||||
|
minStock: wert * faktor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = try await APIClient.shared.setGroupLocationMinStock(id: item.id, liste)
|
||||||
|
done()
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Untergruppen (transitiv). Menge statt Baum-Walk: eine Gruppe ist
|
||||||
|
/// über mehrere Wege erreichbar und würde sonst mehrfach besucht.
|
||||||
|
private static func nachfahren(of id: Int, in all: [GroupItem]) -> Set<Int> {
|
||||||
|
var ergebnis: Set<Int> = []
|
||||||
|
var offen = [id]
|
||||||
|
while let cur = offen.popLast() {
|
||||||
|
for kind in all.first(where: { $0.id == cur })?.childIds ?? [] {
|
||||||
|
if ergebnis.insert(kind).inserted { offen.append(kind) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mehrfachauswahl der Obergruppen. Ein Picker kann das nicht – deshalb eine
|
||||||
|
/// Liste mit Häkchen, das SwiftUI-Idiom dafür.
|
||||||
|
struct GroupParentPicker: View {
|
||||||
|
@Binding var auswahl: Set<Int>
|
||||||
|
let moeglich: [GroupItem]
|
||||||
|
|
||||||
|
private var sortiert: [GroupItem] {
|
||||||
|
moeglich.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(sortiert) { g in
|
||||||
|
Button {
|
||||||
|
if auswahl.contains(g.id) { auswahl.remove(g.id) } else { auswahl.insert(g.id) }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(g.name).foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
if auswahl.contains(g.id) {
|
||||||
|
Image(systemName: "checkmark").foregroundStyle(Color.marke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Obergruppen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Eine Zeile fuer ein Gruppen-Auswahlfeld: Gruppe plus ihre Tiefe im Baum.
|
||||||
|
struct GruppenOption: Identifiable {
|
||||||
|
let id: String // Pfad – eine Gruppe kann unter mehreren haengen
|
||||||
|
let gruppe: GroupItem
|
||||||
|
let tiefe: Int
|
||||||
|
/// Eingerueckte Beschriftung fuer den Picker.
|
||||||
|
var label: String {
|
||||||
|
String(repeating: " ", count: tiefe) + (tiefe > 0 ? "↳ " : "") + gruppe.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gruppen als Baum ausrollen – fuer Auswahlfelder.
|
||||||
|
///
|
||||||
|
/// Eine Gruppe mit mehreren Obergruppen erscheint unter JEDER; das ist der Sinn
|
||||||
|
/// mehrerer Obergruppen, und ausgewaehlt wird ohnehin dieselbe ID.
|
||||||
|
func gruppenOptionen(_ groups: [GroupItem]) -> [GruppenOption] {
|
||||||
|
let vorhanden = Set(groups.map(\.id))
|
||||||
|
var kinderVon: [Int: [GroupItem]] = [:]
|
||||||
|
var wurzeln: [GroupItem] = []
|
||||||
|
for g in groups {
|
||||||
|
let eltern = (g.parentIds ?? []).filter { vorhanden.contains($0) }
|
||||||
|
if eltern.isEmpty { wurzeln.append(g) }
|
||||||
|
for e in eltern { kinderVon[e, default: []].append(g) }
|
||||||
|
}
|
||||||
|
let nachName: (GroupItem, GroupItem) -> Bool = {
|
||||||
|
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
var out: [GruppenOption] = []
|
||||||
|
func walk(_ g: GroupItem, _ tiefe: Int, _ pfad: String, _ gesehen: Set<Int>) {
|
||||||
|
let eigen = "\(pfad)/\(g.id)"
|
||||||
|
out.append(GruppenOption(id: eigen, gruppe: g, tiefe: tiefe))
|
||||||
|
if gesehen.contains(g.id) { return } // Ringschutz
|
||||||
|
var weiter = gesehen
|
||||||
|
weiter.insert(g.id)
|
||||||
|
for k in (kinderVon[g.id] ?? []).sorted(by: nachName) {
|
||||||
|
walk(k, tiefe + 1, eigen, weiter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for w in wurzeln.sorted(by: nachName) { walk(w, 0, "", []) }
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
|
|
||||||
|
<!-- App-Store-Kategorie. Muss hier stehen: Das Projekt nutzt ein eigenes
|
||||||
|
Info.plist (GENERATE_INFOPLIST_FILE = NO), deshalb wird die
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType-Build-Einstellung NICHT in das
|
||||||
|
gebaute Info.plist uebernommen. -->
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.utilities</string>
|
||||||
|
|
||||||
<!-- Export-Compliance: Die App nutzt nur die Standard-Verschluesselung des
|
<!-- Export-Compliance: Die App nutzt nur die Standard-Verschluesselung des
|
||||||
Systems (HTTPS ueber Apples Betriebssystem) und faellt damit unter die
|
Systems (HTTPS ueber Apples Betriebssystem) und faellt damit unter die
|
||||||
Ausnahme. Mit diesem Schluessel entfaellt die "App Encryption
|
Ausnahme. Mit diesem Schluessel entfaellt die "App Encryption
|
||||||
@@ -39,13 +46,12 @@
|
|||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
|
|
||||||
<!-- Am iPad verlangt das System alle Ausrichtungen, solange die App nicht
|
<!-- Am iPhone bewusst nur Hochkant - damit sich beim Drehen nichts
|
||||||
auf Vollbild besteht. Am iPhone bleibt Hochkant plus Querformat. -->
|
verstellt. Am iPad verlangt das System alle Ausrichtungen, solange die
|
||||||
|
App nicht auf Vollbild besteht. -->
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<array>
|
<array>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ struct DateRow: View {
|
|||||||
Text(label).foregroundStyle(.primary)
|
Text(label).foregroundStyle(.primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(Self.fmt.string(from: date))
|
Text(Self.fmt.string(from: date))
|
||||||
.foregroundStyle(open ? Color.accentColor : .secondary)
|
.foregroundStyle(open ? Color.marke : .secondary)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ struct ShoppingListView: View {
|
|||||||
ListRow(
|
ListRow(
|
||||||
systemImage: "square.stack.3d.up",
|
systemImage: "square.stack.3d.up",
|
||||||
title: group.name,
|
title: group.name,
|
||||||
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel"
|
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName)"
|
||||||
|
+ " · \(group.productCount) Artikel"
|
||||||
|
+ ((group.subgroupCount ?? 0) > 0
|
||||||
|
? " · inkl. " + anzahlWort(group.subgroupCount ?? 0, "Untergruppe", "Untergruppen")
|
||||||
|
: "")
|
||||||
) { GroupBadge() }
|
) { GroupBadge() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,7 +150,7 @@ struct ExpiringView: View {
|
|||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(restText(item.daysLeft))
|
Text(item.restlaufzeitText)
|
||||||
.font(.caption).bold()
|
.font(.caption).bold()
|
||||||
.foregroundStyle(item.daysLeft < 0 ? .red : .orange)
|
.foregroundStyle(item.daysLeft < 0 ? .red : .orange)
|
||||||
}
|
}
|
||||||
@@ -181,19 +185,19 @@ struct ExpiringView: View {
|
|||||||
return "\(formatAmount(item.quantity / faktor)) \(item.unitName)"
|
return "\(formatAmount(item.quantity / faktor)) \(item.unitName)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restText(_ days: Int) -> String {
|
|
||||||
if days < 0 { return "abgelaufen" }
|
|
||||||
if days == 0 { return "heute" }
|
|
||||||
return days == 1 ? "1 Tag" : "\(days) Tage"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mindestbestände
|
// MARK: - Mindestbestände
|
||||||
|
|
||||||
/// Übersicht aller Mindestbestände: Produkte und Gruppen, jeweils Gesamt UND je
|
/// Übersicht aller Mindestbestände – nach LAGERORT gegliedert.
|
||||||
/// Lagerort (Soll in der Anzeige-/Gruppeneinheit). Produktzeilen öffnen den
|
///
|
||||||
/// Artikel zum Bearbeiten; das Setzen von Gruppen-/Lagerort-Bedarfen läuft übers
|
/// Ein Mindestbestand ist immer ein Bedarf an einem Ort. „Überall" (egal wo,
|
||||||
/// Web.
|
/// Hauptsache im Haus) ist dabei der oberste Ort; Käufe für einen einzelnen
|
||||||
|
/// Lagerort decken ihn mit ab. Mengen kommen in Basiseinheiten und werden hier
|
||||||
|
/// in die Erfassungseinheit des Ziels umgerechnet.
|
||||||
|
///
|
||||||
|
/// Artikelzeilen öffnen den Artikel, Gruppenzeilen die Gruppe – beide lassen
|
||||||
|
/// sich dort bearbeiten.
|
||||||
struct MinStockView: View {
|
struct MinStockView: View {
|
||||||
@State private var products: [Product] = []
|
@State private var products: [Product] = []
|
||||||
@State private var groups: [GroupItem] = []
|
@State private var groups: [GroupItem] = []
|
||||||
@@ -203,71 +207,92 @@ struct MinStockView: View {
|
|||||||
private struct MinRow: Identifiable {
|
private struct MinRow: Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
let title: String
|
let title: String
|
||||||
let ort: String // "Gesamt" oder Lagerort-Name
|
|
||||||
let soll: String
|
let soll: String
|
||||||
let bestand: String?
|
let bestand: String?
|
||||||
let productId: Int? // Tippziel (nur Produkte)
|
let productId: Int? // Tippziel (Artikel)
|
||||||
let isGroup: Bool
|
let groupId: Int? // Tippziel (Gruppe)
|
||||||
let unter: Bool
|
let unter: Bool
|
||||||
|
let untergruppen: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
private var rows: [MinRow] {
|
private struct OrtBlock: Identifiable {
|
||||||
var out: [MinRow] = []
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let zeilen: [MinRow]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Zeilen, gebündelt je Ort. „Überall" (Ort nil) steht zuerst.
|
||||||
|
private var bloecke: [OrtBlock] {
|
||||||
|
var jeOrt: [String: [MinRow]] = [:]
|
||||||
|
var namen: [String: String] = [UEBERALL_ID: UEBERALL_NAME]
|
||||||
|
|
||||||
|
func merken(_ locationId: String?, _ locationName: String?, _ row: MinRow) {
|
||||||
|
let key = locationId ?? UEBERALL_ID
|
||||||
|
if let n = locationName, locationId != nil { namen[key] = n }
|
||||||
|
jeOrt[key, default: []].append(row)
|
||||||
|
}
|
||||||
|
|
||||||
for p in products.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
|
for p in products.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
|
||||||
let disp = p.unitFactor == 0 ? 1 : p.unitFactor // Basis -> Anzeigeeinheit
|
let disp = p.unitFactor == 0 ? 1 : p.unitFactor // Basis -> Anzeigeeinheit
|
||||||
let art = p.articleUnitFactor == 0 ? 1 : p.articleUnitFactor
|
|
||||||
if let ms = p.minStock, ms > 0 {
|
|
||||||
out.append(MinRow(
|
|
||||||
id: "p\(p.id)g", title: p.name, ort: "Gesamt",
|
|
||||||
soll: "\(formatAmount(ms / disp)) \(p.unitName)",
|
|
||||||
bestand: "\(formatAmount(p.stock / disp)) \(p.unitName)",
|
|
||||||
productId: p.id, isGroup: false, unter: p.stock < ms))
|
|
||||||
}
|
|
||||||
for l in (p.locationMinStocks ?? []) {
|
for l in (p.locationMinStocks ?? []) {
|
||||||
// je Lagerort in Artikeleinheiten gespeichert -> Anzeigeeinheit.
|
merken(l.locationId, l.locationName, MinRow(
|
||||||
out.append(MinRow(
|
id: "p\(p.id)l\(l.id)", title: p.name,
|
||||||
id: "p\(p.id)l\(l.locationId)", title: p.name,
|
soll: "\(formatAmount(l.minStock / disp)) \(p.unitName)",
|
||||||
ort: l.locationName ?? "Lagerort",
|
bestand: l.stock.map { "\(formatAmount($0 / disp)) \(p.unitName)" },
|
||||||
soll: "\(formatAmount(l.minStock * art / disp)) \(p.unitName)",
|
productId: p.id, groupId: nil,
|
||||||
bestand: nil, productId: p.id, isGroup: false, unter: false))
|
unter: (l.stock ?? 0) < l.minStock, untergruppen: 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for g in groups.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
|
for g in groups.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
|
||||||
let unit = g.minStockUnitName ?? ""
|
let faktor = g.minFaktor == 0 ? 1 : g.minFaktor
|
||||||
if let ms = g.minStock, ms > 0 {
|
let einheit = g.minEinheit
|
||||||
out.append(MinRow(
|
|
||||||
id: "g\(g.id)g", title: g.name, ort: "Gesamt",
|
|
||||||
soll: "\(formatAmount(ms)) \(unit)",
|
|
||||||
bestand: g.stock.map { "\(formatAmount($0)) \(unit)" },
|
|
||||||
productId: nil, isGroup: true, unter: (g.stock ?? 0) < ms))
|
|
||||||
}
|
|
||||||
for l in (g.locationMinStocks ?? []) {
|
for l in (g.locationMinStocks ?? []) {
|
||||||
out.append(MinRow(
|
merken(l.locationId, l.locationName, MinRow(
|
||||||
id: "g\(g.id)l\(l.locationId)", title: g.name,
|
id: "g\(g.id)l\(l.id)", title: g.name,
|
||||||
ort: l.locationName ?? "Lagerort",
|
soll: "\(formatAmount(l.minStock / faktor)) \(einheit)",
|
||||||
soll: "\(formatAmount(l.minStock)) \(unit)",
|
bestand: l.stock.map { "\(formatAmount($0 / faktor)) \(einheit)" },
|
||||||
bestand: nil, productId: nil, isGroup: true, unter: false))
|
productId: nil, groupId: g.id,
|
||||||
|
unter: (l.stock ?? 0) < l.minStock,
|
||||||
|
untergruppen: g.childIds?.count ?? 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
|
||||||
|
// „Überall" zuerst, danach die Lagerorte alphabetisch.
|
||||||
|
let schluessel = jeOrt.keys.sorted { a, b in
|
||||||
|
if a == UEBERALL_ID { return true }
|
||||||
|
if b == UEBERALL_ID { return false }
|
||||||
|
return (namen[a] ?? "").localizedCaseInsensitiveCompare(namen[b] ?? "") == .orderedAscending
|
||||||
|
}
|
||||||
|
return schluessel.map {
|
||||||
|
OrtBlock(id: $0, name: namen[$0] ?? "Lagerort",
|
||||||
|
zeilen: jeOrt[$0] ?? [])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
if !busy && rows.isEmpty {
|
if !busy && bloecke.isEmpty {
|
||||||
Text("Noch keine Mindestbestände gesetzt.").foregroundStyle(.secondary)
|
Text("Noch keine Mindestbestände gesetzt.").foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
ForEach(rows) { r in
|
ForEach(bloecke) { block in
|
||||||
if let pid = r.productId, let p = products.first(where: { $0.id == pid }) {
|
Section(block.id == UEBERALL_ID ? "Überall (egal wo)" : block.name) {
|
||||||
NavigationLink { ProductDetailView(product: p) } label: { zeile(r) }
|
ForEach(block.zeilen) { r in
|
||||||
} else {
|
if let pid = r.productId, let p = products.first(where: { $0.id == pid }) {
|
||||||
zeile(r)
|
NavigationLink { ProductDetailView(product: p) } label: { zeile(r) }
|
||||||
|
} else if let gid = r.groupId, let g = groups.first(where: { $0.id == gid }) {
|
||||||
|
NavigationLink {
|
||||||
|
GroupEditor(item: g, all: groups) { Task { await load() } }
|
||||||
|
} label: { zeile(r) }
|
||||||
|
} else {
|
||||||
|
zeile(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Mindestbestände")
|
.navigationTitle("Mindestbestände")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.overlay { if busy && rows.isEmpty { ProgressView() } }
|
.overlay { if busy && bloecke.isEmpty { ProgressView() } }
|
||||||
.refreshable { await load() }
|
.refreshable { await load() }
|
||||||
.onAppear { Task { await load() } }
|
.onAppear { Task { await load() } }
|
||||||
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||||
@@ -281,13 +306,18 @@ struct MinStockView: View {
|
|||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(r.title)
|
Text(r.title)
|
||||||
if r.isGroup {
|
if r.groupId != nil {
|
||||||
Text("Gruppe").font(.caption2)
|
Text("Gruppe").font(.caption2)
|
||||||
.padding(.horizontal, 6).padding(.vertical, 1)
|
.padding(.horizontal, 6).padding(.vertical, 1)
|
||||||
.background(Color.accentColor.opacity(0.2), in: Capsule())
|
.background(Color.marke.opacity(0.2), in: Capsule())
|
||||||
|
}
|
||||||
|
if r.untergruppen > 0 {
|
||||||
|
Text("inkl. \(r.untergruppen)").font(.caption2)
|
||||||
|
.padding(.horizontal, 6).padding(.vertical, 1)
|
||||||
|
.background(Color.secondary.opacity(0.15), in: Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text("\(r.ort) · Soll \(r.soll)" + (r.bestand.map { " · Bestand \($0)" } ?? ""))
|
Text("Soll \(r.soll)" + (r.bestand.map { " · Bestand \($0)" } ?? ""))
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ struct LocalOverview: View {
|
|||||||
anzeige
|
anzeige
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbar { ToolbarItemGroup(placement: .bottomBar) { werkzeugleiste } }
|
// Eigene Leiste statt `.bottomBar`: der Start-Tab liegt in einer TabView,
|
||||||
|
// deren Tab-Balken die `.bottomBar` verdecken würde. `safeAreaInset` legt
|
||||||
|
// die Leiste sichtbar direkt über den Tab-Balken.
|
||||||
|
.safeAreaInset(edge: .bottom) { werkzeugleiste }
|
||||||
.sheet(isPresented: $addShown) {
|
.sheet(isPresented: $addShown) {
|
||||||
CardPickerView { neu in fuegeHinzu(neu) }
|
CardPickerView { neu in fuegeHinzu(neu) }
|
||||||
}
|
}
|
||||||
@@ -59,7 +62,10 @@ struct LocalOverview: View {
|
|||||||
ForEach(aktives.cards) { karte in
|
ForEach(aktives.cards) { karte in
|
||||||
Section {
|
Section {
|
||||||
DashboardCardView(type: karte.type, productId: karte.productId,
|
DashboardCardView(type: karte.type, productId: karte.productId,
|
||||||
stand: stand) { stand += 1 }
|
stand: stand,
|
||||||
|
detail: karte.detail ?? false,
|
||||||
|
tiefe: karte.tiefe,
|
||||||
|
mitOhneMhd: karte.mitOhneMhd ?? true) { stand += 1 }
|
||||||
} header: {
|
} header: {
|
||||||
Text(titel(karte))
|
Text(titel(karte))
|
||||||
}
|
}
|
||||||
@@ -82,14 +88,12 @@ struct LocalOverview: View {
|
|||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
ForEach(aktives?.cards ?? []) { karte in
|
ForEach(aktives?.cards ?? []) { karte in
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Text(CardCatalog.titel(karte.type))
|
||||||
Text(CardCatalog.titel(karte.type))
|
if CardCatalogList.kind(for: karte.type)?.needsProduct == true {
|
||||||
if CardCatalogList.kind(for: karte.type)?.needsProduct == true {
|
artikelWahl(fuer: karte)
|
||||||
artikelWahl(fuer: karte)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer()
|
diagrammEinstellungen(fuer: karte)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDelete(perform: loesche)
|
.onDelete(perform: loesche)
|
||||||
@@ -110,33 +114,41 @@ struct LocalOverview: View {
|
|||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder private var werkzeugleiste: some View {
|
private var werkzeugleiste: some View {
|
||||||
if bearbeiten {
|
VStack(spacing: 0) {
|
||||||
Button { addShown = true } label: { Label("Karte", systemImage: "plus") }
|
Divider()
|
||||||
Spacer()
|
HStack {
|
||||||
Menu {
|
if bearbeiten {
|
||||||
Button("Dashboard hinzufügen", systemImage: "plus") { neuesDashboard() }
|
Button { addShown = true } label: { Label("Karte", systemImage: "plus") }
|
||||||
if aktives != nil {
|
Spacer()
|
||||||
Button("Umbenennen", systemImage: "pencil") {
|
Menu {
|
||||||
renameText = aktives?.name ?? ""; renameShown = true
|
Button("Dashboard hinzufügen", systemImage: "plus") { neuesDashboard() }
|
||||||
}
|
if aktives != nil {
|
||||||
if config.dashboards.count > 1 {
|
Button("Umbenennen", systemImage: "pencil") {
|
||||||
Button("Dashboard löschen", systemImage: "trash", role: .destructive) {
|
renameText = aktives?.name ?? ""; renameShown = true
|
||||||
loescheDashboard()
|
}
|
||||||
|
if config.dashboards.count > 1 {
|
||||||
|
Button("Dashboard löschen", systemImage: "trash", role: .destructive) {
|
||||||
|
loescheDashboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Dashboards", systemImage: "rectangle.stack")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Fertig") { bearbeiten = false }.fontWeight(.semibold)
|
||||||
|
} else {
|
||||||
|
Spacer()
|
||||||
|
Button { bearbeiten = true } label: {
|
||||||
|
Label("Bearbeiten", systemImage: "slider.horizontal.3")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
Label("Dashboards", systemImage: "rectangle.stack")
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button("Fertig") { bearbeiten = false }
|
|
||||||
} else {
|
|
||||||
Spacer()
|
|
||||||
Button { bearbeiten = true } label: {
|
|
||||||
Label("Bearbeiten", systemImage: "slider.horizontal.3")
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
|
.background(.bar)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func titel(_ karte: AppCard) -> String {
|
private func titel(_ karte: AppCard) -> String {
|
||||||
@@ -154,7 +166,54 @@ struct LocalOverview: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(produkte.first { $0.id == karte.productId }?.name ?? "Artikel wählen")
|
Text(produkte.first { $0.id == karte.productId }?.name ?? "Artikel wählen")
|
||||||
.font(.caption).foregroundStyle(Color.accentColor)
|
.font(.caption).foregroundStyle(Color.marke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Diagramm-Einstellungen (nur bestimmte Ringe, wie im Web-Editor)
|
||||||
|
|
||||||
|
private struct TiefeOption: Identifiable {
|
||||||
|
let tiefe: Int?
|
||||||
|
let label: String
|
||||||
|
var id: String { label }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let tiefeOptionen: [TiefeOption] = [
|
||||||
|
.init(tiefe: nil, label: "Feinste (jede Kategorie einzeln)"),
|
||||||
|
.init(tiefe: 1, label: "Nur oberste Stufe"),
|
||||||
|
.init(tiefe: 2, label: "Bis 2. Stufe"),
|
||||||
|
.init(tiefe: 3, label: "Bis 3. Stufe"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@ViewBuilder private func diagrammEinstellungen(fuer karte: AppCard) -> some View {
|
||||||
|
switch karte.type {
|
||||||
|
case "expiry-donut":
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { karte.mitOhneMhd ?? true },
|
||||||
|
set: { setzeMitOhneMhd(karte, $0) }
|
||||||
|
)) {
|
||||||
|
Text("„Ohne MHD“ einbeziehen").font(.caption)
|
||||||
|
}
|
||||||
|
case "category-donut":
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { karte.detail ?? false },
|
||||||
|
set: { setzeDetail(karte, $0) }
|
||||||
|
)) {
|
||||||
|
Text("Detailansicht (alle einzeln)").font(.caption)
|
||||||
|
}
|
||||||
|
Menu {
|
||||||
|
ForEach(Self.tiefeOptionen) { opt in
|
||||||
|
Button(opt.label) { setzeTiefe(karte, opt.tiefe) }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("Tiefe:").font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(Self.tiefeOptionen.first { $0.tiefe == karte.tiefe }?.label ?? "Feinste")
|
||||||
|
.font(.caption).foregroundStyle(Color.marke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,15 +228,32 @@ struct LocalOverview: View {
|
|||||||
|
|
||||||
private func fuegeHinzu(_ karte: AppCard) { mutiere { $0.cards.append(karte) } }
|
private func fuegeHinzu(_ karte: AppCard) { mutiere { $0.cards.append(karte) } }
|
||||||
|
|
||||||
private func setzeProdukt(_ karte: AppCard, _ pid: Int?) {
|
/// Ändert genau eine Karte und lädt die Anzeige neu.
|
||||||
|
private func mutiereKarte(_ karte: AppCard, _ block: (inout AppCard) -> Void) {
|
||||||
mutiere { dash in
|
mutiere { dash in
|
||||||
if let i = dash.cards.firstIndex(where: { $0.id == karte.id }) {
|
if let i = dash.cards.firstIndex(where: { $0.id == karte.id }) {
|
||||||
dash.cards[i].productId = pid
|
block(&dash.cards[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stand += 1
|
stand += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setzeProdukt(_ karte: AppCard, _ pid: Int?) {
|
||||||
|
mutiereKarte(karte) { $0.productId = pid }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setzeMitOhneMhd(_ karte: AppCard, _ wert: Bool) {
|
||||||
|
mutiereKarte(karte) { $0.mitOhneMhd = wert }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setzeDetail(_ karte: AppCard, _ wert: Bool) {
|
||||||
|
mutiereKarte(karte) { $0.detail = wert }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setzeTiefe(_ karte: AppCard, _ wert: Int?) {
|
||||||
|
mutiereKarte(karte) { $0.tiefe = wert }
|
||||||
|
}
|
||||||
|
|
||||||
private func loesche(at offsets: IndexSet) { mutiere { $0.cards.remove(atOffsets: offsets) } }
|
private func loesche(at offsets: IndexSet) { mutiere { $0.cards.remove(atOffsets: offsets) } }
|
||||||
private func verschiebe(from: IndexSet, to: Int) { mutiere { $0.cards.move(fromOffsets: from, toOffset: to) } }
|
private func verschiebe(from: IndexSet, to: Int) { mutiere { $0.cards.move(fromOffsets: from, toOffset: to) } }
|
||||||
|
|
||||||
@@ -240,7 +316,7 @@ struct CardPickerView: View {
|
|||||||
Text("· Artikel").font(.caption).foregroundStyle(.secondary)
|
Text("· Artikel").font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "plus.circle").foregroundStyle(Color.accentColor)
|
Image(systemName: "plus.circle").foregroundStyle(Color.marke)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
|
|||||||
@@ -1126,34 +1126,6 @@ struct FieldEditor: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GroupsView: View {
|
|
||||||
var body: some View {
|
|
||||||
MasterDataListView(
|
|
||||||
title: "Gruppen",
|
|
||||||
singular: "Die Gruppe",
|
|
||||||
load: {
|
|
||||||
try await APIClient.shared.groups().map {
|
|
||||||
MasterDataItem(id: $0.id, title: $0.name, subtitle: "", isBuiltin: false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
delete: { try await APIClient.shared.deleteGroup(id: $0) }
|
|
||||||
) { item, done in
|
|
||||||
NameEditor(
|
|
||||||
title: item == nil ? "Gruppe anlegen" : "Gruppe umbenennen",
|
|
||||||
initial: item?.title ?? "",
|
|
||||||
save: { name in
|
|
||||||
if let item {
|
|
||||||
_ = try await APIClient.shared.renameGroup(id: item.id, name: name)
|
|
||||||
} else {
|
|
||||||
_ = try await APIClient.shared.createGroup(
|
|
||||||
NewGroupRequest(name: name, minStock: nil, minStockUnitId: nil))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
done: done
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bezugsquellen für „gekauft bei" (nur Gegenstände). Wie im Web pflegbar –
|
/// Bezugsquellen für „gekauft bei" (nur Gegenstände). Wie im Web pflegbar –
|
||||||
/// Name plus optionale Website.
|
/// Name plus optionale Website.
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ struct Product: Codable, Identifiable, Hashable {
|
|||||||
let baseUnit: String
|
let baseUnit: String
|
||||||
let packageSize: Double?
|
let packageSize: Double?
|
||||||
let packageLabel: String?
|
let packageLabel: String?
|
||||||
|
/// Zweiteinheit: „secondaryCount Basiseinheiten ≙ secondaryAmount
|
||||||
|
/// secondaryBase". Bruecke zwischen den sonst strikt getrennten Arten.
|
||||||
|
/// Optionals, damit die App auch gegen einen aelteren Server laeuft.
|
||||||
|
let secondaryBase: String?
|
||||||
|
let secondaryCount: Double?
|
||||||
|
let secondaryAmount: Double?
|
||||||
|
/// Vom Server ausgerechnet: Zweit-Basiseinheiten je EINER Basiseinheit.
|
||||||
|
let secondaryFactor: Double?
|
||||||
let groupId: Int?
|
let groupId: Int?
|
||||||
let minStock: Double?
|
let minStock: Double?
|
||||||
let stock: Double
|
let stock: Double
|
||||||
@@ -98,6 +106,10 @@ struct Product: Codable, Identifiable, Hashable {
|
|||||||
case baseUnit = "base_unit"
|
case baseUnit = "base_unit"
|
||||||
case packageSize = "package_size"
|
case packageSize = "package_size"
|
||||||
case packageLabel = "package_label"
|
case packageLabel = "package_label"
|
||||||
|
case secondaryBase = "secondary_base"
|
||||||
|
case secondaryCount = "secondary_count"
|
||||||
|
case secondaryAmount = "secondary_amount"
|
||||||
|
case secondaryFactor = "secondary_factor"
|
||||||
case groupId = "group_id"
|
case groupId = "group_id"
|
||||||
case minStock = "min_stock"
|
case minStock = "min_stock"
|
||||||
case expiredCount = "expired_count"
|
case expiredCount = "expired_count"
|
||||||
@@ -134,6 +146,38 @@ struct Product: Codable, Identifiable, Hashable {
|
|||||||
|
|
||||||
var stockInArticleUnits: Double { stock / articleUnitFactor }
|
var stockInArticleUnits: Double { stock / articleUnitFactor }
|
||||||
|
|
||||||
|
/// Zweit-Basiseinheiten je EINER Basiseinheit – nil ohne Bruecke.
|
||||||
|
var zweitFaktor: Double? {
|
||||||
|
guard let basis = secondaryBase, !basis.isEmpty, basis != baseUnit else { return nil }
|
||||||
|
if let f = secondaryFactor, f > 0 { return f }
|
||||||
|
// Rueckfall, falls das abgeleitete Feld fehlt (aelterer Server).
|
||||||
|
guard let n = secondaryCount, let m = secondaryAmount, n > 0, m > 0 else { return nil }
|
||||||
|
return m / n
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Art der Zweiteinheit ("count"/"weight"/"volume") – fuer die Einheitenwahl.
|
||||||
|
var zweitKind: String? {
|
||||||
|
switch secondaryBase {
|
||||||
|
case "piece": return "count"
|
||||||
|
case "gram": return "weight"
|
||||||
|
case "milliliter": return "volume"
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kuerzel der Zweit-Basiseinheit fuer die Anzeige.
|
||||||
|
var zweitKurz: String {
|
||||||
|
switch secondaryBase {
|
||||||
|
case "gram": return "g"
|
||||||
|
case "milliliter": return "ml"
|
||||||
|
case "piece": return "Stück"
|
||||||
|
default: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bestand in der Zweiteinheit, z.B. 500 g bei 6 Stueck.
|
||||||
|
var stockInZweit: Double? { zweitFaktor.map { stock * $0 } }
|
||||||
|
|
||||||
/// Wie es um den Mindestbestand steht. Beides in Basiseinheiten verglichen.
|
/// Wie es um den Mindestbestand steht. Beides in Basiseinheiten verglichen.
|
||||||
enum StockLevel { case none, ok, close, below }
|
enum StockLevel { case none, ok, close, below }
|
||||||
|
|
||||||
@@ -146,30 +190,50 @@ struct Product: Codable, Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige).
|
/// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige).
|
||||||
|
///
|
||||||
|
/// ``locationId`` nil heisst „Ueberall": egal wo, Hauptsache die Menge ist im
|
||||||
|
/// Haus. Das ersetzt den frueheren separaten Gesamt-Mindestbestand. Mengen in
|
||||||
|
/// Basiseinheiten (g/ml/Stueck).
|
||||||
struct LocationMinStock: Codable, Hashable, Identifiable {
|
struct LocationMinStock: Codable, Hashable, Identifiable {
|
||||||
let locationId: String
|
let locationId: String?
|
||||||
let locationName: String?
|
let locationName: String?
|
||||||
let minStock: Double
|
let minStock: Double
|
||||||
var id: String { locationId }
|
/// Bestand an diesem Ort (inkl. Unterorte), ebenfalls in Basiseinheiten.
|
||||||
|
var stock: Double? = nil
|
||||||
|
var id: String { locationId ?? UEBERALL_ID }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case stock
|
||||||
case minStock = "min_stock"
|
case minStock = "min_stock"
|
||||||
case locationId = "location_id"
|
case locationId = "location_id"
|
||||||
case locationName = "location_name"
|
case locationName = "location_name"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten).
|
/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Basiseinheiten).
|
||||||
|
/// ``locationId`` nil = „Ueberall".
|
||||||
struct LocationMinStockIn: Codable {
|
struct LocationMinStockIn: Codable {
|
||||||
let locationId: String
|
let locationId: String?
|
||||||
let minStock: Double
|
let minStock: Double
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case minStock = "min_stock"
|
case minStock = "min_stock"
|
||||||
case locationId = "location_id"
|
case locationId = "location_id"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Den Ort immer senden – auch null, sonst laesst sich „Ueberall" gar nicht
|
||||||
|
// ausdruecken (Swift laesst nil-Optionals sonst weg).
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try c.encode(locationId, forKey: .locationId)
|
||||||
|
try c.encode(minStock, forKey: .minStock)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Platzhalter-ID fuer „Ueberall" in Pickern – im JSON ist es schlicht null.
|
||||||
|
let UEBERALL_ID = "__ueberall__"
|
||||||
|
let UEBERALL_NAME = "Überall"
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -213,6 +277,10 @@ struct LookupResult: Codable {
|
|||||||
let baseUnit: String?
|
let baseUnit: String?
|
||||||
let packageSize: Double?
|
let packageSize: Double?
|
||||||
let quantityText: String?
|
let quantityText: String?
|
||||||
|
/// Aus „3 x 80 g" vorgeschlagene Umrechnung – nur ein Vorschlag.
|
||||||
|
let secondaryBase: String?
|
||||||
|
let secondaryCount: Double?
|
||||||
|
let secondaryAmount: Double?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case barcode, name, brand
|
case barcode, name, brand
|
||||||
@@ -220,6 +288,9 @@ struct LookupResult: Codable {
|
|||||||
case baseUnit = "base_unit"
|
case baseUnit = "base_unit"
|
||||||
case packageSize = "package_size"
|
case packageSize = "package_size"
|
||||||
case quantityText = "quantity_text"
|
case quantityText = "quantity_text"
|
||||||
|
case secondaryBase = "secondary_base"
|
||||||
|
case secondaryCount = "secondary_count"
|
||||||
|
case secondaryAmount = "secondary_amount"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,6 +348,38 @@ struct BatchCheckInRequest: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Kassenzettel-Abgleich
|
||||||
|
|
||||||
|
struct MatchRequest: Codable {
|
||||||
|
let lines: [String]
|
||||||
|
let threshold: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MatchCandidate: Codable, Identifiable, Hashable {
|
||||||
|
let productId: Int
|
||||||
|
let name: String
|
||||||
|
let brand: String?
|
||||||
|
let score: Int
|
||||||
|
let packageSize: Double?
|
||||||
|
let baseUnit: String
|
||||||
|
|
||||||
|
var id: Int { productId }
|
||||||
|
/// Einheit fuers Einlagern: ganze Gebinde, sonst die Basiseinheit.
|
||||||
|
var checkInUnit: String { (packageSize ?? 0) > 0 ? "package" : baseUnit }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, brand, score
|
||||||
|
case productId = "product_id"
|
||||||
|
case packageSize = "package_size"
|
||||||
|
case baseUnit = "base_unit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MatchLine: Codable {
|
||||||
|
let text: String
|
||||||
|
let candidates: [MatchCandidate]
|
||||||
|
}
|
||||||
|
|
||||||
struct CheckOutRequest: Codable {
|
struct CheckOutRequest: Codable {
|
||||||
let productId: Int
|
let productId: Int
|
||||||
let quantity: Double
|
let quantity: Double
|
||||||
@@ -314,6 +417,10 @@ struct NewProductRequest: Codable {
|
|||||||
var bulk: Bool? = nil
|
var bulk: Bool? = nil
|
||||||
/// Selbst definierte Feldwerte der Kategorie: {feld_id (als Text): Wert}.
|
/// Selbst definierte Feldwerte der Kategorie: {feld_id (als Text): Wert}.
|
||||||
var fieldValues: [String: String?]? = nil
|
var fieldValues: [String: String?]? = nil
|
||||||
|
/// Umrechnung in die andere Art („3 Stück ≙ 250 g"), optional.
|
||||||
|
var secondaryBase: String? = nil
|
||||||
|
var secondaryCount: Double? = nil
|
||||||
|
var secondaryAmount: Double? = nil
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case barcode, name, brand, individual, bulk
|
case barcode, name, brand, individual, bulk
|
||||||
@@ -325,6 +432,9 @@ struct NewProductRequest: Codable {
|
|||||||
case groupId = "group_id"
|
case groupId = "group_id"
|
||||||
case categoryId = "category_id"
|
case categoryId = "category_id"
|
||||||
case fieldValues = "field_values"
|
case fieldValues = "field_values"
|
||||||
|
case secondaryBase = "secondary_base"
|
||||||
|
case secondaryCount = "secondary_count"
|
||||||
|
case secondaryAmount = "secondary_amount"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +468,8 @@ struct GroupShoppingItem: Codable, Identifiable {
|
|||||||
let deficit: Double
|
let deficit: Double
|
||||||
let unitName: String
|
let unitName: String
|
||||||
let productCount: Int
|
let productCount: Int
|
||||||
|
/// Wie viele Untergruppen mitgezaehlt werden (0 = keine).
|
||||||
|
var subgroupCount: Int? = nil
|
||||||
|
|
||||||
var id: Int { groupId }
|
var id: Int { groupId }
|
||||||
|
|
||||||
@@ -367,6 +479,7 @@ struct GroupShoppingItem: Codable, Identifiable {
|
|||||||
case minStock = "min_stock"
|
case minStock = "min_stock"
|
||||||
case unitName = "unit_name"
|
case unitName = "unit_name"
|
||||||
case productCount = "product_count"
|
case productCount = "product_count"
|
||||||
|
case subgroupCount = "subgroup_count"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,6 +585,15 @@ struct ExpiringItem: Codable, Identifiable {
|
|||||||
|
|
||||||
var id: Int { lotId }
|
var id: Int { lotId }
|
||||||
|
|
||||||
|
/// Restlaufzeit als kurzer Text. „0 Tage" ergibt keinen Sinn - am Ablauftag
|
||||||
|
/// selbst steht „heute". Karte und Liste nutzen dieselbe Formulierung.
|
||||||
|
var restlaufzeitText: String {
|
||||||
|
if daysLeft < 0 { return "abgelaufen" }
|
||||||
|
if daysLeft == 0 { return "heute" }
|
||||||
|
if daysLeft == 1 { return "morgen" }
|
||||||
|
return "\(daysLeft) Tage"
|
||||||
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case quantity
|
case quantity
|
||||||
case lotId = "lot_id"
|
case lotId = "lot_id"
|
||||||
@@ -521,6 +643,12 @@ struct ProductUpdateRequest: Encodable {
|
|||||||
var individual: Bool? = nil
|
var individual: Bool? = nil
|
||||||
var bulk: Bool? = nil
|
var bulk: Bool? = nil
|
||||||
var sendMode = false
|
var sendMode = false
|
||||||
|
// Zweiteinheit ebenfalls nur auf Wunsch – ein PATCH aus einer anderen Maske
|
||||||
|
// soll die Bruecke nicht unbeabsichtigt loeschen.
|
||||||
|
var secondaryBase: String? = nil
|
||||||
|
var secondaryCount: Double? = nil
|
||||||
|
var secondaryAmount: Double? = nil
|
||||||
|
var sendSecondary = false
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name, brand, individual, bulk
|
case name, brand, individual, bulk
|
||||||
@@ -536,6 +664,9 @@ struct ProductUpdateRequest: Encodable {
|
|||||||
case minStockInPackages = "min_stock_in_packages"
|
case minStockInPackages = "min_stock_in_packages"
|
||||||
case minStockUnitId = "min_stock_unit_id"
|
case minStockUnitId = "min_stock_unit_id"
|
||||||
case baseUnit = "base_unit"
|
case baseUnit = "base_unit"
|
||||||
|
case secondaryBase = "secondary_base"
|
||||||
|
case secondaryCount = "secondary_count"
|
||||||
|
case secondaryAmount = "secondary_amount"
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
@@ -561,18 +692,36 @@ struct ProductUpdateRequest: Encodable {
|
|||||||
try container.encode(bulk, forKey: .bulk)
|
try container.encode(bulk, forKey: .bulk)
|
||||||
if let baseUnit { try container.encode(baseUnit, forKey: .baseUnit) }
|
if let baseUnit { try container.encode(baseUnit, forKey: .baseUnit) }
|
||||||
}
|
}
|
||||||
|
if sendSecondary {
|
||||||
|
try container.encode(secondaryBase, forKey: .secondaryBase)
|
||||||
|
try container.encode(secondaryCount, forKey: .secondaryCount)
|
||||||
|
try container.encode(secondaryAmount, forKey: .secondaryAmount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LotUpdateRequest: Codable {
|
struct LotUpdateRequest: Encodable {
|
||||||
var quantity: Double?
|
var quantity: Double?
|
||||||
var bestBefore: String?
|
var bestBefore: String?
|
||||||
var bestBeforePrecision: String?
|
var bestBeforePrecision: String?
|
||||||
|
var locationId: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case quantity
|
case quantity
|
||||||
case bestBefore = "best_before"
|
case bestBefore = "best_before"
|
||||||
case bestBeforePrecision = "best_before_precision"
|
case bestBeforePrecision = "best_before_precision"
|
||||||
|
case locationId = "location_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Den Lagerort immer senden (auch null), damit „ohne Ort" den gesetzten Ort
|
||||||
|
// wieder entfernt. Die übrigen Felder bleiben weg, wenn sie nicht gesetzt
|
||||||
|
// sind – das Backend fasst sie dann als „unverändert" auf.
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try c.encodeIfPresent(quantity, forKey: .quantity)
|
||||||
|
try c.encodeIfPresent(bestBefore, forKey: .bestBefore)
|
||||||
|
try c.encodeIfPresent(bestBeforePrecision, forKey: .bestBeforePrecision)
|
||||||
|
try c.encode(locationId, forKey: .locationId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,20 +732,86 @@ struct SettingEntry: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Gruppe: fasst Bestaende mehrerer Marken zusammen (z.B. "Mehl").
|
/// Gruppe: fasst Bestaende mehrerer Marken zusammen (z.B. "Mehl").
|
||||||
|
///
|
||||||
|
/// Gruppen bilden einen Graphen, keinen Baum: eine Gruppe darf unter MEHREREN
|
||||||
|
/// Obergruppen haengen („Grillwurst" unter „Wurst" UND unter „Grillgut").
|
||||||
|
/// Deshalb ``parentIds`` als Liste – und deshalb konformiert GroupItem NICHT zu
|
||||||
|
/// ``TreeItem``, das genau einen Elternteil kennt.
|
||||||
struct GroupItem: Codable, Identifiable, Hashable {
|
struct GroupItem: Codable, Identifiable, Hashable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let name: String
|
let name: String
|
||||||
// Mindestbestand-Infos (nur aus /groups befuellt; Picker brauchen sie nicht).
|
// Mindestbestand-Infos (nur aus /groups befuellt; Picker brauchen sie nicht).
|
||||||
var minStock: Double? = nil
|
var minStock: Double? = nil
|
||||||
|
var minStockUnitId: Int? = nil
|
||||||
var minStockUnitName: String? = nil
|
var minStockUnitName: String? = nil
|
||||||
|
var minStockUnitFactor: Double? = nil
|
||||||
|
var kind: String? = nil
|
||||||
|
var packageSize: Double? = nil
|
||||||
|
var packageLabel: String? = nil
|
||||||
|
var minStockInPackages: Bool? = nil
|
||||||
var stock: Double? = nil
|
var stock: Double? = nil
|
||||||
var locationMinStocks: [LocationMinStock]? = nil
|
var locationMinStocks: [LocationMinStock]? = nil
|
||||||
|
var parentIds: [Int]? = nil
|
||||||
|
var childIds: [Int]? = nil
|
||||||
|
/// Artikel inkl. Untergruppen; ``directProductCount`` nur die eigenen.
|
||||||
|
var productCount: Int? = nil
|
||||||
|
var directProductCount: Int? = nil
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, name, stock
|
case id, name, stock, kind
|
||||||
case minStock = "min_stock"
|
case minStock = "min_stock"
|
||||||
|
case minStockUnitId = "min_stock_unit_id"
|
||||||
case minStockUnitName = "min_stock_unit_name"
|
case minStockUnitName = "min_stock_unit_name"
|
||||||
|
case minStockUnitFactor = "min_stock_unit_factor"
|
||||||
|
case packageSize = "package_size"
|
||||||
|
case packageLabel = "package_label"
|
||||||
|
case minStockInPackages = "min_stock_in_packages"
|
||||||
case locationMinStocks = "location_min_stocks"
|
case locationMinStocks = "location_min_stocks"
|
||||||
|
case parentIds = "parent_ids"
|
||||||
|
case childIds = "child_ids"
|
||||||
|
case productCount = "product_count"
|
||||||
|
case directProductCount = "direct_product_count"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basiseinheiten je Erfassungseinheit – zum Umrechnen der Mindestbestaende,
|
||||||
|
/// die in Basiseinheiten gespeichert sind.
|
||||||
|
var minFaktor: Double {
|
||||||
|
if minStockInPackages == true, let s = packageSize, s > 0 { return s }
|
||||||
|
return minStockUnitFactor ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beschriftung der Erfassungseinheit („Glas", „Gramm", …).
|
||||||
|
var minEinheit: String {
|
||||||
|
if minStockInPackages == true, (packageSize ?? 0) > 0 {
|
||||||
|
return packageLabel ?? "Packung"
|
||||||
|
}
|
||||||
|
if let n = minStockUnitName { return n }
|
||||||
|
switch kind {
|
||||||
|
case "weight": return "g"
|
||||||
|
case "volume": return "ml"
|
||||||
|
case "count": return "Stück"
|
||||||
|
default: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gruppe aendern. Nur mitgeschickte Felder wertet das Backend aus – deshalb
|
||||||
|
/// KEIN eigener Encoder: der synthetisierte laesst nil-Optionals weg
|
||||||
|
/// (encodeIfPresent). Ein handgeschriebener wuerde ``"name": null`` senden und
|
||||||
|
/// damit den Namen loeschen.
|
||||||
|
struct GroupUpdateRequest: Codable {
|
||||||
|
var name: String? = nil
|
||||||
|
var minStock: Double? = nil
|
||||||
|
var minStockUnitId: Int? = nil
|
||||||
|
var minStockInPackages: Bool? = nil
|
||||||
|
var parentIds: [Int]? = nil
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case minStock = "min_stock"
|
||||||
|
case minStockUnitId = "min_stock_unit_id"
|
||||||
|
case minStockInPackages = "min_stock_in_packages"
|
||||||
|
case parentIds = "parent_ids"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,9 +1221,17 @@ struct DashboardCard: Codable, Identifiable, Hashable {
|
|||||||
/// Web-Oberflaeche; hier bestimmen sie allein die Reihenfolge.
|
/// Web-Oberflaeche; hier bestimmen sie allein die Reihenfolge.
|
||||||
struct CardProps: Codable, Hashable {
|
struct CardProps: Codable, Hashable {
|
||||||
let productId: Int?
|
let productId: Int?
|
||||||
|
let locationId: String?
|
||||||
|
let detail: Bool? // Kategorien-Ring: alle einzeln statt „Übrige"
|
||||||
|
let tiefe: String? // Kategorie-Tiefe ("" = feinste, "1"/"2"/…)
|
||||||
|
let mitOhneMhd: Bool? // Ablauf-Ring: „Ohne MHD" einbeziehen
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case productId = "product_id"
|
case productId = "product_id"
|
||||||
|
case locationId = "location_id"
|
||||||
|
case detail
|
||||||
|
case tiefe
|
||||||
|
case mitOhneMhd = "mit_ohne_mhd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1221,10 +1444,13 @@ struct NewGroupRequest: Codable {
|
|||||||
let name: String
|
let name: String
|
||||||
let minStock: Double?
|
let minStock: Double?
|
||||||
let minStockUnitId: Int?
|
let minStockUnitId: Int?
|
||||||
|
/// Obergruppen (mehrere moeglich) – optional, Standard: keine.
|
||||||
|
var parentIds: [Int]? = nil
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
case minStock = "min_stock"
|
case minStock = "min_stock"
|
||||||
case minStockUnitId = "min_stock_unit_id"
|
case minStockUnitId = "min_stock_unit_id"
|
||||||
|
case parentIds = "parent_ids"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ struct ProductDetailView: View {
|
|||||||
@State private var packageSize = ""
|
@State private var packageSize = ""
|
||||||
@State private var packageLabel = ""
|
@State private var packageLabel = ""
|
||||||
@State private var datePrecision = "day"
|
@State private var datePrecision = "day"
|
||||||
|
// Zweiteinheit („3 Stück ≙ 250 g") – Bruecke zur anderen Art.
|
||||||
|
@State private var zweitAnzahl = ""
|
||||||
|
@State private var zweitMenge = ""
|
||||||
|
@State private var zweitBasis = ""
|
||||||
@State private var groupId: Int?
|
@State private var groupId: Int?
|
||||||
@State private var categoryId: Int?
|
@State private var categoryId: Int?
|
||||||
// Verwaltungsart eines Gegenstands: count | individual | bulk – umstellbar.
|
// Verwaltungsart eines Gegenstands: count | individual | bulk – umstellbar.
|
||||||
@@ -31,7 +35,6 @@ struct ProductDetailView: View {
|
|||||||
@State private var shopId: Int?
|
@State private var shopId: Int?
|
||||||
@State private var productUrl = ""
|
@State private var productUrl = ""
|
||||||
// Gesamt-Mindestbestand (nur Gegenstände; Menge in Stück/Artikeleinheit).
|
// Gesamt-Mindestbestand (nur Gegenstände; Menge in Stück/Artikeleinheit).
|
||||||
@State private var minStock = ""
|
|
||||||
@State private var shops: [ShopItem] = []
|
@State private var shops: [ShopItem] = []
|
||||||
@State private var locations: [StorageLocation] = []
|
@State private var locations: [StorageLocation] = []
|
||||||
@State private var fieldDefs: [FieldDefinition] = []
|
@State private var fieldDefs: [FieldDefinition] = []
|
||||||
@@ -63,8 +66,9 @@ struct ProductDetailView: View {
|
|||||||
if packageSize != (current.packageSize.map { formatAmount($0) } ?? "") { return true }
|
if packageSize != (current.packageSize.map { formatAmount($0) } ?? "") { return true }
|
||||||
if packageLabel != (current.packageLabel ?? "") { return true }
|
if packageLabel != (current.packageLabel ?? "") { return true }
|
||||||
if datePrecision != (current.datePrecision == "month" ? "month" : "day") { return true }
|
if datePrecision != (current.datePrecision == "month" ? "month" : "day") { return true }
|
||||||
// Mindestbestand gibt es bei Lebensmitteln und Verbrauchsgegenständen.
|
if zweitBasis != (current.secondaryBase ?? "") { return true }
|
||||||
if minStock != minStockFeld { return true }
|
if zweitAnzahl != (current.secondaryCount.map { formatAmount($0) } ?? "") { return true }
|
||||||
|
if zweitMenge != (current.secondaryAmount.map { formatAmount($0) } ?? "") { return true }
|
||||||
}
|
}
|
||||||
if current.isObject {
|
if current.isObject {
|
||||||
if objMode != currentObjMode { return true }
|
if objMode != currentObjMode { return true }
|
||||||
@@ -100,8 +104,12 @@ struct ProductDetailView: View {
|
|||||||
} else {
|
} else {
|
||||||
// Lebensmittel und Verbrauchsgegenstände: Vorrat aus den Chargen.
|
// Lebensmittel und Verbrauchsgegenstände: Vorrat aus den Chargen.
|
||||||
Section("Bestand") {
|
Section("Bestand") {
|
||||||
LabeledContent("Vorrat",
|
// Mit Zweiteinheit beide Lesarten: „6 Stück · 500 g".
|
||||||
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)")
|
LabeledContent("Vorrat", value: {
|
||||||
|
let haupt = "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)"
|
||||||
|
guard let zweit = current.stockInZweit else { return haupt }
|
||||||
|
return "\(haupt) · \(formatAmount(zweit)) \(current.zweitKurz)"
|
||||||
|
}())
|
||||||
if current.expiredCount > 0 {
|
if current.expiredCount > 0 {
|
||||||
LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
|
LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
@@ -114,17 +122,47 @@ struct ProductDetailView: View {
|
|||||||
Section("Artikel") {
|
Section("Artikel") {
|
||||||
LabeledField(label: "Name", text: $name)
|
LabeledField(label: "Name", text: $name)
|
||||||
LabeledField(label: "Marke", text: $brand)
|
LabeledField(label: "Marke", text: $brand)
|
||||||
if current.foodLike {
|
}
|
||||||
QuantityField(label: "Packungsgröße", text: $packageSize,
|
|
||||||
suffix: display.baseUnitLabel(current.baseUnit))
|
// Alles zur Einheit steht zusammen und liest sich als Satz: das
|
||||||
Picker("Bezeichnung", selection: $packageLabel) {
|
// Gebinde zuerst, weil die Zeile darunter es beim Namen nennt.
|
||||||
|
if current.foodLike {
|
||||||
|
Section("Einheit und Menge") {
|
||||||
|
Picker("Gebinde", selection: $packageLabel) {
|
||||||
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||||||
}
|
}
|
||||||
|
QuantityField(label: "1 \(gebindeName) sind", text: $packageSize,
|
||||||
|
suffix: display.baseUnitLabel(current.baseUnit))
|
||||||
Picker("MHD-Angabe", selection: $datePrecision) {
|
Picker("MHD-Angabe", selection: $datePrecision) {
|
||||||
Text("Tagesdatum").tag("day")
|
Text("Tagesdatum").tag("day")
|
||||||
Text("nur Monat/Jahr").tag("month")
|
Text("nur Monat/Jahr").tag("month")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Umrechnung in die ANDERE Art: „3 Stück wiegen 250 g". Damit
|
||||||
|
// zaehlt der Artikel auch in Gruppen, die in dieser Einheit
|
||||||
|
// rechnen. Das Gebinde bleibt davon unberuehrt.
|
||||||
|
Section {
|
||||||
|
Picker("Umrechnen in", selection: Binding(
|
||||||
|
get: { zweitBasis },
|
||||||
|
set: { waehleZweitart($0) }
|
||||||
|
)) {
|
||||||
|
Text("– keine –").tag("")
|
||||||
|
ForEach(zweitBasisOptionen, id: \.self) { basis in
|
||||||
|
Text(display.baseUnitLabel(basis)).tag(basis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !zweitBasis.isEmpty {
|
||||||
|
QuantityField(label: "Menge", text: $zweitAnzahl,
|
||||||
|
suffix: display.baseUnitLabel(current.baseUnit))
|
||||||
|
QuantityField(label: zweitVerb, text: $zweitMenge,
|
||||||
|
suffix: display.baseUnitLabel(zweitBasis))
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("\(zweitTitel) (optional)")
|
||||||
|
} footer: {
|
||||||
|
Text(zweitHinweis)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -179,8 +217,9 @@ struct ProductDetailView: View {
|
|||||||
Section {
|
Section {
|
||||||
Picker("Gruppe", selection: $groupId) {
|
Picker("Gruppe", selection: $groupId) {
|
||||||
Text("– keine –").tag(Int?.none)
|
Text("– keine –").tag(Int?.none)
|
||||||
ForEach(groups) { gruppe in
|
// Als Baum, damit sichtbar ist, was unter was haengt.
|
||||||
Text(gruppe.name).tag(Int?.some(gruppe.id))
|
ForEach(gruppenOptionen(groups)) { eintrag in
|
||||||
|
Text(eintrag.label).tag(Int?.some(eintrag.gruppe.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
@@ -197,26 +236,31 @@ struct ProductDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mindestbestand bei Lebensmitteln UND Verbrauchsgegenständen (Charge) –
|
// Mindestbestand bei Lebensmitteln UND Verbrauchsgegenständen (Charge).
|
||||||
// in der Anzeigeeinheit (z.B. Gramm/Milliliter), nicht in Packungen.
|
|
||||||
// Menge je Lagerort und Einzelstücke haben keinen Mindestbestand.
|
// Menge je Lagerort und Einzelstücke haben keinen Mindestbestand.
|
||||||
if current.foodLike {
|
//
|
||||||
Section("Mindestbestand") {
|
// Es gibt kein separates „Gesamt"-Feld mehr: der Bedarf hängt immer
|
||||||
QuantityField(label: "Gesamt (\(current.unitName))", text: $minStock)
|
// an einem Ort, und „Überall" ist einer davon (der oberste).
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if current.foodLike {
|
if current.foodLike {
|
||||||
Section {
|
Section {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ProductLocationMinView(product: current) { await reload() }
|
ProductLocationMinView(product: current) { await reload() }
|
||||||
} label: {
|
} label: {
|
||||||
let n = (current.locationMinStocks ?? []).count
|
let zeilen = current.locationMinStocks ?? []
|
||||||
Label(n > 0 ? "Mindestbestand je Lagerort (\(n))" : "Mindestbestand je Lagerort",
|
let ueberall = zeilen.first { $0.locationId == nil }
|
||||||
systemImage: "mappin.and.ellipse")
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Mindestbestand")
|
||||||
|
Text(minStockUntertitel(zeilen: zeilen, ueberall: ueberall))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "mappin.and.ellipse")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum Gesamt-Mindestbestand.")
|
Text("„Überall“ heißt: egal wo, Hauptsache im Haus – Käufe für einen "
|
||||||
|
+ "Lagerort decken das mit ab.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +285,10 @@ struct ProductDetailView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
|
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
|
||||||
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)")
|
// Ohne den Lagerort sehen zwei Chargen mit
|
||||||
|
// gleichem MHD identisch aus – er ist hier
|
||||||
|
// oft das einzige Unterscheidungsmerkmal.
|
||||||
|
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel) · \(lotLocationLabel(lot))")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -286,7 +333,7 @@ struct ProductDetailView: View {
|
|||||||
}
|
}
|
||||||
.sheet(item: $editLot) { lot in
|
.sheet(item: $editLot) { lot in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
LotEditView(lot: lot, product: current) {
|
LotEditView(lot: lot, product: current, locations: locations) {
|
||||||
Task { await reload() }
|
Task { await reload() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,10 +373,75 @@ struct ProductDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gesamt-Mindestbestand in der Anzeigeeinheit (Gramm/Milliliter/Stück) – aus
|
/// Kurzfassung der hinterlegten Mindestbestände für die Übersichtszeile.
|
||||||
/// den Basiseinheiten umgerechnet, unabhängig davon, wie er erfasst wurde.
|
private func minStockUntertitel(zeilen: [LocationMinStock], ueberall: LocationMinStock?) -> String {
|
||||||
private var minStockFeld: String {
|
if zeilen.isEmpty { return "keiner hinterlegt" }
|
||||||
current.minStock.map { formatAmount($0 / max(current.unitFactor, 1)) } ?? ""
|
let faktor = max(current.unitFactor, 1)
|
||||||
|
var teile: [String] = []
|
||||||
|
if let u = ueberall {
|
||||||
|
teile.append("Überall \(formatAmount(u.minStock / faktor)) \(current.unitName)")
|
||||||
|
}
|
||||||
|
let orte = zeilen.count - (ueberall == nil ? 0 : 1)
|
||||||
|
if orte > 0 { teile.append("\(orte) \(orte == 1 ? "Lagerort" : "Lagerorte")") }
|
||||||
|
return teile.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basiseinheiten der ANDEREN Arten – eine Bruecke auf die eigene Art
|
||||||
|
/// haette nichts umzurechnen.
|
||||||
|
private var zweitBasisOptionen: [String] {
|
||||||
|
["piece", "gram", "milliliter"].filter { $0 != current.baseUnit }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Umrechnung heisst nach dem, was sie liefert – „Zweiteinheit" musste
|
||||||
|
/// man erst uebersetzen. Ohne gewaehlte Zielart ein neutraler Titel.
|
||||||
|
private var zweitTitel: String {
|
||||||
|
switch zweitBasis {
|
||||||
|
case "gram": return "Gewicht"
|
||||||
|
case "milliliter": return "Volumen"
|
||||||
|
case "piece": return "Stückzahl"
|
||||||
|
default: return "Umrechnung"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verb passend zur Zielart und zur Anzahl: „1 Stück wiegt" / „3 wiegen".
|
||||||
|
private var zweitVerb: String {
|
||||||
|
let eins = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) == 1
|
||||||
|
switch zweitBasis {
|
||||||
|
case "gram": return eins ? "wiegt" : "wiegen"
|
||||||
|
case "milliliter": return eins ? "fasst" : "fassen"
|
||||||
|
case "piece": return eins ? "ist" : "sind"
|
||||||
|
default: return "sind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das Gebinde beim Namen nennen: „1 Packung sind …". Ohne Auswahl der
|
||||||
|
/// Standard, sonst stuende dort „1 sind 3 Stück".
|
||||||
|
private var gebindeName: String {
|
||||||
|
packageLabel.trimmingCharacters(in: .whitespaces).isEmpty ? "Packung" : packageLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zielart waehlen und die linke Zahl EINMALIG mit der Packungsgroesse
|
||||||
|
/// vorbelegen – im Normalfall ist sie genau das. Bewusst keine dauerhafte
|
||||||
|
/// Kopplung: wer die Packung spaeter aendert, hat dieselbe Ware.
|
||||||
|
private func waehleZweitart(_ basis: String) {
|
||||||
|
zweitBasis = basis
|
||||||
|
if !basis.isEmpty, zweitAnzahl.isEmpty {
|
||||||
|
zweitAnzahl = packageSize.isEmpty ? "1" : packageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kontrolle unter der Eingabe: „1 Stück ≈ 83,33 g".
|
||||||
|
private var zweitHinweis: String {
|
||||||
|
let n = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) ?? 0
|
||||||
|
let m = Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) ?? 0
|
||||||
|
guard !zweitBasis.isEmpty, n > 0, m > 0 else {
|
||||||
|
return "Zusätzliche Lesart desselben Artikels – damit zählt er auch in "
|
||||||
|
+ "Gruppen der anderen Art und lässt sich darin ein- und auslagern."
|
||||||
|
}
|
||||||
|
return "1 \(display.baseUnitLabel(current.baseUnit)) ≈ \(formatAmount(m / n)) "
|
||||||
|
+ "\(display.baseUnitLabel(zweitBasis)). Damit zählt der Artikel auch in "
|
||||||
|
+ "Gruppen, die in dieser Einheit rechnen. Bestände bleiben beim Ändern "
|
||||||
|
+ "unverändert – nur ihre Umrechnung verschiebt sich."
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aktuelle Verwaltungsart des geladenen Artikels.
|
/// Aktuelle Verwaltungsart des geladenen Artikels.
|
||||||
@@ -343,11 +455,13 @@ struct ProductDetailView: View {
|
|||||||
packageSize = current.packageSize.map { formatAmount($0) } ?? ""
|
packageSize = current.packageSize.map { formatAmount($0) } ?? ""
|
||||||
packageLabel = current.packageLabel ?? ""
|
packageLabel = current.packageLabel ?? ""
|
||||||
datePrecision = current.datePrecision == "month" ? "month" : "day"
|
datePrecision = current.datePrecision == "month" ? "month" : "day"
|
||||||
|
zweitBasis = current.secondaryBase ?? ""
|
||||||
|
zweitAnzahl = current.secondaryCount.map { formatAmount($0) } ?? ""
|
||||||
|
zweitMenge = current.secondaryAmount.map { formatAmount($0) } ?? ""
|
||||||
groupId = current.groupId
|
groupId = current.groupId
|
||||||
categoryId = current.categoryId
|
categoryId = current.categoryId
|
||||||
shopId = current.shopId
|
shopId = current.shopId
|
||||||
productUrl = current.productUrl ?? ""
|
productUrl = current.productUrl ?? ""
|
||||||
minStock = minStockFeld
|
|
||||||
objMode = currentObjMode
|
objMode = currentObjMode
|
||||||
fieldValues = Self.stringValues(current.fieldValues)
|
fieldValues = Self.stringValues(current.fieldValues)
|
||||||
}
|
}
|
||||||
@@ -364,6 +478,14 @@ struct ProductDetailView: View {
|
|||||||
return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 })
|
return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lagerort einer Charge als Pfad („Keller → Regal 2"). Chargen ohne Ort –
|
||||||
|
/// und solche, deren Ort (noch) nicht geladen ist – bleiben „ohne Ort".
|
||||||
|
private func lotLocationLabel(_ lot: Lot) -> String {
|
||||||
|
guard let id = lot.locationId,
|
||||||
|
let ort = locations.first(where: { $0.id == id }) else { return "ohne Ort" }
|
||||||
|
return ort.path(in: locations)
|
||||||
|
}
|
||||||
|
|
||||||
private func reload() async {
|
private func reload() async {
|
||||||
groups = (try? await APIClient.shared.groups()) ?? []
|
groups = (try? await APIClient.shared.groups()) ?? []
|
||||||
categories = (try? await APIClient.shared.categories()) ?? []
|
categories = (try? await APIClient.shared.categories()) ?? []
|
||||||
@@ -400,9 +522,6 @@ struct ProductDetailView: View {
|
|||||||
busy = true
|
busy = true
|
||||||
defer { busy = false }
|
defer { busy = false }
|
||||||
do {
|
do {
|
||||||
// Mindestbestand in der Anzeigeeinheit erfasst → Basiseinheiten.
|
|
||||||
let minBase = Double(minStock.replacingOccurrences(of: ",", with: "."))
|
|
||||||
.map { $0 * current.unitFactor }
|
|
||||||
if current.foodLike {
|
if current.foodLike {
|
||||||
// Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/
|
// Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/
|
||||||
// Packung/MHD/Gruppe. Verbrauchsgegenstände (isObject) tragen
|
// Packung/MHD/Gruppe. Verbrauchsgegenstände (isObject) tragen
|
||||||
@@ -416,11 +535,20 @@ struct ProductDetailView: View {
|
|||||||
groupId: groupId,
|
groupId: groupId,
|
||||||
categoryId: categoryId
|
categoryId: categoryId
|
||||||
)
|
)
|
||||||
// Mindestbestand für Lebensmittel UND Verbrauchsgegenstände.
|
// Der Mindestbestand wird in ProductLocationMinView gepflegt und
|
||||||
req.minStock = minBase
|
// hier bewusst NICHT mitgeschickt – sonst überschriebe das
|
||||||
req.minStockInPackages = false
|
// Stammdaten-Speichern die dort gesetzte „Überall"-Zeile.
|
||||||
req.minStockUnitId = nil
|
//
|
||||||
req.sendMinStock = true
|
// Zweiteinheit nur vollstaendig; leere oder halbe Angabe loescht
|
||||||
|
// die Bruecke (secondary_base null raeumt im Backend mit auf).
|
||||||
|
let zn = Double(zweitAnzahl.replacingOccurrences(of: ",", with: "."))
|
||||||
|
let zm = Double(zweitMenge.replacingOccurrences(of: ",", with: "."))
|
||||||
|
req.sendSecondary = true
|
||||||
|
if !zweitBasis.isEmpty, let zn, let zm, zn > 0, zm > 0 {
|
||||||
|
req.secondaryBase = zweitBasis
|
||||||
|
req.secondaryCount = zn
|
||||||
|
req.secondaryAmount = zm
|
||||||
|
}
|
||||||
if current.isObject {
|
if current.isObject {
|
||||||
var fv: [String: String?] = [:]
|
var fv: [String: String?] = [:]
|
||||||
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
|
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
|
||||||
@@ -508,10 +636,11 @@ struct ProductByIdView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Menge und MHD einer Charge korrigieren.
|
/// Menge, MHD und Lagerort einer Charge korrigieren.
|
||||||
struct LotEditView: View {
|
struct LotEditView: View {
|
||||||
let lot: Lot
|
let lot: Lot
|
||||||
let product: Product
|
let product: Product
|
||||||
|
let locations: [StorageLocation]
|
||||||
var onSaved: () -> Void
|
var onSaved: () -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -520,6 +649,8 @@ struct LotEditView: View {
|
|||||||
@State private var hasDate = false
|
@State private var hasDate = false
|
||||||
@State private var bestBefore = Date()
|
@State private var bestBefore = Date()
|
||||||
@State private var precision = "day"
|
@State private var precision = "day"
|
||||||
|
@State private var locationId: String?
|
||||||
|
@State private var locScanShown = false
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
@@ -545,6 +676,19 @@ struct LotEditView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !locations.isEmpty {
|
||||||
|
Section("Lagerort") {
|
||||||
|
Picker("Lagerort", selection: $locationId) {
|
||||||
|
Text("– ohne –").tag(String?.none)
|
||||||
|
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
|
||||||
|
}
|
||||||
|
// Ort per QR am Regal/Fach setzen, statt in der Liste zu suchen.
|
||||||
|
Button { locScanShown = true } label: {
|
||||||
|
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let error {
|
if let error {
|
||||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
}
|
}
|
||||||
@@ -559,11 +703,16 @@ struct LotEditView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(isPresented: $locScanShown) {
|
||||||
|
LocationScannerView(locations: locations) { ort in locationId = ort.id }
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
.onAppear(perform: fill)
|
.onAppear(perform: fill)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fill() {
|
private func fill() {
|
||||||
quantity = formatAmount(lot.quantity / product.articleUnitFactor)
|
quantity = formatAmount(lot.quantity / product.articleUnitFactor)
|
||||||
|
locationId = lot.locationId
|
||||||
precision = lot.bestBeforePrecision == "month" ? "month" : "day"
|
precision = lot.bestBeforePrecision == "month" ? "month" : "day"
|
||||||
if let raw = lot.bestBefore {
|
if let raw = lot.bestBefore {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
@@ -595,7 +744,8 @@ struct LotEditView: View {
|
|||||||
LotUpdateRequest(
|
LotUpdateRequest(
|
||||||
quantity: menge * product.articleUnitFactor,
|
quantity: menge * product.articleUnitFactor,
|
||||||
bestBefore: hasDate ? formatter.string(from: datum) : nil,
|
bestBefore: hasDate ? formatter.string(from: datum) : nil,
|
||||||
bestBeforePrecision: precision
|
bestBeforePrecision: precision,
|
||||||
|
locationId: locationId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
onSaved()
|
onSaved()
|
||||||
@@ -627,22 +777,21 @@ struct ProductLocationMinView: View {
|
|||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
// Speicherung in Artikeleinheiten, Anzeige in der Anzeigeeinheit (Gramm/ml/Stück).
|
// Gespeichert wird in Basiseinheiten, erfasst in der Anzeigeeinheit (g/ml/Stück).
|
||||||
private var artToDisp: Double {
|
private var dispFaktor: Double { product.unitFactor == 0 ? 1 : product.unitFactor }
|
||||||
let disp = product.unitFactor == 0 ? 1 : product.unitFactor
|
|
||||||
return product.articleUnitFactor / disp
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause). Menge in \(product.unitName).")
|
Text("Was soll wo immer da sein? „Überall“ heißt: egal wo, Hauptsache "
|
||||||
|
+ "im Haus – Käufe für einen Lagerort decken das mit ab. "
|
||||||
|
+ "Menge in \(product.unitName).")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
ForEach($rows) { $row in
|
ForEach($rows) { $row in
|
||||||
HStack {
|
HStack {
|
||||||
Picker("Lagerort", selection: $row.locationId) {
|
Picker("Ort", selection: $row.locationId) {
|
||||||
Text("– wählen –").tag(String?.none)
|
Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID))
|
||||||
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
|
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
|
||||||
}
|
}
|
||||||
TextField("Menge", text: $row.amount)
|
TextField("Menge", text: $row.amount)
|
||||||
@@ -658,15 +807,15 @@ struct ProductLocationMinView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button {
|
Button {
|
||||||
rows.append(MinRow(locationId: nil, amount: ""))
|
rows.append(MinRow(locationId: UEBERALL_ID, amount: ""))
|
||||||
} label: {
|
} label: {
|
||||||
Label("Lagerort hinzufügen", systemImage: "plus")
|
Label("Mindestbestand hinzufügen", systemImage: "plus")
|
||||||
}
|
}
|
||||||
if let error {
|
if let error {
|
||||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Bedarf je Lagerort")
|
.navigationTitle("Mindestbestand")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
@@ -679,8 +828,10 @@ struct ProductLocationMinView: View {
|
|||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
locations = (try? await APIClient.shared.locations()) ?? []
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
|
// „Überall" ist der Ort nil – im Picker braucht es einen Platzhalter.
|
||||||
rows = (product.locationMinStocks ?? []).map {
|
rows = (product.locationMinStocks ?? []).map {
|
||||||
MinRow(locationId: $0.locationId, amount: formatAmount($0.minStock * artToDisp))
|
MinRow(locationId: $0.locationId ?? UEBERALL_ID,
|
||||||
|
amount: formatAmount($0.minStock / dispFaktor))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,8 +845,10 @@ struct ProductLocationMinView: View {
|
|||||||
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
|
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
|
||||||
if wert > 0 {
|
if wert > 0 {
|
||||||
gesehen.insert(loc)
|
gesehen.insert(loc)
|
||||||
// Eingabe in Anzeigeeinheit → Artikeleinheiten (so gespeichert).
|
// Eingabe in Anzeigeeinheit → Basiseinheiten (so gespeichert).
|
||||||
list.append(LocationMinStockIn(locationId: loc, minStock: wert / artToDisp))
|
list.append(LocationMinStockIn(
|
||||||
|
locationId: loc == UEBERALL_ID ? nil : loc,
|
||||||
|
minStock: wert * dispFaktor))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ struct ProductFormView: View {
|
|||||||
// Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel.
|
// Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel.
|
||||||
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||||||
|
|
||||||
|
// Umrechnung in die andere Art („3 Stück ≙ 250 g") – siehe ProductDetailView.
|
||||||
|
@State private var zweitBasis = ""
|
||||||
|
@State private var zweitAnzahl = ""
|
||||||
|
@State private var zweitMenge = ""
|
||||||
|
|
||||||
/// Nutzt die Lebensmittel-Pfade (Einheit, MHD, Charge): Lebensmittel ODER
|
/// Nutzt die Lebensmittel-Pfade (Einheit, MHD, Charge): Lebensmittel ODER
|
||||||
/// Verbrauchsgegenstand.
|
/// Verbrauchsgegenstand.
|
||||||
private var foodLike: Bool { modus == "food" || objMode == "bulk" }
|
private var foodLike: Bool { modus == "food" || objMode == "bulk" }
|
||||||
@@ -161,8 +166,9 @@ struct ProductFormView: View {
|
|||||||
Section {
|
Section {
|
||||||
Picker("Gruppe", selection: $selectedGroupId) {
|
Picker("Gruppe", selection: $selectedGroupId) {
|
||||||
Text("– keine –").tag(Int?.none)
|
Text("– keine –").tag(Int?.none)
|
||||||
ForEach(groups) { gruppe in
|
// Als Baum, damit sichtbar ist, was unter was haengt.
|
||||||
Text(gruppe.name).tag(Int?.some(gruppe.id))
|
ForEach(gruppenOptionen(groups)) { eintrag in
|
||||||
|
Text(eintrag.label).tag(Int?.some(eintrag.gruppe.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if session.isAdmin {
|
if session.isAdmin {
|
||||||
@@ -195,6 +201,31 @@ struct ProductFormView: View {
|
|||||||
Text("nur Monat/Jahr").tag("month")
|
Text("nur Monat/Jahr").tag("month")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dieselbe Ware in der ANDEREN Art lesbar machen. Damit zaehlt
|
||||||
|
// der Artikel auch in Gruppen, die in dieser Einheit rechnen.
|
||||||
|
Section {
|
||||||
|
Picker("Umrechnen in", selection: Binding(
|
||||||
|
get: { zweitBasis },
|
||||||
|
set: { waehleZweitart($0) }
|
||||||
|
)) {
|
||||||
|
Text("– keine –").tag("")
|
||||||
|
ForEach(zweitBasisOptionen, id: \.self) { basis in
|
||||||
|
Text(DisplaySettings.baseUnitLabel(basis)).tag(basis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !zweitBasis.isEmpty {
|
||||||
|
QuantityField(label: "Menge", text: $zweitAnzahl,
|
||||||
|
suffix: DisplaySettings.baseUnitLabel(baseUnit))
|
||||||
|
QuantityField(label: zweitVerb, text: $zweitMenge,
|
||||||
|
suffix: DisplaySettings.baseUnitLabel(zweitBasis))
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("\(zweitTitel) (optional)")
|
||||||
|
} footer: {
|
||||||
|
Text("Zweite Lesart desselben Artikels – z.B. „3 Stück wiegen 250 g“. "
|
||||||
|
+ "Lässt sich auch später am Artikel nachtragen.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Eigene Felder der Gegenstands-Kategorie (z.B. Größe, Farbe beim
|
// Eigene Felder der Gegenstands-Kategorie (z.B. Größe, Farbe beim
|
||||||
// T-Shirt) – schon beim Anlegen ausfüllbar, nicht erst danach.
|
// T-Shirt) – schon beim Anlegen ausfüllbar, nicht erst danach.
|
||||||
@@ -299,6 +330,48 @@ struct ProductFormView: View {
|
|||||||
return fv
|
return fv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Basiseinheiten der ANDEREN Arten – eine Bruecke auf die eigene Art
|
||||||
|
/// haette nichts umzurechnen.
|
||||||
|
private var zweitBasisOptionen: [String] {
|
||||||
|
["piece", "gram", "milliliter"].filter { $0 != baseUnit }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var zweitTitel: String {
|
||||||
|
switch zweitBasis {
|
||||||
|
case "gram": return "Gewicht"
|
||||||
|
case "milliliter": return "Volumen"
|
||||||
|
case "piece": return "Stückzahl"
|
||||||
|
default: return "Umrechnung"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var zweitVerb: String {
|
||||||
|
let eins = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) == 1
|
||||||
|
switch zweitBasis {
|
||||||
|
case "gram": return eins ? "wiegt" : "wiegen"
|
||||||
|
case "milliliter": return eins ? "fasst" : "fassen"
|
||||||
|
case "piece": return eins ? "ist" : "sind"
|
||||||
|
default: return "sind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zielart waehlen und die linke Zahl EINMALIG mit der Packungsgroesse
|
||||||
|
/// vorbelegen – im Normalfall ist sie genau das.
|
||||||
|
private func waehleZweitart(_ basis: String) {
|
||||||
|
zweitBasis = basis
|
||||||
|
if !basis.isEmpty, zweitAnzahl.isEmpty {
|
||||||
|
zweitAnzahl = packageSize.isEmpty ? "1" : packageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nur eine vollstaendige Angabe ist eine Bruecke – eine halbe waere keine.
|
||||||
|
private var zweitVollstaendig: Bool {
|
||||||
|
guard !zweitBasis.isEmpty,
|
||||||
|
let n = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")),
|
||||||
|
let m = Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) else { return false }
|
||||||
|
return n > 0 && m > 0
|
||||||
|
}
|
||||||
|
|
||||||
private func prefill() {
|
private func prefill() {
|
||||||
if let initialType { modus = initialType }
|
if let initialType { modus = initialType }
|
||||||
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
||||||
@@ -312,6 +385,14 @@ struct ProductFormView: View {
|
|||||||
brand = suggestion.brand ?? ""
|
brand = suggestion.brand ?? ""
|
||||||
baseUnit = suggestion.baseUnit ?? "piece"
|
baseUnit = suggestion.baseUnit ?? "piece"
|
||||||
if let size = suggestion.packageSize { packageSize = String(size) }
|
if let size = suggestion.packageSize { packageSize = String(size) }
|
||||||
|
// „3 x 80 g" schlaegt die Umrechnung gleich mit vor.
|
||||||
|
if let basis = suggestion.secondaryBase,
|
||||||
|
let anzahl = suggestion.secondaryCount,
|
||||||
|
let menge = suggestion.secondaryAmount {
|
||||||
|
zweitBasis = basis
|
||||||
|
zweitAnzahl = formatAmount(anzahl)
|
||||||
|
zweitMenge = formatAmount(menge)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +415,12 @@ struct ProductFormView: View {
|
|||||||
categoryId: selectedCategoryId,
|
categoryId: selectedCategoryId,
|
||||||
individual: modus == "object" ? (objMode == "individual") : nil,
|
individual: modus == "object" ? (objMode == "individual") : nil,
|
||||||
bulk: modus == "object" ? (objMode == "bulk") : nil,
|
bulk: modus == "object" ? (objMode == "bulk") : nil,
|
||||||
fieldValues: eigeneFeldwerte()
|
fieldValues: eigeneFeldwerte(),
|
||||||
|
secondaryBase: zweitVollstaendig ? zweitBasis : nil,
|
||||||
|
secondaryCount: zweitVollstaendig
|
||||||
|
? Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) : nil,
|
||||||
|
secondaryAmount: zweitVollstaendig
|
||||||
|
? Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) : nil
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
// Vorgemerktes Foto hochladen – ein Fehler darf das Anlegen nicht
|
// Vorgemerktes Foto hochladen – ein Fehler darf das Anlegen nicht
|
||||||
|
|||||||
@@ -99,14 +99,25 @@ struct HomeView: View {
|
|||||||
.tabItem { Label("Verwaltung", systemImage: "gearshape") }
|
.tabItem { Label("Verwaltung", systemImage: "gearshape") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.tint(Color.marke)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
/// Markenblau (wie App-Icon und Web-Akzent) statt des System-Blaus.
|
||||||
|
static let marke = Color(UIColor { trait in
|
||||||
|
trait.userInterfaceStyle == .dark
|
||||||
|
? UIColor(red: 0.486, green: 0.541, blue: 0.910, alpha: 1) // #7C8AE8
|
||||||
|
: UIColor(red: 0.247, green: 0.318, blue: 0.710, alpha: 1) // #3F51B5
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Die beiden Kacheln, die frueher die Startseite waren.
|
/// Die beiden Kacheln, die frueher die Startseite waren.
|
||||||
struct ScanTabView: View {
|
struct ScanTabView: View {
|
||||||
@EnvironmentObject private var router: Router
|
@EnvironmentObject private var router: Router
|
||||||
@EnvironmentObject private var session: Session
|
@EnvironmentObject private var session: Session
|
||||||
@State private var showNew = false
|
@State private var showNew = false
|
||||||
|
@State private var showKassenzettel = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -132,6 +143,13 @@ struct ScanTabView: View {
|
|||||||
subtitle: "Barcode/QR scannen und nur ansehen",
|
subtitle: "Barcode/QR scannen und nur ansehen",
|
||||||
systemImage: "doc.text.magnifyingglass")
|
systemImage: "doc.text.magnifyingglass")
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
showKassenzettel = true
|
||||||
|
} label: {
|
||||||
|
ActionTile(title: "Kassenzettel",
|
||||||
|
subtitle: "Bon fotografieren und Lebensmittel einlagern",
|
||||||
|
systemImage: "doc.text.viewfinder")
|
||||||
|
}
|
||||||
// Ohne Barcode: gerade bei Gegenstaenden (Kleidung, Werkzeug) gibt
|
// Ohne Barcode: gerade bei Gegenstaenden (Kleidung, Werkzeug) gibt
|
||||||
// es keinen Code zum Scannen. Deshalb hier ein offensichtlicher Weg.
|
// es keinen Code zum Scannen. Deshalb hier ein offensichtlicher Weg.
|
||||||
if session.isAdmin {
|
if session.isAdmin {
|
||||||
@@ -152,8 +170,10 @@ struct ScanTabView: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
.padding()
|
.padding()
|
||||||
.navigationTitle("Scannen")
|
.navigationTitle("Scannen")
|
||||||
|
.fullScreenCover(isPresented: $showKassenzettel) { KassenzettelView() }
|
||||||
.sheet(isPresented: $showNew) {
|
.sheet(isPresented: $showNew) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ProductFormView(prefillBarcode: nil, groupId: nil) { _ in
|
ProductFormView(prefillBarcode: nil, groupId: nil) { _ in
|
||||||
@@ -215,6 +235,7 @@ struct ListsTabView: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
.padding()
|
.padding()
|
||||||
.navigationTitle("Listen")
|
.navigationTitle("Listen")
|
||||||
}
|
}
|
||||||
@@ -232,7 +253,7 @@ struct CompactTile: View {
|
|||||||
Image(systemName: systemImage)
|
Image(systemName: systemImage)
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
.background(Color.accentColor.opacity(0.15))
|
.background(Color.marke.opacity(0.15))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
Text(title).font(.headline)
|
Text(title).font(.headline)
|
||||||
}
|
}
|
||||||
@@ -254,7 +275,7 @@ struct ActionTile: View {
|
|||||||
Image(systemName: systemImage)
|
Image(systemName: systemImage)
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
.background(Color.accentColor.opacity(0.15))
|
.background(Color.marke.opacity(0.15))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(title).font(.headline)
|
Text(title).font(.headline)
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ struct TorchButton: View {
|
|||||||
isOn.toggle()
|
isOn.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: isOn ? "bolt.fill" : "bolt.slash.fill")
|
Image(systemName: isOn ? "bolt.fill" : "bolt.slash.fill")
|
||||||
.foregroundStyle(locked ? Color.yellow : Color.accentColor)
|
.foregroundStyle(locked ? Color.yellow : Color.marke)
|
||||||
}
|
}
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
LongPressGesture(minimumDuration: 0.4).onEnded { _ in
|
LongPressGesture(minimumDuration: 0.4).onEnded { _ in
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ struct ServerListView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: NotificationStore.config(for: profile.id).enabled
|
Image(systemName: NotificationStore.config(for: profile.id).enabled
|
||||||
? "bell.fill" : "bell")
|
? "bell.fill" : "bell")
|
||||||
.foregroundStyle(Color.accentColor)
|
.foregroundStyle(Color.marke)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
}
|
}
|
||||||
@@ -138,7 +138,7 @@ struct ServerListView: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if profile.id == session.activeProfileID {
|
if profile.id == session.activeProfileID {
|
||||||
Image(systemName: "checkmark").foregroundStyle(Color.accentColor)
|
Image(systemName: "checkmark").foregroundStyle(Color.marke)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
|
|||||||
@@ -30,17 +30,31 @@ final class Router: ObservableObject {
|
|||||||
route = .expiring
|
route = .expiring
|
||||||
}
|
}
|
||||||
|
|
||||||
/// vorrania://checkin bzw. vorrania://checkout – oder ein Universal Link
|
/// vorrania://checkin | checkout … (Aktionen) oder vorrania://i/<UID>
|
||||||
/// (https://…/i/<UID>), der das Einzelstück direkt öffnet.
|
/// (Einzelstück – aus einem gescannten QR, auch über die normale Kamera-App).
|
||||||
|
///
|
||||||
|
/// Früher lief das Einzelstück über einen Universal Link auf eine feste Domain.
|
||||||
|
/// Das war für einen selbstgehosteten Dienst untauglich (jede Instanz hat eine
|
||||||
|
/// andere URL, und die Domain stünde im offenen Quellcode). Das Custom-Scheme
|
||||||
|
/// braucht keine Domain: Die Kamera-App öffnet den QR trotzdem, und der Code
|
||||||
|
/// verrät keinen Server.
|
||||||
func handle(url: URL) {
|
func handle(url: URL) {
|
||||||
if url.scheme == "vorrania" {
|
if url.scheme == "vorrania" {
|
||||||
let target = (url.host ?? url.path.replacingOccurrences(of: "/", with: "")).lowercased()
|
// Host + Pfad zu Bestandteilen ohne Trenner: ["i","ABC123"] bzw. ["checkin"].
|
||||||
if let route = Route(rawValue: target) { self.route = route }
|
let comps = ([url.host].compactMap { $0 } + url.pathComponents)
|
||||||
|
.filter { $0 != "/" && !$0.isEmpty }
|
||||||
|
// Einzelstück: vorrania://i/<UID> – am aktuell aktiven Server öffnen.
|
||||||
|
if let i = comps.firstIndex(where: { $0.lowercased() == "i" }), i + 1 < comps.count {
|
||||||
|
openItemUid = comps[i + 1].uppercased()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let route = comps.first.flatMap({ Route(rawValue: $0.lowercased()) }) { self.route = route }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let parts = url.pathComponents // z.B. ["/", "i", "ABC123"]
|
// Alt-Fall: Universal Link (…/i/<UID>) – nur noch relevant, falls je wieder
|
||||||
|
// eine Associated Domain hinterlegt würde (dann Serverwechsel per Host).
|
||||||
|
let parts = url.pathComponents
|
||||||
if let idx = parts.firstIndex(of: "i"), idx + 1 < parts.count {
|
if let idx = parts.firstIndex(of: "i"), idx + 1 < parts.count {
|
||||||
// Auf den Server aus dem Link wechseln, damit die UID dort gesucht wird.
|
|
||||||
Session.shared.switchToProfile(matching: url)
|
Session.shared.switchToProfile(matching: url)
|
||||||
openItemUid = parts[idx + 1].uppercased()
|
openItemUid = parts[idx + 1].uppercased()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; };
|
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; };
|
||||||
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */; };
|
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */; };
|
||||||
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; };
|
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; };
|
||||||
|
34787DA16A1AB035BF129BB7 /* GroupViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED41226394E7F31F37EC3B8 /* GroupViews.swift */; };
|
||||||
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */; };
|
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */; };
|
||||||
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
|
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
|
||||||
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; };
|
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; };
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */; };
|
5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */; };
|
||||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
|
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
|
||||||
728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */; };
|
728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */; };
|
||||||
|
749139BEFF35F9DAFFE87C3B /* ItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00288DFA409BAD753AA4CA5D /* ItemListView.swift */; };
|
||||||
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; };
|
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; };
|
||||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
|
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
|
||||||
7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; };
|
7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; };
|
||||||
@@ -46,9 +48,11 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
00288DFA409BAD753AA4CA5D /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
|
||||||
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = "<group>"; };
|
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = "<group>"; };
|
||||||
090485CC54558EB4433376A9 /* DashboardCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCards.swift; sourceTree = "<group>"; };
|
090485CC54558EB4433376A9 /* DashboardCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCards.swift; sourceTree = "<group>"; };
|
||||||
099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
|
||||||
|
0ED41226394E7F31F37EC3B8 /* GroupViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupViews.swift; sourceTree = "<group>"; };
|
||||||
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = "<group>"; };
|
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = "<group>"; };
|
||||||
163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScheduler.swift; sourceTree = "<group>"; };
|
163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScheduler.swift; sourceTree = "<group>"; };
|
||||||
1847E65D41ACEDD01CD108BC /* ServerFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFetch.swift; sourceTree = "<group>"; };
|
1847E65D41ACEDD01CD108BC /* ServerFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFetch.swift; sourceTree = "<group>"; };
|
||||||
@@ -103,7 +107,9 @@
|
|||||||
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */,
|
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */,
|
||||||
717C8EB336170526F5F3E695 /* DateScanView.swift */,
|
717C8EB336170526F5F3E695 /* DateScanView.swift */,
|
||||||
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
|
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
|
||||||
|
0ED41226394E7F31F37EC3B8 /* GroupViews.swift */,
|
||||||
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
|
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
|
||||||
|
00288DFA409BAD753AA4CA5D /* ItemListView.swift */,
|
||||||
69B59B9F69BC30451D45A3C7 /* ItemViews.swift */,
|
69B59B9F69BC30451D45A3C7 /* ItemViews.swift */,
|
||||||
A6785CD9174067EFBB2B9043 /* ListViews.swift */,
|
A6785CD9174067EFBB2B9043 /* ListViews.swift */,
|
||||||
C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */,
|
C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */,
|
||||||
@@ -231,7 +237,9 @@
|
|||||||
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */,
|
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */,
|
||||||
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
|
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
|
||||||
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
|
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
|
||||||
|
34787DA16A1AB035BF129BB7 /* GroupViews.swift in Sources */,
|
||||||
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
|
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
|
||||||
|
749139BEFF35F9DAFFE87C3B /* ItemListView.swift in Sources */,
|
||||||
56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */,
|
56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */,
|
||||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
|
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
|
||||||
82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */,
|
82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */,
|
||||||
|
|||||||
@@ -17,9 +17,21 @@ server {
|
|||||||
return 200 '{"applinks":{"details":[{"appIDs":["PP34X97WS3.com.scarriffle.vorrania"],"components":[{"/":"/i/*"}]}]}}';
|
return 200 '{"applinks":{"details":[{"appIDs":["PP34X97WS3.com.scarriffle.vorrania"],"components":[{"/":"/i/*"}]}]}}';
|
||||||
}
|
}
|
||||||
|
|
||||||
# SPA-Routing: unbekannte Pfade auf index.html mappen
|
# Gehashte Assets (index-<hash>.js/.css, Bilder, Schriften): der Dateiname
|
||||||
|
# aendert sich bei jedem Build, wenn sich der Inhalt aendert -> unveraenderlich,
|
||||||
|
# deshalb aggressiv cachen.
|
||||||
|
location ~* \.(?:js|css|woff2?|ttf|eot|png|jpe?g|gif|svg|ico|webp)$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA-Routing: unbekannte Pfade auf index.html mappen. index.html selbst darf
|
||||||
|
# NICHT gecacht werden - sonst laedt der Browser nach einem Deploy weiter die
|
||||||
|
# alte Seite mit den alten Asset-Verweisen, und Aenderungen kommen nie an.
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
}
|
}
|
||||||
|
|
||||||
# API an das Backend weiterleiten (Service-Name "backend" aus docker-compose)
|
# API an das Backend weiterleiten (Service-Name "backend" aus docker-compose)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import { useAuth } from "./auth";
|
import { useAuth } from "./auth";
|
||||||
import Icon from "./components/Icon";
|
import Icon from "./components/Icon";
|
||||||
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import BrandMark from "./components/BrandMark";
|
import BrandMark from "./components/BrandMark";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
@@ -115,9 +116,32 @@ function Sidebar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Server nicht erreichbar – die Anmeldung bleibt bestehen, nur der Abruf nicht. */
|
||||||
|
function StartFehler({ text, erneut }) {
|
||||||
|
return (
|
||||||
|
<div className="center" style={{ padding: 60 }}>
|
||||||
|
<div className="alert error" style={{ maxWidth: 520 }}>
|
||||||
|
<Icon name="alert" size={16} />
|
||||||
|
<div>
|
||||||
|
<div>{text}</div>
|
||||||
|
<div className="muted small" style={{ marginTop: 4 }}>
|
||||||
|
Deine Anmeldung ist noch gültig – sobald der Server wieder antwortet,
|
||||||
|
geht es weiter.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn primary" style={{ marginTop: 16 }} onClick={erneut}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Protected({ children, adminOnly = false, wide = false }) {
|
function Protected({ children, adminOnly = false, wide = false }) {
|
||||||
const { user, isAdmin, loading } = useAuth();
|
const { user, isAdmin, loading, startFehler, erneutVersuchen } = useAuth();
|
||||||
|
const { pathname: pfad } = useLocation();
|
||||||
if (loading) return <div className="center muted" style={{ padding: 60 }}>Lädt…</div>;
|
if (loading) return <div className="center muted" style={{ padding: 60 }}>Lädt…</div>;
|
||||||
|
if (startFehler) return <StartFehler text={startFehler} erneut={erneutVersuchen} />;
|
||||||
if (!user) return <Navigate to="/login" replace />;
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
|
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
|
||||||
return (
|
return (
|
||||||
@@ -125,7 +149,12 @@ function Protected({ children, adminOnly = false, wide = false }) {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="main">
|
<div className="main">
|
||||||
{/* `wide` hebt die Lesebreite auf – nur fürs Dashboard-Raster sinnvoll. */}
|
{/* `wide` hebt die Lesebreite auf – nur fürs Dashboard-Raster sinnvoll. */}
|
||||||
<div className={wide ? "content content--wide" : "content"}>{children}</div>
|
<div className={wide ? "content content--wide" : "content"}>
|
||||||
|
{/* Je Seite eine eigene Grenze: stuerzt eine ab, bleibt wenigstens
|
||||||
|
die Navigation bedienbar. `key` setzt sie beim Seitenwechsel
|
||||||
|
zurueck, sonst haenge man nach einem Fehler dauerhaft fest. */}
|
||||||
|
<ErrorBoundary key={pfad} bereich="Diese Seite">{children}</ErrorBoundary>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export function setToken(token) {
|
|||||||
else localStorage.removeItem(TOKEN_KEY);
|
else localStorage.removeItem(TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wird bei einer abgelaufenen/ungueltigen Anmeldung gerufen (HTTP 401).
|
||||||
|
// Ohne das bleibt die Oberflaeche „angemeldet", waehrend jede Abfrage scheitert –
|
||||||
|
// man sieht dann leere Seiten statt eines Hinweises.
|
||||||
|
let beiAbmeldung = null;
|
||||||
|
export function setUnauthorizedHandler(fn) {
|
||||||
|
beiAbmeldung = fn;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(message, status) {
|
constructor(message, status) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -41,6 +49,12 @@ async function request(path, { method = "GET", body, form, formData } = {}) {
|
|||||||
|
|
||||||
const resp = await fetch(`${API_BASE}${path}`, { method, headers, body: payload });
|
const resp = await fetch(`${API_BASE}${path}`, { method, headers, body: payload });
|
||||||
|
|
||||||
|
// Abgelaufene Anmeldung: EINMAL zentral behandeln statt in jeder Seite.
|
||||||
|
// Beim Anmelden selbst nicht – dort heisst 401 schlicht „Passwort falsch".
|
||||||
|
if (resp.status === 401 && path !== "/auth/login") {
|
||||||
|
beiAbmeldung?.();
|
||||||
|
}
|
||||||
|
|
||||||
if (resp.status === 204) return null;
|
if (resp.status === 204) return null;
|
||||||
|
|
||||||
let data = null;
|
let data = null;
|
||||||
@@ -229,7 +243,7 @@ export const api = {
|
|||||||
request(`/dashboard/layout/enforced?value=${value ? "true" : "false"}`, { method: "PUT" }),
|
request(`/dashboard/layout/enforced?value=${value ? "true" : "false"}`, { method: "PUT" }),
|
||||||
dashboardStats: () => request("/dashboard/stats"),
|
dashboardStats: () => request("/dashboard/stats"),
|
||||||
dashboardExpirySplit: () => request("/dashboard/expiry-split"),
|
dashboardExpirySplit: () => request("/dashboard/expiry-split"),
|
||||||
dashboardByCategory: () => request("/dashboard/by-category"),
|
dashboardByCategory: (depth) => request(`/dashboard/by-category${depth ? `?depth=${depth}` : ""}`),
|
||||||
dashboardTimeline: (days, productId) =>
|
dashboardTimeline: (days, productId) =>
|
||||||
request(`/dashboard/timeline?days=${days}${productId ? `&product_id=${productId}` : ""}`),
|
request(`/dashboard/timeline?days=${days}${productId ? `&product_id=${productId}` : ""}`),
|
||||||
dashboardActivity: (days) => request(`/dashboard/activity?days=${days}`),
|
dashboardActivity: (days) => request(`/dashboard/activity?days=${days}`),
|
||||||
|
|||||||
@@ -1,24 +1,56 @@
|
|||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { api, getToken, setToken } from "./api";
|
import { api, ApiError, getToken, setToken, setUnauthorizedHandler } from "./api";
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
// Anmeldung war da, ist aber abgelaufen – der Login-Bildschirm sagt warum.
|
||||||
|
const [abgelaufen, setAbgelaufen] = useState(false);
|
||||||
|
// Start fehlgeschlagen, ABER nicht wegen der Anmeldung (Server weg, Netz weg).
|
||||||
|
// Dann bleibt das Token liegen; alles andere hiesse, ein kurzer Aussetzer
|
||||||
|
// meldet einen ab.
|
||||||
|
const [startFehler, setStartFehler] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
async function pruefen() {
|
||||||
async function boot() {
|
setStartFehler(null);
|
||||||
if (getToken()) {
|
if (!getToken()) {
|
||||||
try {
|
setLoading(false);
|
||||||
setUser(await api.me());
|
return;
|
||||||
} catch {
|
}
|
||||||
setToken(null);
|
try {
|
||||||
}
|
setUser(await api.me());
|
||||||
|
} catch (err) {
|
||||||
|
// NUR bei abgelehnter Anmeldung abmelden. Ein 502 waehrend eines
|
||||||
|
// Server-Neustarts ist kein Grund, die Sitzung wegzuwerfen.
|
||||||
|
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
} else if (err instanceof ApiError) {
|
||||||
|
setStartFehler(err.message);
|
||||||
|
} else {
|
||||||
|
// Kein HTTP-Fehler, sondern gar keine Antwort (Netz weg, Server aus).
|
||||||
|
// Die Browser-Meldung („Failed to fetch") hilft niemandem.
|
||||||
|
setStartFehler("Server nicht erreichbar.");
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
boot();
|
}
|
||||||
|
|
||||||
|
useEffect(() => { pruefen(); }, []);
|
||||||
|
|
||||||
|
// Laeuft die Anmeldung waehrend der Nutzung ab, scheitert ab da JEDE Abfrage.
|
||||||
|
// Ohne diesen Griff bliebe die Oberflaeche „angemeldet" und zeigte nur noch
|
||||||
|
// leere Seiten – genau das war der Fehler.
|
||||||
|
useEffect(() => {
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
setAbgelaufen(true);
|
||||||
|
});
|
||||||
|
return () => setUnauthorizedHandler(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function login(username, password) {
|
async function login(username, password) {
|
||||||
@@ -26,18 +58,29 @@ export function AuthProvider({ children }) {
|
|||||||
setToken(res.access_token);
|
setToken(res.access_token);
|
||||||
const me = await api.me();
|
const me = await api.me();
|
||||||
setUser(me);
|
setUser(me);
|
||||||
|
setAbgelaufen(false);
|
||||||
|
setStartFehler(null);
|
||||||
return me;
|
return me;
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setAbgelaufen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function erneutVersuchen() {
|
||||||
|
setLoading(true);
|
||||||
|
await pruefen();
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = user?.role === "admin";
|
const isAdmin = user?.role === "admin";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, isAdmin, loading, login, logout }}>
|
<AuthContext.Provider value={{
|
||||||
|
user, isAdmin, loading, login, logout,
|
||||||
|
abgelaufen, startFehler, erneutVersuchen,
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
29
web/src/components/BewegungIcon.jsx
Normal file
29
web/src/components/BewegungIcon.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Art einer Bestandsbewegung als Sinnbild statt als Wort.
|
||||||
|
*
|
||||||
|
* Grosser Pfeil auf eine Linie zu (Einlagern), von ihr weg (Auslagern), Stift
|
||||||
|
* (Korrektur) – dieselben Zeichen wie auf den Ein-/Auslagern-Knoepfen.
|
||||||
|
*
|
||||||
|
* Vorher standen hier zwei gefuellte Kisten, die sich NUR in einem kleinen
|
||||||
|
* Pfeil unterschieden. Bei 18 px war das nicht zu trennen; erkannt hat man sie
|
||||||
|
* allein an der Farbe – und die trug mit Gruen/Orange eine Wertung, die es
|
||||||
|
* nicht geben soll. Auslagern ist kein Missstand.
|
||||||
|
*
|
||||||
|
* Deshalb: eine Farbe fuer alle drei, die Richtung liegt im Zeichen selbst.
|
||||||
|
*
|
||||||
|
* Titel und aria-label behalten das Wort: fuer Screenreader und fuer alle, die
|
||||||
|
* ein Sinnbild nicht auf Anhieb lesen.
|
||||||
|
*/
|
||||||
|
const LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
|
||||||
|
const ICON = { in: "checkin", out: "checkout", adjust: "edit" };
|
||||||
|
|
||||||
|
export default function BewegungIcon({ typ, size = 19 }) {
|
||||||
|
const wort = LABEL[typ] || typ;
|
||||||
|
return (
|
||||||
|
<span className="bewegung-icon" title={wort} aria-label={wort} role="img">
|
||||||
|
<Icon name={ICON[typ] || "package"} size={size} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
import { Skeleton } from "./Skeleton";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
|
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
|
||||||
@@ -39,6 +40,22 @@ export default function DataTable({
|
|||||||
const rest = columns.map((c) => c.key).filter((k) => !saved.includes(k));
|
const rest = columns.map((c) => c.key).filter((k) => !saved.includes(k));
|
||||||
return [...saved, ...rest];
|
return [...saved, ...rest];
|
||||||
});
|
});
|
||||||
|
// `order` ist eine Momentaufnahme vom Mounten. Seiten duerfen ihre Spalten
|
||||||
|
// aber danach aendern (die Artikelliste zeigt „Verwaltung" nur bei
|
||||||
|
// Gegenstaenden), und eine neu hinzugefuegte Spalte steht ohnehin nie in der
|
||||||
|
// gespeicherten Reihenfolge. Fehlt sie dort, faellt sie aus allOrdered heraus
|
||||||
|
// und ist WEG – nicht nur aus dem Spalten-Menue, sondern aus der Tabelle.
|
||||||
|
// Deshalb hier nachziehen. Angehaengt, nicht einsortiert: eine selbst
|
||||||
|
// gewaehlte Reihenfolge soll nicht umspringen.
|
||||||
|
const schluessel = columns.map((c) => c.key).join("|");
|
||||||
|
useEffect(() => {
|
||||||
|
setOrder((alt) => {
|
||||||
|
const fehlend = columns.map((c) => c.key).filter((k) => !alt.includes(k));
|
||||||
|
return fehlend.length ? [...alt, ...fehlend] : alt;
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [schluessel]);
|
||||||
|
|
||||||
const [widths, setWidths] = useState(() => loadPersisted(id).widths || {});
|
const [widths, setWidths] = useState(() => loadPersisted(id).widths || {});
|
||||||
// Filter + Sortierung bleiben erhalten (localStorage je Tabelle), bis man
|
// Filter + Sortierung bleiben erhalten (localStorage je Tabelle), bis man
|
||||||
// „Filter löschen" drückt oder anders sortiert – auch nach Navigieren/Neuladen.
|
// „Filter löschen" drückt oder anders sortiert – auch nach Navigieren/Neuladen.
|
||||||
@@ -97,7 +114,14 @@ export default function DataTable({
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (chooserOpen && chooserBtn.current) {
|
if (chooserOpen && chooserBtn.current) {
|
||||||
const r = chooserBtn.current.getBoundingClientRect();
|
const r = chooserBtn.current.getBoundingClientRect();
|
||||||
setChooserPos({ left: Math.min(r.left, window.innerWidth - 240), top: r.bottom + 4 });
|
// Hoehe an den freien Platz koppeln. Eine feste Obergrenze schnitt die
|
||||||
|
// letzten Eintraege ab, ohne dass man es sah – auf macOS blendet das
|
||||||
|
// System die Bildlaufleiste aus, und dann fehlt die Spalte scheinbar.
|
||||||
|
setChooserPos({
|
||||||
|
left: Math.min(r.left, window.innerWidth - 240),
|
||||||
|
top: r.bottom + 4,
|
||||||
|
maxHeight: Math.max(180, window.innerHeight - r.bottom - 24),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [chooserOpen]);
|
}, [chooserOpen]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -315,7 +339,7 @@ export default function DataTable({
|
|||||||
</div>
|
</div>
|
||||||
{chooserOpen && chooserPos && createPortal(
|
{chooserOpen && chooserPos && createPortal(
|
||||||
<div ref={chooserPop} className="dt-pop" style={{ left: chooserPos.left, top: chooserPos.top }}>
|
<div ref={chooserPop} className="dt-pop" style={{ left: chooserPos.left, top: chooserPos.top }}>
|
||||||
<div className="dt-pop-list">
|
<div className="dt-pop-list" style={{ maxHeight: chooserPos.maxHeight }}>
|
||||||
{allOrdered.map((c) => (
|
{allOrdered.map((c) => (
|
||||||
<label key={c.key} className="dt-pop-opt">
|
<label key={c.key} className="dt-pop-opt">
|
||||||
<input type="checkbox" checked={!hidden.has(c.key)} onChange={() => toggleHidden(c.key)} />
|
<input type="checkbox" checked={!hidden.has(c.key)} onChange={() => toggleHidden(c.key)} />
|
||||||
@@ -394,11 +418,30 @@ export default function DataTable({
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{displayRows.length === 0 && (
|
{displayRows.length === 0 && (
|
||||||
// Während des Ladens NICHT „nichts gefunden" behaupten – die Leer-
|
loading && rows.length === 0 ? (
|
||||||
// Meldung kommt erst, wenn die Daten wirklich da (und leer) sind.
|
// Erstladung: schimmernde Platzhalterzeilen in Spaltenform statt
|
||||||
<tr><td className="empty" colSpan={orderedCols.length}>
|
// eines nackten „Wird geladen…". (Beim Nachladen mit bereits
|
||||||
{loading ? "Wird geladen…" : empty}
|
// vorhandenen Zeilen greift dieser Zweig nicht - die Daten
|
||||||
</td></tr>
|
// bleiben stehen.)
|
||||||
|
Array.from({ length: 8 }, (_, r) => (
|
||||||
|
<tr key={`skel-${r}`} className="skel-tr" aria-hidden="true">
|
||||||
|
{orderedCols.map((c, ci) => (
|
||||||
|
<td key={c.key} className={c.align === "num" ? "num" : ""}>
|
||||||
|
<Skeleton
|
||||||
|
w={c.align === "num" ? "40%" : `${[80, 64, 72, 58, 86][(r + ci) % 5]}%`}
|
||||||
|
style={c.align === "num" ? { marginLeft: "auto" } : undefined}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
// Während des Ladens NICHT „nichts gefunden" behaupten – die Leer-
|
||||||
|
// Meldung kommt erst, wenn die Daten wirklich da (und leer) sind.
|
||||||
|
<tr><td className="empty" colSpan={orderedCols.length}>
|
||||||
|
{loading ? "Wird geladen…" : empty}
|
||||||
|
</td></tr>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useToast } from "../toast";
|
|||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import { REASONS } from "./ObjektBestand";
|
import { REASONS } from "./ObjektBestand";
|
||||||
import { locationOptions, locationPathById } from "../locationPath";
|
import { locationOptions, locationPathById } from "../locationPath";
|
||||||
|
import { itemDeepLink } from "../qr";
|
||||||
|
|
||||||
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
|
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
|
||||||
|
|
||||||
@@ -224,7 +225,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
|
|
||||||
async function druckeEtiketten() {
|
async function druckeEtiketten() {
|
||||||
const labels = await Promise.all(items.map(async (it) => {
|
const labels = await Promise.all(items.map(async (it) => {
|
||||||
const url = await QRCode.toDataURL(`${origin}/i/${it.uid}`, { margin: 1, width: 260 });
|
const url = await QRCode.toDataURL(itemDeepLink(it.uid), { margin: 1, width: 260 });
|
||||||
const name = (product.name || "").replace(/[<>&]/g, "");
|
const name = (product.name || "").replace(/[<>&]/g, "");
|
||||||
return `<div class="label"><img src="${url}"/><div class="uid">${it.uid}</div><div class="name">${name}</div></div>`;
|
return `<div class="label"><img src="${url}"/><div class="uid">${it.uid}</div><div class="name">${name}</div></div>`;
|
||||||
}));
|
}));
|
||||||
@@ -270,7 +271,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<Fragment key={it.id}>
|
<Fragment key={it.id}>
|
||||||
<tr className="einzel-mainrow">
|
<tr className="einzel-mainrow">
|
||||||
<td><QrImg text={`${origin}/i/${it.uid}`} size={56} /></td>
|
<td><QrImg text={itemDeepLink(it.uid)} size={56} /></td>
|
||||||
<td data-label="UID" className="strong nowrap">{it.uid}</td>
|
<td data-label="UID" className="strong nowrap">{it.uid}</td>
|
||||||
<td data-label="Lagerort">
|
<td data-label="Lagerort">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
|
|||||||
74
web/src/components/ErrorBoundary.jsx
Normal file
74
web/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Component } from "react";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fängt Fehler beim Rendern ab.
|
||||||
|
*
|
||||||
|
* Ohne so eine Grenze hängt React den kompletten Baum aus, sobald irgendwo eine
|
||||||
|
* Ausnahme fliegt – die Seite wird schlagartig weiß und nur ein Neuladen hilft.
|
||||||
|
* Man sieht dann NICHT, was passiert ist, und kann es folglich auch nicht
|
||||||
|
* melden. Genau das war hier das Problem.
|
||||||
|
*
|
||||||
|
* Die Grenze zeigt deshalb die Meldung selbst an, nicht nur ein „Ups". Wer sie
|
||||||
|
* einmal sieht, kann sie weitergeben, und dann ist die Ursache in einer Minute
|
||||||
|
* gefunden statt in einer Stunde.
|
||||||
|
*
|
||||||
|
* `bereich` beschreibt, was gekapselt ist – für die Meldung.
|
||||||
|
*/
|
||||||
|
export default class ErrorBoundary extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { fehler: null, info: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(fehler) {
|
||||||
|
return { fehler };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(fehler, info) {
|
||||||
|
// React schreibt selbst schon in die Konsole; der Komponentenpfad hilft
|
||||||
|
// zusätzlich beim Einordnen.
|
||||||
|
console.error("Fehler beim Rendern:", fehler, info?.componentStack);
|
||||||
|
this.setState({ info });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { fehler, info } = this.state;
|
||||||
|
if (!fehler) return this.props.children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "var(--sp-4)" }}>
|
||||||
|
<div className="alert error" style={{ alignItems: "flex-start" }}>
|
||||||
|
<Icon name="alert" size={16} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="strong">
|
||||||
|
{this.props.bereich ? `${this.props.bereich} konnte nicht angezeigt werden.` : "Etwas ist schiefgelaufen."}
|
||||||
|
</div>
|
||||||
|
<div className="small" style={{ marginTop: 4, wordBreak: "break-word" }}>
|
||||||
|
{fehler.message || String(fehler)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-inline" style={{ marginTop: "var(--sp-3)" }}>
|
||||||
|
<button className="btn primary" onClick={() => this.setState({ fehler: null, info: null })}>
|
||||||
|
Nochmal versuchen
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={() => window.location.reload()}>
|
||||||
|
Seite neu laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aufgeklappt nur auf Wunsch: die Angaben sind für einen Fehlerbericht
|
||||||
|
gedacht, nicht für den Alltag. */}
|
||||||
|
<details style={{ marginTop: "var(--sp-3)" }}>
|
||||||
|
<summary className="muted small">Technische Angaben</summary>
|
||||||
|
<pre className="muted small" style={{ whiteSpace: "pre-wrap", overflowX: "auto" }}>
|
||||||
|
{fehler.stack || String(fehler)}
|
||||||
|
{info?.componentStack || ""}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
web/src/components/GroupParentSelect.jsx
Normal file
152
web/src/components/GroupParentSelect.jsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import { gruppenOptionen, nachfahrenIds, pfadText } from "../groupGraph";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mehrfachauswahl der Obergruppen einer Gruppe.
|
||||||
|
*
|
||||||
|
* Bewusst KEIN Baum wie CategorySelect: eine Gruppe darf unter mehreren
|
||||||
|
* Obergruppen hängen („Grillwurst" unter „Wurst" UND unter „Grillgut"). Ein
|
||||||
|
* Baum müsste sie mehrfach anzeigen; eine flache, alphabetische Liste mit
|
||||||
|
* Häkchen ist ehrlicher und deutlich weniger Code. Der volle Weg steht als
|
||||||
|
* Titel an jeder Zeile, damit gleichnamige Äste unterscheidbar bleiben.
|
||||||
|
*
|
||||||
|
* Die eigene Gruppe und ihre Untergruppen sind gesperrt – sie würden einen Ring
|
||||||
|
* erzeugen. Der Server lehnt das ohnehin ab; hier wird es vorher sichtbar.
|
||||||
|
*
|
||||||
|
* Das Menü hängt per Portal am <body>, damit es der horizontal scrollende
|
||||||
|
* Tabellenrahmen nicht abschneidet (wie bei CategorySelect).
|
||||||
|
*/
|
||||||
|
export default function GroupParentSelect({
|
||||||
|
value = [],
|
||||||
|
onChange,
|
||||||
|
groups = [],
|
||||||
|
selfId = null,
|
||||||
|
disabled = false,
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pos, setPos] = useState(null);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const btnRef = useRef(null);
|
||||||
|
const popRef = useRef(null);
|
||||||
|
|
||||||
|
const gewaehlt = new Set(value || []);
|
||||||
|
const gesperrt = selfId == null ? new Set() : new Set([selfId, ...nachfahrenIds(groups, selfId)]);
|
||||||
|
const nameById = Object.fromEntries(groups.map((g) => [g.id, g.name]));
|
||||||
|
|
||||||
|
// Als Baum, damit die Struktur beim Zuordnen sichtbar ist. Beim Suchen
|
||||||
|
// flacht die Liste ab – eine Einrückung ohne ihre Eltern wäre irreführend.
|
||||||
|
const sichtbar = filter
|
||||||
|
? groups
|
||||||
|
.filter((g) => g.name.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, "de"))
|
||||||
|
.map((g) => ({ id: g.id, key: `f${g.id}`, gruppe: g, tiefe: 0, label: g.name }))
|
||||||
|
: gruppenOptionen(groups);
|
||||||
|
|
||||||
|
const label = value?.length
|
||||||
|
? value.map((id) => nameById[id]).filter(Boolean).join(", ")
|
||||||
|
: "– keine –";
|
||||||
|
|
||||||
|
function place() {
|
||||||
|
const r = btnRef.current?.getBoundingClientRect();
|
||||||
|
if (r) setPos({ left: r.left, top: r.bottom + 4, width: r.width });
|
||||||
|
}
|
||||||
|
useLayoutEffect(() => { if (open) place(); }, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onDoc(e) {
|
||||||
|
if (btnRef.current?.contains(e.target)) return;
|
||||||
|
if (popRef.current?.contains(e.target)) return;
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
function onScroll(e) {
|
||||||
|
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
function onResize() { setOpen(false); }
|
||||||
|
function onKey(e) { if (e.key === "Escape") setOpen(false); }
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
window.addEventListener("scroll", onScroll, true);
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDoc);
|
||||||
|
window.removeEventListener("scroll", onScroll, true);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Bei Mehrfachauswahl bleibt das Menü offen – sonst müsste man es für jede
|
||||||
|
// weitere Obergruppe neu aufklappen.
|
||||||
|
function umschalten(id) {
|
||||||
|
const neu = new Set(gewaehlt);
|
||||||
|
if (neu.has(id)) neu.delete(id);
|
||||||
|
else neu.add(id);
|
||||||
|
onChange([...neu]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={btnRef}
|
||||||
|
className="tree-select-btn"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
<span className={value?.length ? "" : "muted"}>{label}</span>
|
||||||
|
<Icon name="chevronRight" size={13} className={`caret ${open ? "open" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && pos && createPortal(
|
||||||
|
<div
|
||||||
|
ref={popRef}
|
||||||
|
className="tree-pop"
|
||||||
|
style={{ left: pos.left, top: pos.top, minWidth: Math.max(pos.width, 240) }}
|
||||||
|
>
|
||||||
|
{groups.length > 8 && (
|
||||||
|
<div className="tree-pop-line">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Suchen…"
|
||||||
|
value={filter}
|
||||||
|
style={{ marginTop: 0, width: "100%" }}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sichtbar.map((o) => {
|
||||||
|
const aus = gesperrt.has(o.id);
|
||||||
|
const an = gewaehlt.has(o.id);
|
||||||
|
return (
|
||||||
|
<div key={o.key} className="tree-pop-line" style={{ paddingLeft: o.tiefe * 14 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tree-pop-opt ${an ? "sel" : ""}`}
|
||||||
|
disabled={aus}
|
||||||
|
title={aus
|
||||||
|
? "Würde einen Ring erzeugen (die Gruppe selbst oder eine ihrer Untergruppen)"
|
||||||
|
: pfadText(groups, o.id)}
|
||||||
|
onClick={() => umschalten(o.id)}
|
||||||
|
>
|
||||||
|
<span style={{ display: "inline-block", width: 18 }}>{an ? "✓" : ""}</span>
|
||||||
|
{o.tiefe > 0 && <span className="muted">↳ </span>}
|
||||||
|
{o.gruppe.name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{sichtbar.length === 0 && (
|
||||||
|
<div className="tree-pop-line muted" style={{ padding: 6 }}>Keine Gruppe gefunden.</div>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ const PATHS = {
|
|||||||
<rect x="3" y="16" width="7" height="5" rx="1" />
|
<rect x="3" y="16" width="7" height="5" rx="1" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
// Ein- und Auslagern sind ein PAAR und duerfen sich nur in EINEM Merkmal
|
||||||
|
// unterscheiden: der Pfeilrichtung. Die Linie ist das Lager und liegt bei
|
||||||
|
// beiden unten. Frueher lag sie beim Auslagern oben und der Pfeil zeigte auf
|
||||||
|
// sie zu – das las sich wie „hinein, nur nach oben".
|
||||||
checkin: (
|
checkin: (
|
||||||
<>
|
<>
|
||||||
<path d="M12 3v12" />
|
<path d="M12 3v12" />
|
||||||
@@ -25,9 +29,9 @@ const PATHS = {
|
|||||||
),
|
),
|
||||||
checkout: (
|
checkout: (
|
||||||
<>
|
<>
|
||||||
<path d="M12 21V9" />
|
<path d="M12 3v12" />
|
||||||
<path d="m7 14 5-5 5 5" />
|
<path d="m7 8 5-5 5 5" />
|
||||||
<path d="M5 3h14" />
|
<path d="M5 21h14" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
package: (
|
package: (
|
||||||
@@ -142,6 +146,7 @@ const PATHS = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default function Icon({ name, size = 18, className = "", strokeWidth = 2 }) {
|
export default function Icon({ name, size = 18, className = "", strokeWidth = 2 }) {
|
||||||
const path = PATHS[name];
|
const path = PATHS[name];
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
|
|||||||
@@ -2,20 +2,36 @@ import { useState } from "react";
|
|||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import { locationOptions } from "../locationPath";
|
import { locationOptions } from "../locationPath";
|
||||||
|
|
||||||
|
// „Überall" ist ein Ort wie jeder andere – nur eben der oberste. Im Datenmodell
|
||||||
|
// ist er location_id = null; im <select> braucht es einen Wert, deshalb hier ein
|
||||||
|
// Platzhalter, der beim Speichern wieder zu null wird.
|
||||||
|
const UEBERALL = "__ueberall__";
|
||||||
|
const zuId = (wert) => (wert === UEBERALL ? null : wert);
|
||||||
|
const vonId = (id) => (id == null ? UEBERALL : String(id));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editor für Mindestbestände je Lagerort (Bedarfe). Eine Zeile je Ort mit Menge;
|
* Editor für Mindestbestände je Lagerort. Eine Zeile je Ort mit Menge;
|
||||||
* „Bedarfe speichern" ersetzt über onSave die komplette Liste. Menge 0/leer =
|
* „Bedarfe speichern" ersetzt über onSave die komplette Liste. Menge 0/leer =
|
||||||
* Ort fällt weg.
|
* Ort fällt weg.
|
||||||
|
*
|
||||||
|
* Den früheren separaten Gesamt-Mindestbestand gibt es nicht mehr – er ist die
|
||||||
|
* Zeile „Überall (egal wo)" und wird hier genauso gepflegt wie jeder Lagerort.
|
||||||
|
* Mengen in Basiseinheiten (g/ml/Stück).
|
||||||
*/
|
*/
|
||||||
export default function LocationMinStock({ locations, initial = [], unitLabel = "", onSave, onError }) {
|
export default function LocationMinStock({ locations, initial = [], unitLabel = "", onSave, onError }) {
|
||||||
const [rows, setRows] = useState(() =>
|
const [rows, setRows] = useState(() =>
|
||||||
initial.map((e) => ({ location_id: String(e.location_id), min_stock: String(e.min_stock) })),
|
initial.map((e) => ({ location_id: vonId(e.location_id), min_stock: String(e.min_stock) })),
|
||||||
);
|
);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [ok, setOk] = useState(false);
|
const [ok, setOk] = useState(false);
|
||||||
|
|
||||||
const used = new Set(rows.map((r) => r.location_id));
|
const used = new Set(rows.map((r) => r.location_id));
|
||||||
const frei = locations.filter((l) => !used.has(String(l.id)));
|
// „Überall" steht ganz oben und ist wie ein Ort nur einmal vergebbar.
|
||||||
|
const alleOrte = [
|
||||||
|
{ id: UEBERALL, label: "Überall (egal wo)" },
|
||||||
|
...locationOptions(locations),
|
||||||
|
];
|
||||||
|
const frei = alleOrte.filter((o) => !used.has(String(o.id)));
|
||||||
|
|
||||||
function addRow() {
|
function addRow() {
|
||||||
if (!frei.length) return;
|
if (!frei.length) return;
|
||||||
@@ -36,7 +52,7 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
|
|||||||
try {
|
try {
|
||||||
const list = rows
|
const list = rows
|
||||||
.filter((r) => r.location_id !== "" && Number(r.min_stock) > 0)
|
.filter((r) => r.location_id !== "" && Number(r.min_stock) > 0)
|
||||||
.map((r) => ({ location_id: r.location_id, min_stock: Number(r.min_stock) }));
|
.map((r) => ({ location_id: zuId(r.location_id), min_stock: Number(r.min_stock) }));
|
||||||
await onSave(list);
|
await onSave(list);
|
||||||
setOk(true);
|
setOk(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -46,20 +62,16 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!locations.length) {
|
|
||||||
return <p className="muted small mt-0">Erst Lagerorte anlegen, dann Bedarfe je Ort möglich.</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{rows.length === 0 && (
|
{rows.length === 0 && (
|
||||||
<p className="muted small mt-0">Kein Bedarf je Lagerort festgelegt.</p>
|
<p className="muted small mt-0">Kein Mindestbestand festgelegt.</p>
|
||||||
)}
|
)}
|
||||||
{rows.map((r, i) => (
|
{rows.map((r, i) => (
|
||||||
<div className="field-inline" key={i} style={{ marginBottom: 0 }}>
|
<div className="field-inline" key={i} style={{ marginBottom: 0 }}>
|
||||||
<label className="grow" style={{ flex: "1 1 auto", minWidth: 0, margin: 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 })}>
|
<select value={r.location_id} onChange={(e) => setRow(i, { location_id: e.target.value })}>
|
||||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
{alleOrte.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ margin: 0, width: 130 }}>
|
<label style={{ margin: 0, width: 130 }}>
|
||||||
@@ -74,7 +86,7 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
|
|||||||
))}
|
))}
|
||||||
<div className="field-inline" style={{ marginBottom: 0 }}>
|
<div className="field-inline" style={{ marginBottom: 0 }}>
|
||||||
<button type="button" className="btn" onClick={addRow} disabled={!frei.length}>
|
<button type="button" className="btn" onClick={addRow} disabled={!frei.length}>
|
||||||
<Icon name="plus" size={16} />Lagerort
|
<Icon name="plus" size={16} />Mindestbestand
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn primary" onClick={save} disabled={busy}>
|
<button type="button" className="btn primary" onClick={save} disabled={busy}>
|
||||||
<Icon name="check" size={16} />{busy ? "Speichern…" : "Bedarfe speichern"}
|
<Icon name="check" size={16} />{busy ? "Speichern…" : "Bedarfe speichern"}
|
||||||
|
|||||||
24
web/src/components/ShoppingNeedText.jsx
Normal file
24
web/src/components/ShoppingNeedText.jsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { fmt, gebinde } from "../units";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einheitliche Bedarfszeile der Einkaufsliste: die Gebinde-Leitangabe groß
|
||||||
|
* ("3 Gläser"), die Basiseinheit klein daneben ("(500 g)"), dann Bestand/min in
|
||||||
|
* derselben Leitangabe. Die Rechnung (Aufrunden auf ganze Packungen,
|
||||||
|
* Pluralisierung des Leitworts) kommt fertig vom Server als `need`.
|
||||||
|
*/
|
||||||
|
export default function ShoppingNeedText({ need, stock, minStock, unitName }) {
|
||||||
|
// Fallback für den (Übergangs-)Fall ohne `need` vom Server: altes Format.
|
||||||
|
if (!need) {
|
||||||
|
return (
|
||||||
|
<>fehlt <strong>{fmt(stock == null ? 0 : minStock - stock)} {unitName}</strong></>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const einheit = need.singular ? ` ${gebinde(need.min_stock, need.singular)}` : "";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
fehlt <strong>{need.text}</strong>
|
||||||
|
{need.hint ? <> <span className="muted">({need.hint})</span></> : null}{" "}
|
||||||
|
(Bestand {fmt(need.stock)} / min {fmt(need.min_stock)}{einheit})
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
web/src/components/Skeleton.jsx
Normal file
61
web/src/components/Skeleton.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Schimmernde Ladeplatzhalter ("Skeleton"). Nur CSS-Tokens (--surface-2 /
|
||||||
|
* --border), passt sich daher automatisch an Hell/Dunkel an. Reine Deko, deshalb
|
||||||
|
* durchgehend aria-hidden – Screenreader sollen keine leeren Balken vorlesen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Eine einzelne schimmernde Leiste. Breite/Höhe als px oder CSS-Wert ("60%"). */
|
||||||
|
export function Skeleton({ w, h = 14, radius, className = "", style }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`skeleton ${className}`.trim()}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ width: w, height: h, borderRadius: radius, ...style }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mehrere gestapelte Text-Leisten unterschiedlicher Breite. */
|
||||||
|
export function SkeletonLines({ lines = 3 }) {
|
||||||
|
const breiten = ["90%", "72%", "82%", "60%", "76%"];
|
||||||
|
return (
|
||||||
|
<span className="skel-stack" aria-hidden="true">
|
||||||
|
{Array.from({ length: lines }, (_, i) => (
|
||||||
|
<Skeleton key={i} w={breiten[i % breiten.length]} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kartenkörper-Skelett fürs Dashboard, grob in der Form des späteren Inhalts:
|
||||||
|
* - "kpi" – Label-Zeile + große Wertzahl
|
||||||
|
* - "liste" – ein paar Zeilen mit je zwei Werten (Standard)
|
||||||
|
* - "chart" – ein großer Block, füllt die Karte
|
||||||
|
*/
|
||||||
|
export function CardSkeleton({ variant = "liste" }) {
|
||||||
|
if (variant === "kpi") {
|
||||||
|
return (
|
||||||
|
<span className="skel-stack" aria-hidden="true">
|
||||||
|
<Skeleton w="45%" h={12} />
|
||||||
|
<Skeleton w="60%" h={26} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (variant === "chart") {
|
||||||
|
// Höhe bewusst null -> keine Inline-Höhe, damit .skel-chart (flex:1) den
|
||||||
|
// Kartenkörper ausfüllt.
|
||||||
|
return <Skeleton className="skel-chart" w="100%" h={null} />;
|
||||||
|
}
|
||||||
|
// "liste"
|
||||||
|
return (
|
||||||
|
<span className="skel-stack" aria-hidden="true">
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<span className="skel-row" key={i}>
|
||||||
|
<Skeleton w={`${72 - i * 4}%`} />
|
||||||
|
<Skeleton w="18%" />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
web/src/dashboard/KartenConfig.jsx
Normal file
93
web/src/dashboard/KartenConfig.jsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import Icon from "../components/Icon";
|
||||||
|
import { ZEITRAEUME } from "./cards";
|
||||||
|
import { locationOptions } from "../locationPath";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konfig-Dialog einer Karte – wie in HomeAssistant: ein eigener Dialog mit den
|
||||||
|
* Optionen der Karte, statt Dropdowns im Kartenkopf. Welche Felder erscheinen,
|
||||||
|
* bestimmt `karte.config`. Die Werte landen in den Karten-Props (bzw. `tage`
|
||||||
|
* beim Zeitraum) und werden mit dem Layout gespeichert.
|
||||||
|
*/
|
||||||
|
export default function KartenConfig({
|
||||||
|
karte, props: karteProps = {}, tage,
|
||||||
|
produkte = [], locations = [], onProps, onTage, onClose,
|
||||||
|
}) {
|
||||||
|
const felder = karte?.config || [];
|
||||||
|
|
||||||
|
function feld(f) {
|
||||||
|
if (f.type === "product") {
|
||||||
|
return (
|
||||||
|
<label key={f.key}>
|
||||||
|
{f.label}
|
||||||
|
<select value={karteProps[f.key] ?? ""}
|
||||||
|
onChange={(e) => onProps({ [f.key]: e.target.value ? Number(e.target.value) : undefined })}>
|
||||||
|
<option value="">– Artikel wählen –</option>
|
||||||
|
{produkte.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (f.type === "location") {
|
||||||
|
return (
|
||||||
|
<label key={f.key}>
|
||||||
|
{f.label}{f.hint ? <span className="muted small"> ({f.hint})</span> : null}
|
||||||
|
<select value={karteProps[f.key] ?? ""}
|
||||||
|
onChange={(e) => onProps({ [f.key]: e.target.value || undefined })}>
|
||||||
|
<option value="">– alle Orte –</option>
|
||||||
|
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (f.type === "zeitraum") {
|
||||||
|
return (
|
||||||
|
<label key="zeitraum">
|
||||||
|
{f.label}
|
||||||
|
<select value={tage ?? karte.zeitraum} onChange={(e) => onTage(Number(e.target.value))}>
|
||||||
|
{ZEITRAEUME.map((z) => <option key={z.tage} value={z.tage}>{z.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (f.type === "select") {
|
||||||
|
return (
|
||||||
|
<label key={f.key}>
|
||||||
|
{f.label}{f.hint ? <span className="muted small"> ({f.hint})</span> : null}
|
||||||
|
<select value={karteProps[f.key] ?? f.default ?? ""}
|
||||||
|
onChange={(e) => onProps({ [f.key]: e.target.value })}>
|
||||||
|
{(f.optionen || []).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (f.type === "boolean") {
|
||||||
|
return (
|
||||||
|
<label key={f.key} className="check-inline">
|
||||||
|
<input type="checkbox" checked={karteProps[f.key] ?? f.default ?? false}
|
||||||
|
onChange={(e) => onProps({ [f.key]: e.target.checked })} />
|
||||||
|
<span>{f.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal" role="dialog" aria-modal="true">
|
||||||
|
<div className="modal-head">
|
||||||
|
<Icon name="settings" size={18} />
|
||||||
|
<h2>{karte?.titel} einstellen</h2>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ display: "grid", gap: "var(--sp-3)" }}>
|
||||||
|
{felder.length === 0
|
||||||
|
? <p className="muted">Diese Karte hat keine Einstellungen.</p>
|
||||||
|
: felder.map(feld)}
|
||||||
|
</div>
|
||||||
|
<div className="btn-pair">
|
||||||
|
<button type="button" className="btn primary" onClick={onClose}>Fertig</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ import { useEffect, useState } from "react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import { CardSkeleton } from "../components/Skeleton";
|
||||||
|
import ShoppingNeedText from "../components/ShoppingNeedText";
|
||||||
|
import BewegungIcon from "../components/BewegungIcon";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import {
|
import {
|
||||||
amountText, articlePrimary, articleSecondary, fmt, relativeExpiry,
|
articlePrimary, articleSecondary, fmt, relativeExpiry,
|
||||||
} from "../units";
|
} from "../units";
|
||||||
import Bars from "./charts/Bars";
|
import Bars from "./charts/Bars";
|
||||||
import Donut from "./charts/Donut";
|
import Donut from "./charts/Donut";
|
||||||
@@ -35,9 +38,9 @@ function useDaten(laden, abhaengig = []) {
|
|||||||
return { daten, fehler };
|
return { daten, fehler };
|
||||||
}
|
}
|
||||||
|
|
||||||
function Zustand({ fehler, daten, children, leer = "Keine Daten." }) {
|
function Zustand({ fehler, daten, children, leer = "Keine Daten.", skelett = "liste" }) {
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
if (daten == null) return <div className="empty">Lädt…</div>;
|
if (daten == null) return <CardSkeleton variant={skelett} />;
|
||||||
if (Array.isArray(daten) && daten.length === 0) return <div className="empty">{leer}</div>;
|
if (Array.isArray(daten) && daten.length === 0) return <div className="empty">{leer}</div>;
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
@@ -183,7 +186,7 @@ function KarteArtikel({ props: karteProps }) {
|
|||||||
return <div className="empty">Kein Artikel gewählt – über „Anordnen“ einstellen.</div>;
|
return <div className="empty">Kein Artikel gewählt – über „Anordnen“ einstellen.</div>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Zustand fehler={fehler} daten={daten}>
|
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||||||
{daten && (
|
{daten && (
|
||||||
<div className="card-stack">
|
<div className="card-stack">
|
||||||
<div className="kpi">
|
<div className="kpi">
|
||||||
@@ -219,7 +222,7 @@ function Kennzahl({ wert, label, icon, ton }) {
|
|||||||
function KarteStatus() {
|
function KarteStatus() {
|
||||||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||||
return (
|
return (
|
||||||
<Zustand fehler={fehler} daten={daten}>
|
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||||||
<div className="kpi-row">
|
<div className="kpi-row">
|
||||||
<Kennzahl icon="clock" label="Bald ablaufend" wert={daten?.expiring_soon ?? 0}
|
<Kennzahl icon="clock" label="Bald ablaufend" wert={daten?.expiring_soon ?? 0}
|
||||||
ton={daten?.expiring_soon ? "warn" : ""} />
|
ton={daten?.expiring_soon ? "warn" : ""} />
|
||||||
@@ -234,7 +237,7 @@ function KarteStatus() {
|
|||||||
function KarteArtikelzahl() {
|
function KarteArtikelzahl() {
|
||||||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||||
return (
|
return (
|
||||||
<Zustand fehler={fehler} daten={daten}>
|
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||||||
<Kennzahl icon="package" label="Artikel mit Bestand" wert={daten?.products_in_stock ?? 0} />
|
<Kennzahl icon="package" label="Artikel mit Bestand" wert={daten?.products_in_stock ?? 0} />
|
||||||
</Zustand>
|
</Zustand>
|
||||||
);
|
);
|
||||||
@@ -243,7 +246,7 @@ function KarteArtikelzahl() {
|
|||||||
function KarteEinheiten() {
|
function KarteEinheiten() {
|
||||||
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
const { daten, fehler } = useDaten(() => api.dashboardStats());
|
||||||
return (
|
return (
|
||||||
<Zustand fehler={fehler} daten={daten}>
|
<Zustand fehler={fehler} daten={daten} skelett="kpi">
|
||||||
<Kennzahl icon="box" label="Artikeleinheiten im Lager" wert={fmt(daten?.article_units ?? 0)} />
|
<Kennzahl icon="box" label="Artikeleinheiten im Lager" wert={fmt(daten?.article_units ?? 0)} />
|
||||||
</Zustand>
|
</Zustand>
|
||||||
);
|
);
|
||||||
@@ -306,90 +309,126 @@ function KarteAblauf({ modus = "alle" }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function KarteEinkaufsliste() {
|
/**
|
||||||
|
* Einkaufsliste mit Gesamt-Bedarf UND Bedarf je Lagerort. Über die Karten-Konfig
|
||||||
|
* lässt sich ein Lagerort festlegen: leer = alle Orte, sonst nur dieser.
|
||||||
|
*/
|
||||||
|
function KarteEinkaufsliste({ props: karteProps }) {
|
||||||
|
const ortId = karteProps?.location_id || "";
|
||||||
|
const [erledigt, setErledigt] = useState({}); // abgehakte Zeilen (nur in dieser Sitzung)
|
||||||
const { daten, fehler } = useDaten(async () => {
|
const { daten, fehler } = useDaten(async () => {
|
||||||
const [produkte, gruppen] = await Promise.all([api.shoppingList(), api.groupShoppingList()]);
|
const [produkte, gruppen, orte] = await Promise.all([
|
||||||
return { produkte, gruppen };
|
api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation(),
|
||||||
|
]);
|
||||||
|
return { produkte, gruppen, orte };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
if (!daten) return <div className="empty">Lädt…</div>;
|
if (!daten) return <CardSkeleton variant="liste" />;
|
||||||
if (!daten.produkte.length && !daten.gruppen.length) {
|
|
||||||
return <div className="empty">Alle Mindestbestände erreicht.</div>;
|
const orte = ortId ? daten.orte.filter((o) => o.location_id === ortId) : daten.orte;
|
||||||
}
|
const gesamtLeer = !daten.produkte.length && !daten.gruppen.length;
|
||||||
|
const orteLeer = orte.every((o) => !o.products.length && !o.groups.length);
|
||||||
|
if (gesamtLeer && orteLeer) return <div className="empty">Alle Mindestbestände erreicht.</div>;
|
||||||
|
|
||||||
|
const abhaken = (key, val) => setErledigt((e) => ({ ...e, [key]: val }));
|
||||||
|
|
||||||
|
// Ein Abschnitt (Gesamt oder ein Ort) als abhakbare Liste – wie die volle Seite.
|
||||||
|
// `prefix` macht die Schlüssel über die Abschnitte hinweg eindeutig. Die
|
||||||
|
// Bedarfszeile kommt vom Server fertig aufbereitet (`need`) und ist für
|
||||||
|
// Produkte wie Gruppen, gesamt wie je Ort identisch aufgebaut.
|
||||||
|
const abschnitt = (gruppen, produkte, prefix) => (
|
||||||
|
<ul className="checklist">
|
||||||
|
{gruppen.map((it) => {
|
||||||
|
const key = `${prefix}g${it.group_id}`;
|
||||||
|
return (
|
||||||
|
<li key={key} className={erledigt[key] ? "done" : ""}>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={!!erledigt[key]} onChange={(e) => abhaken(key, e.target.checked)} />
|
||||||
|
<span className="badge accent">Gruppe</span>
|
||||||
|
{it.subgroup_count > 0 && (
|
||||||
|
<span className="badge" title="Untergruppen sind mitgezählt.">+{it.subgroup_count}</span>
|
||||||
|
)}
|
||||||
|
<span className="item-name">{it.name}</span>
|
||||||
|
</label>
|
||||||
|
<span className="muted small">
|
||||||
|
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_name} />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{produkte.map((it) => {
|
||||||
|
const key = `${prefix}p${it.product_id}`;
|
||||||
|
return (
|
||||||
|
<li key={key} className={erledigt[key] ? "done" : ""}>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={!!erledigt[key]} onChange={(e) => abhaken(key, e.target.checked)} />
|
||||||
|
<span className="item-name">{it.name}</span>
|
||||||
|
</label>
|
||||||
|
<span className="muted small">
|
||||||
|
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_label} />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="table-wrap">
|
<div className="card-stack">
|
||||||
<table className="table">
|
{!gesamtLeer && (
|
||||||
<thead>
|
<div>
|
||||||
<tr><th>Bedarf</th><th className="num">Bestand</th><th className="num">Fehlt</th></tr>
|
<div className="muted small" style={{ fontWeight: 600 }}>Überall</div>
|
||||||
</thead>
|
{abschnitt(daten.gruppen, daten.produkte, "")}
|
||||||
<tbody>
|
</div>
|
||||||
{daten.gruppen.map((it) => (
|
)}
|
||||||
<tr key={`g${it.group_id}`}>
|
{orte.map((o) => ((o.products.length || o.groups.length) ? (
|
||||||
<td data-label="Bedarf"><span className="badge accent">Gruppe</span> {it.name}</td>
|
<div key={o.location_id}>
|
||||||
<td data-label="Bestand" className="num">{fmt(it.stock)} {it.unit_name}</td>
|
<div className="muted small" style={{ fontWeight: 600 }}>
|
||||||
<td data-label="Fehlt" className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
|
<Icon name="location" size={13} /> {o.location_name}
|
||||||
</tr>
|
</div>
|
||||||
))}
|
{abschnitt(o.groups, o.products, `l${o.location_id}`)}
|
||||||
{daten.produkte.map((it) => (
|
</div>
|
||||||
<tr key={`p${it.product_id}`}>
|
) : null))}
|
||||||
<td data-label="Bedarf">{it.name}</td>
|
|
||||||
<td data-label="Bestand" className="num">{amountText(it.stock, it.package_size, it.base_unit)}</td>
|
|
||||||
<td data-label="Fehlt" className="num strong">{amountText(it.deficit, it.package_size, it.base_unit)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const BEWEGUNG_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
|
|
||||||
|
|
||||||
function KarteBewegungen() {
|
function KarteBewegungen() {
|
||||||
const { daten, fehler } = useDaten(() => api.listMovements(20));
|
const { daten, fehler } = useDaten(() => api.listMovements(20));
|
||||||
return (
|
return (
|
||||||
<Zustand fehler={fehler} daten={daten} leer="Noch keine Bewegungen.">
|
<Zustand fehler={fehler} daten={daten} leer="Noch keine Bewegungen.">
|
||||||
<div className="table-wrap">
|
{/* Kachel links, Zeitpunkt ueber Produkt. Die Bewegungsart trug frueher
|
||||||
<table className="table">
|
eine eigene Spalte mit dem Wort „Ausgelagert" – als Sinnbild kostet
|
||||||
<thead><tr><th>Zeitpunkt</th><th>Aktion</th><th>Produkt</th></tr></thead>
|
sie keine Zeile mehr und bleibt auf einen Blick erkennbar. */}
|
||||||
<tbody>
|
<div className="bewegung-liste">
|
||||||
{(daten || []).map((m) => (
|
{(daten || []).map((m) => (
|
||||||
<tr key={m.id}>
|
<div className="bewegung-zeile" key={m.id}>
|
||||||
<td data-label="Zeitpunkt" className="muted">
|
<BewegungIcon typ={m.type} />
|
||||||
{new Date(m.created_at).toLocaleString("de-DE", {
|
<div className="text">
|
||||||
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
<div className="zeit">
|
||||||
})}
|
{new Date(m.created_at).toLocaleString("de-DE", {
|
||||||
</td>
|
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
||||||
<td data-label="Aktion">
|
})}
|
||||||
<span className={`badge ${m.type === "in" ? "ok" : m.type === "out" ? "warn" : ""}`}>
|
</div>
|
||||||
{BEWEGUNG_LABEL[m.type] || m.type}
|
<div className="produkt">{m.product_name}</div>
|
||||||
</span>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td data-label="Produkt">{m.product_name}</td>
|
))}
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</Zustand>
|
</Zustand>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Diagramme
|
// ---------------------------------------------------------------- Diagramme
|
||||||
function KarteAblaufRing() {
|
function KarteAblaufRing({ props: karteProps }) {
|
||||||
const { daten, fehler } = useDaten(() => api.dashboardExpirySplit());
|
const { daten, fehler } = useDaten(() => api.dashboardExpirySplit());
|
||||||
// Ob „Ohne MHD" (Gegenstände/undatierte Chargen) mitgezählt wird – gemerkt.
|
// Ob „Ohne MHD" (Gegenstände/undatierte Chargen) mitgezählt wird – Karten-Einstellung.
|
||||||
const [mitOhneMhd, setMitOhneMhd] = useState(
|
const mitOhneMhd = karteProps?.mit_ohne_mhd ?? true;
|
||||||
() => localStorage.getItem("ablauf_mit_ohne_mhd") !== "0",
|
|
||||||
);
|
|
||||||
function umschalten(on) {
|
|
||||||
setMitOhneMhd(on);
|
|
||||||
localStorage.setItem("ablauf_mit_ohne_mhd", on ? "1" : "0");
|
|
||||||
}
|
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
if (!daten) return <div className="empty">Lädt…</div>;
|
if (!daten) return <CardSkeleton variant="chart" />;
|
||||||
|
|
||||||
// Zustandsfarben, nie Serienfarben – und immer mit Beschriftung.
|
// Zustandsfarben, nie Serienfarben – und immer mit Beschriftung.
|
||||||
const segmente = [
|
const segmente = [
|
||||||
@@ -400,26 +439,21 @@ function KarteAblaufRing() {
|
|||||||
if (mitOhneMhd) {
|
if (mitOhneMhd) {
|
||||||
segmente.push({ name: "Ohne MHD", value: daten.no_date, color: tokenFarbe("--muted", "#6b7480") });
|
segmente.push({ name: "Ohne MHD", value: daten.no_date, color: tokenFarbe("--muted", "#6b7480") });
|
||||||
}
|
}
|
||||||
return (
|
return <Donut segmente={segmente} gesamtLabel="Einheiten" />;
|
||||||
<div className="ring-mit-schalter">
|
|
||||||
<label className="check-inline ring-schalter">
|
|
||||||
<input type="checkbox" checked={mitOhneMhd}
|
|
||||||
onChange={(e) => umschalten(e.target.checked)} />
|
|
||||||
<span>„Ohne MHD" einbeziehen</span>
|
|
||||||
</label>
|
|
||||||
<Donut segmente={segmente} gesamtLabel="Einheiten" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function KarteKategorienRing() {
|
function KarteKategorienRing({ props: karteProps }) {
|
||||||
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
|
// Tiefe: bis zu welcher Kategorie-Stufe zusammengefasst wird (leer = feinste).
|
||||||
|
const tiefe = karteProps?.tiefe ? Number(karteProps.tiefe) : undefined;
|
||||||
|
const { daten, fehler } = useDaten(() => api.dashboardByCategory(tiefe), [tiefe]);
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
if (!daten) return <div className="empty">Lädt…</div>;
|
if (!daten) return <CardSkeleton variant="chart" />;
|
||||||
|
|
||||||
|
// Detailansicht: jede Kategorie einzeln statt der Bündelung zu „Andere“.
|
||||||
const segmente = mitFarben(
|
const segmente = mitFarben(
|
||||||
daten.map((k) => ({ name: k.name, value: k.article_units })),
|
daten.map((k) => ({ name: k.name, value: k.article_units })),
|
||||||
istDunkel(),
|
istDunkel(),
|
||||||
|
{ alle: !!karteProps?.detail },
|
||||||
);
|
);
|
||||||
return <Donut segmente={segmente} gesamtLabel="Einheiten" />;
|
return <Donut segmente={segmente} gesamtLabel="Einheiten" />;
|
||||||
}
|
}
|
||||||
@@ -427,7 +461,7 @@ function KarteKategorienRing() {
|
|||||||
function KarteKategorienSaeulen() {
|
function KarteKategorienSaeulen() {
|
||||||
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
|
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
if (!daten) return <div className="empty">Lädt…</div>;
|
if (!daten) return <CardSkeleton variant="chart" />;
|
||||||
|
|
||||||
const zeilen = daten.slice(0, 8).map((k) => ({
|
const zeilen = daten.slice(0, 8).map((k) => ({
|
||||||
name: k.name,
|
name: k.name,
|
||||||
@@ -474,7 +508,7 @@ function KarteBestandsverlauf({ tage = 90 }) {
|
|||||||
const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]);
|
const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]);
|
||||||
|
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
if (!daten) return <div className="empty">Lädt…</div>;
|
if (!daten) return <CardSkeleton variant="chart" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Line
|
<Line
|
||||||
@@ -533,7 +567,7 @@ function KarteBewegungsverlauf({ tage = 30 }) {
|
|||||||
const { daten, fehler } = useDaten(() => api.dashboardFlow(tage), [tage]);
|
const { daten, fehler } = useDaten(() => api.dashboardFlow(tage), [tage]);
|
||||||
|
|
||||||
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
if (!daten) return <div className="empty">Lädt…</div>;
|
if (!daten) return <CardSkeleton variant="chart" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wasserfall
|
<Wasserfall
|
||||||
@@ -575,7 +609,7 @@ export const KARTEN = {
|
|||||||
titel: "Artikel im Blick", icon: "package", vorschau: "kennzahl",
|
titel: "Artikel im Blick", icon: "package", vorschau: "kennzahl",
|
||||||
beschreibung: "Bestand eines festen Artikels – mehrfach verwendbar",
|
beschreibung: "Bestand eines festen Artikels – mehrfach verwendbar",
|
||||||
standard: { w: 3, h: 3 }, min: { w: 2, h: 3 },
|
standard: { w: 3, h: 3 }, min: { w: 2, h: 3 },
|
||||||
artikel: true,
|
config: [{ key: "product_id", type: "product", label: "Artikel" }],
|
||||||
komponente: KarteArtikel,
|
komponente: KarteArtikel,
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
@@ -618,6 +652,7 @@ export const KARTEN = {
|
|||||||
titel: "Einkaufsliste", icon: "cart", vorschau: "tabelle",
|
titel: "Einkaufsliste", icon: "cart", vorschau: "tabelle",
|
||||||
beschreibung: "Produkte und Gruppen unter Mindestbestand",
|
beschreibung: "Produkte und Gruppen unter Mindestbestand",
|
||||||
standard: { w: 6, h: 6 }, min: { w: 4, h: 4 },
|
standard: { w: 6, h: 6 }, min: { w: 4, h: 4 },
|
||||||
|
config: [{ key: "location_id", type: "location", label: "Lagerort", hint: "leer = alle Orte" }],
|
||||||
komponente: KarteEinkaufsliste,
|
komponente: KarteEinkaufsliste,
|
||||||
},
|
},
|
||||||
movements: {
|
movements: {
|
||||||
@@ -630,12 +665,24 @@ export const KARTEN = {
|
|||||||
titel: "Ablauf-Anteile", icon: "clock", vorschau: "ring",
|
titel: "Ablauf-Anteile", icon: "clock", vorschau: "ring",
|
||||||
beschreibung: "Ring: in Ordnung, bald ablaufend, abgelaufen",
|
beschreibung: "Ring: in Ordnung, bald ablaufend, abgelaufen",
|
||||||
standard: { w: 4, h: 6 }, min: { w: 3, h: 5 },
|
standard: { w: 4, h: 6 }, min: { w: 3, h: 5 },
|
||||||
|
config: [{ key: "mit_ohne_mhd", type: "boolean", label: "„Ohne MHD“ einbeziehen", default: true }],
|
||||||
komponente: KarteAblaufRing,
|
komponente: KarteAblaufRing,
|
||||||
},
|
},
|
||||||
"category-donut": {
|
"category-donut": {
|
||||||
titel: "Kategorien-Anteile", icon: "tag", vorschau: "ring",
|
titel: "Kategorien-Anteile", icon: "tag", vorschau: "ring",
|
||||||
beschreibung: "Ring: welche Kategorie wie viel ausmacht",
|
beschreibung: "Ring: welche Kategorie wie viel ausmacht",
|
||||||
standard: { w: 4, h: 6 }, min: { w: 3, h: 5 },
|
standard: { w: 4, h: 6 }, min: { w: 3, h: 5 },
|
||||||
|
config: [
|
||||||
|
{ key: "tiefe", type: "select", label: "Kategorie-Tiefe",
|
||||||
|
hint: "wie weit Unterkategorien zusammengefasst werden", default: "",
|
||||||
|
optionen: [
|
||||||
|
{ value: "", label: "Feinste (jede Kategorie einzeln)" },
|
||||||
|
{ value: "1", label: "Nur oberste Stufe" },
|
||||||
|
{ value: "2", label: "Bis 2. Stufe" },
|
||||||
|
{ value: "3", label: "Bis 3. Stufe" },
|
||||||
|
] },
|
||||||
|
{ key: "detail", type: "boolean", label: "Detailansicht (alle Kategorien einzeln, keine Gruppe „Andere“)" },
|
||||||
|
],
|
||||||
komponente: KarteKategorienRing,
|
komponente: KarteKategorienRing,
|
||||||
},
|
},
|
||||||
"category-bars": {
|
"category-bars": {
|
||||||
@@ -648,19 +695,24 @@ export const KARTEN = {
|
|||||||
titel: "Bestandsverlauf", icon: "history", vorschau: "linie",
|
titel: "Bestandsverlauf", icon: "history", vorschau: "linie",
|
||||||
beschreibung: "Artikeleinheiten im Lager über 90 Tage",
|
beschreibung: "Artikeleinheiten im Lager über 90 Tage",
|
||||||
zeitraum: 90, standard: { w: 8, h: 6 }, min: { w: 4, h: 5 },
|
zeitraum: 90, standard: { w: 8, h: 6 }, min: { w: 4, h: 5 },
|
||||||
|
config: [{ type: "zeitraum", label: "Zeitraum" }],
|
||||||
komponente: KarteBestandsverlauf,
|
komponente: KarteBestandsverlauf,
|
||||||
},
|
},
|
||||||
"product-timeline": {
|
"product-timeline": {
|
||||||
titel: "Verlauf eines Artikels", icon: "package", vorschau: "linie",
|
titel: "Verlauf eines Artikels", icon: "package", vorschau: "linie",
|
||||||
beschreibung: "Ein fester Artikel über die Zeit – mehrfach verwendbar",
|
beschreibung: "Ein fester Artikel über die Zeit – mehrfach verwendbar",
|
||||||
zeitraum: 90, standard: { w: 8, h: 7 }, min: { w: 4, h: 6 },
|
zeitraum: 90, standard: { w: 8, h: 7 }, min: { w: 4, h: 6 },
|
||||||
artikel: true,
|
config: [
|
||||||
|
{ key: "product_id", type: "product", label: "Artikel" },
|
||||||
|
{ type: "zeitraum", label: "Zeitraum" },
|
||||||
|
],
|
||||||
komponente: KarteArtikelverlauf,
|
komponente: KarteArtikelverlauf,
|
||||||
},
|
},
|
||||||
"activity-timeline": {
|
"activity-timeline": {
|
||||||
titel: "Ein- und Auslagerungen", icon: "history", vorschau: "wasserfall",
|
titel: "Ein- und Auslagerungen", icon: "history", vorschau: "wasserfall",
|
||||||
beschreibung: "Brücke: Bestandslinie mit grünen und roten Balken je Bewegung",
|
beschreibung: "Brücke: Bestandslinie mit grünen und roten Balken je Bewegung",
|
||||||
zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 5, h: 5 },
|
zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 5, h: 5 },
|
||||||
|
config: [{ type: "zeitraum", label: "Zeitraum" }],
|
||||||
komponente: KarteBewegungsverlauf,
|
komponente: KarteBewegungsverlauf,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,19 +65,22 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", einheit =
|
|||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<ul className="chart-legend">
|
{/* Eigener Scroll-Bereich: nur die Legende scrollt, der Ring bleibt stehen. */}
|
||||||
{boegen.map((b) => (
|
<div className="chart-legend-scroll">
|
||||||
<li key={b.name}
|
<ul className="chart-legend">
|
||||||
onMouseEnter={() => setAktiv(b)}
|
{boegen.map((b) => (
|
||||||
onMouseLeave={() => setAktiv(null)}>
|
<li key={b.name}
|
||||||
<span className="swatch" style={{ background: b.color }} />
|
onMouseEnter={() => setAktiv(b)}
|
||||||
<span className="legend-name">{b.name}</span>
|
onMouseLeave={() => setAktiv(null)}>
|
||||||
<span className="legend-value">
|
<span className="swatch" style={{ background: b.color }} />
|
||||||
{fmt(b.value)} · {Math.round(b.anteil * 100)} %
|
<span className="legend-name">{b.name}</span>
|
||||||
</span>
|
<span className="legend-value">
|
||||||
</li>
|
{fmt(b.value)} · {Math.round(b.anteil * 100)} %
|
||||||
))}
|
</span>
|
||||||
</ul>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,13 +58,36 @@ export function andereFarbe(dunkel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bündelt eine sortierte Liste auf SERIES_MAX Einträge plus „Andere“ und
|
* Zusätzliche Farben für die Detailansicht (mehr Kategorien als die geprüfte
|
||||||
* vergibt die Farben in fester Reihenfolge.
|
* Palette hat). Bewusst nur für den Detail-Fall: gleichmäßig verteilte Farbtöne
|
||||||
* Erwartet Objekte mit { name, value }.
|
* (Goldener Winkel) statt der farbfehlsicht-geprüften Festfarben.
|
||||||
*/
|
*/
|
||||||
export function mitFarben(eintraege, dunkel) {
|
function extraFarben(anzahl, dunkel) {
|
||||||
|
const s = dunkel ? 62 : 66;
|
||||||
|
const l = dunkel ? 58 : 46;
|
||||||
|
return Array.from({ length: anzahl }, (_, j) => {
|
||||||
|
const ton = Math.round((28 + j * 137.508) % 360);
|
||||||
|
return `hsl(${ton}, ${s}%, ${l}%)`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergibt Farben für eine sortierte Anteilsliste (Objekte mit { name, value }).
|
||||||
|
*
|
||||||
|
* Standard: höchstens SERIES_MAX Einträge in fester Reihenfolge, der Rest zu
|
||||||
|
* „Andere“ gebündelt. Mit `alle` (Detailansicht) bekommt JEDE Kategorie eine
|
||||||
|
* eigene Farbe (Festpalette vorne, danach erzeugte Töne) – ohne „Andere“.
|
||||||
|
*/
|
||||||
|
export function mitFarben(eintraege, dunkel, { alle = false } = {}) {
|
||||||
const farben = kategorieFarben(dunkel);
|
const farben = kategorieFarben(dunkel);
|
||||||
const sortiert = [...eintraege].sort((a, b) => b.value - a.value);
|
const sortiert = [...eintraege].sort((a, b) => b.value - a.value);
|
||||||
|
if (alle) {
|
||||||
|
const extra = extraFarben(Math.max(0, sortiert.length - farben.length), dunkel);
|
||||||
|
return sortiert.map((e, i) => ({
|
||||||
|
...e,
|
||||||
|
color: i < farben.length ? farben[i] : extra[i - farben.length],
|
||||||
|
}));
|
||||||
|
}
|
||||||
const vorne = sortiert.slice(0, SERIES_MAX).map((e, i) => ({ ...e, color: farben[i] }));
|
const vorne = sortiert.slice(0, SERIES_MAX).map((e, i) => ({ ...e, color: farben[i] }));
|
||||||
const rest = sortiert.slice(SERIES_MAX);
|
const rest = sortiert.slice(SERIES_MAX);
|
||||||
if (rest.length === 0) return vorne;
|
if (rest.length === 0) return vorne;
|
||||||
|
|||||||
148
web/src/groupGraph.js
Normal file
148
web/src/groupGraph.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// Gruppen bilden einen gerichteten azyklischen Graphen, keinen Baum:
|
||||||
|
// „Grillwurst" hängt unter „Wurst" UND unter „Grillgut". Deshalb hier
|
||||||
|
// Mengen-Helfer statt der Baum-Helfer aus categoryTree.js – ein Baum-Walk
|
||||||
|
// würde eine über zwei Wege erreichbare Gruppe doppelt liefern.
|
||||||
|
//
|
||||||
|
// Alle Funktionen schützen sich mit einem gesehen-Set gegen Ringe. Die API
|
||||||
|
// lässt keinen zu, eine eingespielte Sicherung könnte aber einen mitbringen.
|
||||||
|
|
||||||
|
const byId = (groups) => new Map((groups || []).map((g) => [g.id, g]));
|
||||||
|
|
||||||
|
/** IDs aller Untergruppen (transitiv, ohne die Gruppe selbst). */
|
||||||
|
export function nachfahrenIds(groups, id) {
|
||||||
|
const map = byId(groups);
|
||||||
|
const gesehen = new Set();
|
||||||
|
const offen = [id];
|
||||||
|
while (offen.length) {
|
||||||
|
const cur = offen.pop();
|
||||||
|
for (const kind of map.get(cur)?.child_ids || []) {
|
||||||
|
if (!gesehen.has(kind)) {
|
||||||
|
gesehen.add(kind);
|
||||||
|
offen.push(kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gesehen.delete(id);
|
||||||
|
return gesehen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IDs aller Obergruppen (transitiv, ohne die Gruppe selbst). */
|
||||||
|
export function vorfahrenIds(groups, id) {
|
||||||
|
const map = byId(groups);
|
||||||
|
const gesehen = new Set();
|
||||||
|
const offen = [...(map.get(id)?.parent_ids || [])];
|
||||||
|
while (offen.length) {
|
||||||
|
const cur = offen.pop();
|
||||||
|
if (gesehen.has(cur)) continue;
|
||||||
|
gesehen.add(cur);
|
||||||
|
offen.push(...(map.get(cur)?.parent_ids || []));
|
||||||
|
}
|
||||||
|
gesehen.delete(id);
|
||||||
|
return gesehen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Wege zu einer Gruppe, z.B. [["Wurst","Grillwurst"], ["Grillgut","Grillwurst"]].
|
||||||
|
* Anders als bei Kategorien gibt es nicht DEN einen Pfad – deshalb eine Liste.
|
||||||
|
*/
|
||||||
|
export function pfade(groups, id) {
|
||||||
|
const map = byId(groups);
|
||||||
|
const bauen = (cur, gesehen) => {
|
||||||
|
const g = map.get(cur);
|
||||||
|
if (!g) return [[]];
|
||||||
|
const eltern = (g.parent_ids || []).filter((p) => !gesehen.has(p));
|
||||||
|
if (eltern.length === 0) return [[g.name]];
|
||||||
|
const raus = [];
|
||||||
|
for (const p of eltern) {
|
||||||
|
for (const oben of bauen(p, new Set([...gesehen, cur]))) {
|
||||||
|
raus.push([...oben, g.name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raus;
|
||||||
|
};
|
||||||
|
return bauen(id, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Wege als ein Text: „Wurst → Grillwurst · Grillgut → Grillwurst". */
|
||||||
|
export function pfadText(groups, id) {
|
||||||
|
return pfade(groups, id)
|
||||||
|
.map((teile) => teile.join(" → "))
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Namen der Obergruppen, für Chips und Spaltentexte. */
|
||||||
|
export function elternNamen(groups, group) {
|
||||||
|
const map = byId(groups);
|
||||||
|
return (group?.parent_ids || []).map((id) => map.get(id)?.name).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gruppen für ein Auswahlfeld: als Baum ausgerollt und eingerückt.
|
||||||
|
*
|
||||||
|
* Eine Gruppe mit mehreren Obergruppen erscheint unter JEDER – genau das ist
|
||||||
|
* der Sinn mehrerer Obergruppen. Der Wert ist ohnehin dieselbe ID, egal welche
|
||||||
|
* Zeile man trifft.
|
||||||
|
*
|
||||||
|
* Eingerückt wird mit GESCHÜTZTEN Leerzeichen: normale fasst der Browser in
|
||||||
|
* einem <option> zusammen, die Einrückung wäre dann wirkungslos.
|
||||||
|
*/
|
||||||
|
export function gruppenOptionen(groups) {
|
||||||
|
const liste = groups || [];
|
||||||
|
const vorhanden = new Set(liste.map((g) => g.id));
|
||||||
|
const nachName = (a, b) => a.name.localeCompare(b.name, "de");
|
||||||
|
const kinderVon = new Map();
|
||||||
|
const wurzeln = [];
|
||||||
|
for (const g of liste) {
|
||||||
|
const eltern = (g.parent_ids || []).filter((id) => vorhanden.has(id));
|
||||||
|
if (eltern.length === 0) wurzeln.push(g);
|
||||||
|
for (const e of eltern) {
|
||||||
|
if (!kinderVon.has(e)) kinderVon.set(e, []);
|
||||||
|
kinderVon.get(e).push(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
const walk = (g, tiefe, pfad, gesehen) => {
|
||||||
|
const eigen = `${pfad}/${g.id}`;
|
||||||
|
out.push({
|
||||||
|
id: g.id,
|
||||||
|
key: eigen,
|
||||||
|
gruppe: g,
|
||||||
|
tiefe,
|
||||||
|
label: `${"\u00A0\u00A0\u00A0".repeat(tiefe)}${tiefe > 0 ? "\u21B3 " : ""}${g.name}`,
|
||||||
|
});
|
||||||
|
if (gesehen.has(g.id)) return; // Ringschutz
|
||||||
|
const weiter = new Set(gesehen).add(g.id);
|
||||||
|
for (const k of (kinderVon.get(g.id) || []).slice().sort(nachName)) {
|
||||||
|
walk(k, tiefe + 1, eigen, weiter);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const w of wurzeln.slice().sort(nachName)) walk(w, 0, "", new Set());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gegenstück zu categoryInfoMap für Gruppen: je Gruppe der Weg von oben.
|
||||||
|
*
|
||||||
|
* Anders als bei Kategorien kann es MEHRERE Wege geben („Grillwurst" unter
|
||||||
|
* „Wurst" und unter „Grillgut"). Angezeigt wird der erste, gefiltert wird über
|
||||||
|
* alle – wer auf „Wurst" filtert, will „Grillwurst" mitsehen.
|
||||||
|
*/
|
||||||
|
export function gruppenInfoMap(groups) {
|
||||||
|
const out = new Map();
|
||||||
|
for (const g of groups || []) {
|
||||||
|
const wege = pfade(groups, g.id);
|
||||||
|
const erster = wege[0]?.length ? wege[0] : [g.name];
|
||||||
|
const tokens = new Set();
|
||||||
|
for (const weg of wege) {
|
||||||
|
weg.forEach((_, i) => tokens.add(weg.slice(0, i + 1).join(" → ")));
|
||||||
|
}
|
||||||
|
out.set(g.id, {
|
||||||
|
parts: erster,
|
||||||
|
path: erster.join(" → "),
|
||||||
|
alle: wege.map((w) => w.join(" → ")),
|
||||||
|
tokens: [...tokens],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -8,12 +8,16 @@ import { ConfirmProvider } from "./confirm";
|
|||||||
import { ToastProvider } from "./toast";
|
import { ToastProvider } from "./toast";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
import { applyFavicon } from "./branding";
|
import { applyFavicon } from "./branding";
|
||||||
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
|
|
||||||
// Eigenes Favicon, falls hinterlegt - bewusst vor dem Login.
|
// Eigenes Favicon, falls hinterlegt - bewusst vor dem Login.
|
||||||
applyFavicon();
|
applyFavicon();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
{/* Letzte Instanz: ohne sie haengt ein Fehler beim Rendern den ganzen
|
||||||
|
Baum aus und die Seite bleibt weiss. */}
|
||||||
|
<ErrorBoundary>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
@@ -25,5 +29,6 @@ ReactDOM.createRoot(document.getElementById("root")).render(
|
|||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import SplitLotDialog from "../components/SplitLotDialog";
|
|||||||
import { ProduktThumb } from "../components/ProduktBild";
|
import { ProduktThumb } from "../components/ProduktBild";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import { locationOptions, locationPathById } from "../locationPath";
|
import { locationOptions, locationPathById } from "../locationPath";
|
||||||
import { fmt, gebinde, unitShort } from "../units";
|
import { expiryRowClass, fmt, gebinde, unitShort } from "../units";
|
||||||
|
|
||||||
// Artikeleinheit einer Charge (Gebinde, sonst Produkteinheit).
|
// Artikeleinheit einer Charge (Gebinde, sonst Produkteinheit).
|
||||||
const artFactor = (l) => (l.package_size && l.package_size > 0 ? l.package_size : (l.unit_factor || 1));
|
const artFactor = (l) => (l.package_size && l.package_size > 0 ? l.package_size : (l.unit_factor || 1));
|
||||||
@@ -36,6 +36,7 @@ export default function Charges() {
|
|||||||
const [selected, setSelected] = useState(() => new Set());
|
const [selected, setSelected] = useState(() => new Set());
|
||||||
const [visible, setVisible] = useState([]); // aktuell gefiltert sichtbare Chargen
|
const [visible, setVisible] = useState([]); // aktuell gefiltert sichtbare Chargen
|
||||||
const [splitting, setSplitting] = useState(null); // Charge, die gerade aufgeteilt wird
|
const [splitting, setSplitting] = useState(null); // Charge, die gerade aufgeteilt wird
|
||||||
|
const [warnDays, setWarnDays] = useState(7); // Warnfrist für „bald ablaufend"
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -50,6 +51,12 @@ export default function Charges() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
// Warnfrist (Tage) für die gelbe „bald ablaufend"-Markierung – wie auf der Artikelseite.
|
||||||
|
useEffect(() => {
|
||||||
|
api.listSettings()
|
||||||
|
.then((s) => { const r = s.find((x) => x.key === "expiry_warning_days"); if (r) setWarnDays(parseInt(r.value, 10) || 7); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const ohneOrt = useMemo(() => lots.filter((l) => !l.location_id), [lots]);
|
const ohneOrt = useMemo(() => lots.filter((l) => !l.location_id), [lots]);
|
||||||
|
|
||||||
@@ -132,12 +139,12 @@ export default function Charges() {
|
|||||||
{ key: "created", header: "Eingelagert", width: 130,
|
{ key: "created", header: "Eingelagert", width: 130,
|
||||||
sortValue: (l) => new Date(l.created_at).getTime(),
|
sortValue: (l) => new Date(l.created_at).getTime(),
|
||||||
render: (l) => <span className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</span> },
|
render: (l) => <span className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</span> },
|
||||||
{ key: "split", header: "", label: "Aufteilen", fixed: true, width: 118, align: "num",
|
{ key: "split", header: "Aufteilen", label: "Aufteilen", width: 118, align: "num",
|
||||||
render: (l) => (isAdmin ? (
|
render: (l) => (isAdmin ? (
|
||||||
<button type="button" className="btn sm ghost" title="Charge aufteilen und Teil umlagern"
|
<button type="button" className="btn sm ghost" title="Charge aufteilen und Teil umlagern"
|
||||||
onClick={() => setSplitting(l)}><Icon name="split" size={15} />Aufteilen</button>
|
onClick={() => setSplitting(l)}><Icon name="split" size={15} />Aufteilen</button>
|
||||||
) : null) },
|
) : null) },
|
||||||
{ key: "details", header: "", label: "Artikel", fixed: true, width: 84, align: "num",
|
{ key: "details", header: "Artikel", label: "Artikel", width: 90, align: "num",
|
||||||
render: (l) => <Link to={`/products/${l.product_id}`}>Artikel</Link> },
|
render: (l) => <Link to={`/products/${l.product_id}`}>Artikel</Link> },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -191,7 +198,7 @@ export default function Charges() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<DataTable id="charges" columns={columns} rows={lots} loading={loading}
|
<DataTable id="charges" columns={columns} rows={lots} loading={loading}
|
||||||
onVisibleRows={setVisible}
|
onVisibleRows={setVisible}
|
||||||
rowClassName={(l) => (selected.has(l.id) ? "row-active" : "")}
|
rowClassName={(l) => [expiryRowClass(l.best_before, warnDays), selected.has(l.id) ? "row-active" : ""].filter(Boolean).join(" ")}
|
||||||
onRowClick={(l, e) => {
|
onRowClick={(l, e) => {
|
||||||
// Klicks auf Bedienelemente (Checkbox, Lagerort-Dropdown, Link) nicht
|
// Klicks auf Bedienelemente (Checkbox, Lagerort-Dropdown, Link) nicht
|
||||||
// als Zeilenauswahl werten – überall sonst die Zeile umschalten.
|
// als Zeilenauswahl werten – überall sonst die Zeile umschalten.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { asTree } from "../categoryTree";
|
|||||||
import CategorySelect from "../components/CategorySelect";
|
import CategorySelect from "../components/CategorySelect";
|
||||||
import { locationOptions } from "../locationPath";
|
import { locationOptions } from "../locationPath";
|
||||||
import { buildUnitOptions, fmt, fromMonthInput, isExpired, monthInputToLastDay } from "../units";
|
import { buildUnitOptions, fmt, fromMonthInput, isExpired, monthInputToLastDay } from "../units";
|
||||||
|
import { gruppenOptionen } from "../groupGraph";
|
||||||
|
|
||||||
const emptyLine = () => ({ quantity: "", best_before: "" });
|
const emptyLine = () => ({ quantity: "", best_before: "" });
|
||||||
|
|
||||||
@@ -159,7 +160,9 @@ export default function CheckIn() {
|
|||||||
Gruppe
|
Gruppe
|
||||||
<select value={newGroupId} onChange={(e) => setNewGroupId(e.target.value)}>
|
<select value={newGroupId} onChange={(e) => setNewGroupId(e.target.value)}>
|
||||||
<option value="">– keine –</option>
|
<option value="">– keine –</option>
|
||||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
{gruppenOptionen(groups).map((o) => (
|
||||||
|
<option key={o.key} value={o.id}>{o.label}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
Zählt Bestände mehrerer Marken zusammen. Der EAN-Code landet dann auch dort.
|
Zählt Bestände mehrerer Marken zusammen. Der EAN-Code landet dann auch dort.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Icon from "../components/Icon";
|
|||||||
import { ProduktThumb } from "../components/ProduktBild";
|
import { ProduktThumb } from "../components/ProduktBild";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import { buildUnitOptions, fmt, gebinde, isExpired, unitShort } from "../units";
|
import { buildUnitOptions, fmt, gebinde, isExpired, unitShort, zweitText } from "../units";
|
||||||
|
|
||||||
export default function CheckOut() {
|
export default function CheckOut() {
|
||||||
const { formatBestBefore } = useSettings();
|
const { formatBestBefore } = useSettings();
|
||||||
@@ -149,6 +149,10 @@ export default function CheckOut() {
|
|||||||
<div className="title">{product.name}</div>
|
<div className="title">{product.name}</div>
|
||||||
<div className="muted small">
|
<div className="muted small">
|
||||||
Verfügbar: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
|
Verfügbar: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
|
||||||
|
{/* Zweite Lesart, wenn eine Zweiteinheit hinterlegt ist. */}
|
||||||
|
{zweitText(product.stock || 0, product) && (
|
||||||
|
<> · {zweitText(product.stock || 0, product)}</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { useAuth } from "../auth";
|
|||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { KARTEN, KARTEN_IDS, ZEITRAEUME } from "../dashboard/cards";
|
import { KARTEN, KARTEN_IDS } from "../dashboard/cards";
|
||||||
|
import KartenConfig from "../dashboard/KartenConfig";
|
||||||
import Vorschau from "../dashboard/Vorschau";
|
import Vorschau from "../dashboard/Vorschau";
|
||||||
|
|
||||||
const Raster = WidthProvider(GridLayout);
|
const Raster = WidthProvider(GridLayout);
|
||||||
@@ -54,6 +55,8 @@ export default function Dashboard() {
|
|||||||
const [fehler, setFehler] = useState(null);
|
const [fehler, setFehler] = useState(null);
|
||||||
const [menuOffen, setMenuOffen] = useState(false);
|
const [menuOffen, setMenuOffen] = useState(false);
|
||||||
const [produkte, setProdukte] = useState([]);
|
const [produkte, setProdukte] = useState([]);
|
||||||
|
const [locations, setLocations] = useState([]);
|
||||||
|
const [konfigKarte, setKonfigKarte] = useState(null); // Kennung der Karte im Konfig-Dialog
|
||||||
|
|
||||||
const aktivesId = routeId ? Number(routeId) : dashboards[0]?.id;
|
const aktivesId = routeId ? Number(routeId) : dashboards[0]?.id;
|
||||||
const aktives = dashboards.find((d) => d.id === aktivesId) ?? dashboards[0];
|
const aktives = dashboards.find((d) => d.id === aktivesId) ?? dashboards[0];
|
||||||
@@ -80,14 +83,15 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
useEffect(() => { laden(); }, []);
|
useEffect(() => { laden(); }, []);
|
||||||
|
|
||||||
// Artikelliste nur einmal für die Einstellung der artikelbezogenen Karten.
|
// Artikel- und Lagerortliste einmal für die Karten-Konfiguration laden.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listProducts().then(setProdukte).catch(() => {});
|
api.listProducts().then(setProdukte).catch(() => {});
|
||||||
|
api.listLocations().then(setLocations).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Beim Wechsel des Dashboards einen offenen Entwurf verwerfen – sonst
|
// Beim Wechsel des Dashboards einen offenen Entwurf verwerfen – sonst
|
||||||
// landete er beim Speichern auf dem falschen.
|
// landete er beim Speichern auf dem falschen.
|
||||||
useEffect(() => { setEntwurf(null); setMenuOffen(false); }, [aktivesId]);
|
useEffect(() => { setEntwurf(null); setMenuOffen(false); setKonfigKarte(null); }, [aktivesId]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unbekannte Karten aussortieren – z.B. nach einem Update, das eine entfernt.
|
* Unbekannte Karten aussortieren – z.B. nach einem Update, das eine entfernt.
|
||||||
@@ -149,6 +153,7 @@ export default function Dashboard() {
|
|||||||
const gespeichert = await api.updateDashboard(aktives.id, { layout: schlank(entwurf) });
|
const gespeichert = await api.updateDashboard(aktives.id, { layout: schlank(entwurf) });
|
||||||
setDashboards((alt) => alt.map((d) => (d.id === gespeichert.id ? gespeichert : d)));
|
setDashboards((alt) => alt.map((d) => (d.id === gespeichert.id ? gespeichert : d)));
|
||||||
setEntwurf(null);
|
setEntwurf(null);
|
||||||
|
setKonfigKarte(null);
|
||||||
toast("Anordnung gespeichert.");
|
toast("Anordnung gespeichert.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFehler(err.message);
|
setFehler(err.message);
|
||||||
@@ -271,26 +276,10 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Zeitraum einer Karte ändern. Passiert nur noch im Konfig-Dialog, also im
|
||||||
* Zeitraum einer Verlaufskarte ändern. Außerhalb des Bearbeitungsmodus wird
|
* Bearbeitungsmodus – gespeichert wird mit „Speichern" wie jede andere Änderung. */
|
||||||
* die Wahl still gespeichert – sonst wäre sie nach dem nächsten Laden wieder
|
function setzeZeitraum(kennung, tage) {
|
||||||
* weg, ohne dass es einen sichtbaren Speichern-Knopf gäbe.
|
setEntwurf((alt) => (alt ?? []).map((k) => (k.i === kennung ? { ...k, tage } : k)));
|
||||||
*/
|
|
||||||
async function setzeZeitraum(kennung, tage) {
|
|
||||||
const geaendert = aktuell.map((k) => (k.i === kennung ? { ...k, tage } : k));
|
|
||||||
if (bearbeiten) {
|
|
||||||
setEntwurf(geaendert);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDashboards((alt) => alt.map((d) => (
|
|
||||||
d.id === aktives.id ? { ...d, layout: geaendert } : d
|
|
||||||
)));
|
|
||||||
if (erzwungen || !aktives?.id) return;
|
|
||||||
try {
|
|
||||||
await api.updateDashboard(aktives.id, { layout: geaendert });
|
|
||||||
} catch {
|
|
||||||
/* Nicht speichern zu können ist hier kein Grund, die Ansicht zu stören. */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const eigenes = herkunft === "user" && aktives?.id;
|
const eigenes = herkunft === "user" && aktives?.id;
|
||||||
@@ -348,7 +337,8 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn primary" onClick={speichern}>Speichern</button>
|
<button className="btn primary" onClick={speichern}>Speichern</button>
|
||||||
<button className="btn ghost" onClick={() => { setEntwurf(null); setMenuOffen(false); }}>
|
<button className="btn ghost"
|
||||||
|
onClick={() => { setEntwurf(null); setMenuOffen(false); setKonfigKarte(null); }}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -387,33 +377,24 @@ export default function Dashboard() {
|
|||||||
if (!karte) return null;
|
if (!karte) return null;
|
||||||
const Inhalt = karte.komponente;
|
const Inhalt = karte.komponente;
|
||||||
const artikel = produkte.find((p) => p.id === eintrag.props?.product_id);
|
const artikel = produkte.find((p) => p.id === eintrag.props?.product_id);
|
||||||
|
const ort = eintrag.props?.location_id
|
||||||
|
? locations.find((l) => l.id === eintrag.props.location_id)
|
||||||
|
: null;
|
||||||
|
const zusatz = artikel?.name || ort?.name; // im Titel gezeigt
|
||||||
return (
|
return (
|
||||||
<section key={eintrag.i} className="card dash-card">
|
<section key={eintrag.i} className="card dash-card">
|
||||||
<div className="dash-card-head">
|
<div className="dash-card-head">
|
||||||
<Icon name={karte.icon} size={16} />
|
<Icon name={karte.icon} size={16} />
|
||||||
<h2>{artikel ? `${karte.titel}: ${artikel.name}` : karte.titel}</h2>
|
<h2>{zusatz ? `${karte.titel}: ${zusatz}` : karte.titel}</h2>
|
||||||
{/* Zeitraum gehört in den Kopf: So bleibt die volle Kartenhöhe
|
{/* Einstellungen sammelt der Konfig-Dialog (wie in HomeAssistant),
|
||||||
dem Diagramm. onMouseDown stoppt das Ziehen der Karte. */}
|
statt einzelner Dropdowns im Kopf. Nur beim Anordnen sichtbar
|
||||||
{karte.zeitraum && (
|
und nur, wenn die Karte überhaupt Optionen hat. */}
|
||||||
<select className="zeitraum" value={eintrag.tage ?? karte.zeitraum}
|
{bearbeiten && karte.config && (
|
||||||
|
<button className="btn-icon" title="Karte einstellen"
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setzeZeitraum(eintrag.i, Number(e.target.value))}>
|
onClick={() => setKonfigKarte(eintrag.i)}>
|
||||||
{ZEITRAEUME.map((z) => (
|
<Icon name="settings" size={15} />
|
||||||
<option key={z.tage} value={z.tage}>{z.label}</option>
|
</button>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
{/* Artikelbezogene Karten werden beim Anordnen eingestellt -
|
|
||||||
so kann dieselbe Art mehrfach auf verschiedene Artikel zeigen. */}
|
|
||||||
{bearbeiten && karte.artikel && (
|
|
||||||
<select className="zeitraum" value={eintrag.props?.product_id ?? ""}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onChange={(e) => setzeProps(eintrag.i, {
|
|
||||||
product_id: e.target.value ? Number(e.target.value) : undefined,
|
|
||||||
})}>
|
|
||||||
<option value="">– Artikel wählen –</option>
|
|
||||||
{produkte.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
{bearbeiten && (
|
{bearbeiten && (
|
||||||
<button className="btn-icon danger" title="Karte entfernen"
|
<button className="btn-icon danger" title="Karte entfernen"
|
||||||
@@ -456,6 +437,24 @@ export default function Dashboard() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{konfigKarte && (() => {
|
||||||
|
const inst = aktuell.find((k) => k.i === konfigKarte);
|
||||||
|
if (!inst) return null;
|
||||||
|
const karte = KARTEN[inst.type];
|
||||||
|
return (
|
||||||
|
<KartenConfig
|
||||||
|
karte={karte}
|
||||||
|
props={inst.props || {}}
|
||||||
|
tage={inst.tage ?? karte.zeitraum}
|
||||||
|
produkte={produkte}
|
||||||
|
locations={locations}
|
||||||
|
onProps={(partial) => setzeProps(konfigKarte, partial)}
|
||||||
|
onTage={(n) => setzeZeitraum(konfigKarte, n)}
|
||||||
|
onClose={() => setKonfigKarte(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
@@ -6,7 +6,8 @@ import BarcodeList from "../components/BarcodeList";
|
|||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import DataTable from "../components/DataTable";
|
import DataTable from "../components/DataTable";
|
||||||
import LocationMinStock from "../components/LocationMinStock";
|
import LocationMinStock from "../components/LocationMinStock";
|
||||||
import { fmt } from "../units";
|
import GroupParentSelect from "../components/GroupParentSelect";
|
||||||
|
import { anzahlWort, fmt, grpPkgMode, grpUnit, kindShort } from "../units";
|
||||||
|
|
||||||
export default function Groups() {
|
export default function Groups() {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -14,7 +15,9 @@ export default function Groups() {
|
|||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
const [units, setUnits] = useState([]);
|
const [units, setUnits] = useState([]);
|
||||||
const [locations, setLocations] = 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: "", parent_ids: [],
|
||||||
|
});
|
||||||
const [selectedId, setSelectedId] = useState(null);
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -45,8 +48,11 @@ export default function Groups() {
|
|||||||
name: form.name,
|
name: form.name,
|
||||||
min_stock: form.min_stock === "" ? null : Number(form.min_stock),
|
min_stock: form.min_stock === "" ? null : Number(form.min_stock),
|
||||||
min_stock_unit_id: form.min_stock_unit_id === "" ? null : Number(form.min_stock_unit_id),
|
min_stock_unit_id: form.min_stock_unit_id === "" ? null : Number(form.min_stock_unit_id),
|
||||||
|
parent_ids: form.parent_ids,
|
||||||
|
});
|
||||||
|
setForm({
|
||||||
|
name: "", min_stock: "", min_stock_unit_id: form.min_stock_unit_id, parent_ids: [],
|
||||||
});
|
});
|
||||||
setForm({ name: "", min_stock: "", min_stock_unit_id: form.min_stock_unit_id });
|
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -66,7 +72,9 @@ export default function Groups() {
|
|||||||
async function remove(group) {
|
async function remove(group) {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: `Gruppe „${group.name}“ löschen?`,
|
title: `Gruppe „${group.name}“ löschen?`,
|
||||||
message: "Die Produkte bleiben erhalten, verlieren aber ihre Zuordnung zu dieser Gruppe.",
|
message: "Die Produkte bleiben erhalten, verlieren aber ihre Zuordnung zu dieser Gruppe. "
|
||||||
|
+ "Untergruppen bleiben ebenfalls bestehen und verlieren nur die Verbindung — "
|
||||||
|
+ "sie rücken nicht automatisch eine Ebene hoch.",
|
||||||
confirmLabel: "Löschen",
|
confirmLabel: "Löschen",
|
||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
@@ -81,6 +89,41 @@ export default function Groups() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selected = groups.find((g) => g.id === selectedId) || null;
|
const selected = groups.find((g) => g.id === selectedId) || null;
|
||||||
|
const nameById = Object.fromEntries(groups.map((g) => [g.id, g.name]));
|
||||||
|
|
||||||
|
// Gruppen als Baum ausrollen. Eine Gruppe mit ZWEI Obergruppen erscheint
|
||||||
|
// unter beiden – das ist die ehrliche Darstellung eines Graphen; sie zu
|
||||||
|
// verstecken hiesse, den Sinn mehrerer Obergruppen zu verbergen.
|
||||||
|
//
|
||||||
|
// Jede Zeile traegt ihren PFAD als Schluessel („/3/7"). Damit kommt die
|
||||||
|
// vorhandene Baumlogik der DataTable unveraendert zurecht: sie kennt nur
|
||||||
|
// einen Elternteil je Zeile, und ein Pfad hat genau einen.
|
||||||
|
const baumZeilen = useMemo(() => {
|
||||||
|
const vorhanden = new Set(groups.map((g) => g.id));
|
||||||
|
const nachName = (a, b) => a.name.localeCompare(b.name, "de");
|
||||||
|
const kinderVon = new Map();
|
||||||
|
const wurzeln = [];
|
||||||
|
for (const g of groups) {
|
||||||
|
const eltern = (g.parent_ids || []).filter((id) => vorhanden.has(id));
|
||||||
|
if (eltern.length === 0) wurzeln.push(g);
|
||||||
|
for (const e of eltern) {
|
||||||
|
if (!kinderVon.has(e)) kinderVon.set(e, []);
|
||||||
|
kinderVon.get(e).push(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
const walk = (g, elternPfad, gesehen) => {
|
||||||
|
const pfad = `${elternPfad ?? ""}/${g.id}`;
|
||||||
|
out.push({ ...g, _pfad: pfad, _elternPfad: elternPfad });
|
||||||
|
if (gesehen.has(g.id)) return; // Ringschutz, falls doch einer da ist
|
||||||
|
const weiter = new Set(gesehen).add(g.id);
|
||||||
|
for (const k of (kinderVon.get(g.id) || []).slice().sort(nachName)) {
|
||||||
|
walk(k, pfad, weiter);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const w of wurzeln.slice().sort(nachName)) walk(w, null, new Set());
|
||||||
|
return out;
|
||||||
|
}, [groups]);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: "name", header: "Gruppe", grow: true, min: 160,
|
{ key: "name", header: "Gruppe", grow: true, min: 160,
|
||||||
@@ -94,26 +137,89 @@ export default function Groups() {
|
|||||||
onBlur={(e) => { const v = e.target.value.trim(); if (v && v !== g.name) patch(g, { name: v }); }} />
|
onBlur={(e) => { const v = e.target.value.trim(); if (v && v !== g.name) patch(g, { name: v }); }} />
|
||||||
) : <span className="strong">{g.name}</span>}
|
) : <span className="strong">{g.name}</span>}
|
||||||
{low && <span className="badge warn">niedrig</span>}
|
{low && <span className="badge warn">niedrig</span>}
|
||||||
|
{g.child_ids?.length > 0 && (
|
||||||
|
<span className="badge" title="Bestand und Mindestbestand zählen diese Untergruppen mit.">
|
||||||
|
{anzahlWort(g.child_ids.length, "Untergruppe", "Untergruppen")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Ohne Einheit summiert eine Gruppe Gramm und Stück zu einer
|
||||||
|
sinnlosen Zahl. Bei einem grossen Untergraphen faellt das
|
||||||
|
besonders ins Gewicht. */}
|
||||||
|
{g.child_ids?.length > 0 && !g.min_stock_unit_id && (
|
||||||
|
<span className="badge warn" title="Ohne Einheit werden Artikel verschiedener Basiseinheiten (g, ml, Stück) zusammengezählt.">
|
||||||
|
Einheit fehlt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
} },
|
} },
|
||||||
{ key: "products", header: "Produkte", width: 100, align: "num",
|
{ key: "parents", header: "Obergruppen", width: 220,
|
||||||
sortValue: (g) => g.product_count, render: (g) => <span className="muted">{g.product_count}</span> },
|
filterText: (g) => (g.parent_ids || []).map((id) => nameById[id]).join(" "),
|
||||||
{ key: "stock", header: "Bestand", width: 130, align: "num",
|
render: (g) => (isAdmin ? (
|
||||||
sortValue: (g) => g.stock, render: (g) => `${fmt(g.stock)} ${g.min_stock_unit_name || ""}` },
|
<GroupParentSelect
|
||||||
|
groups={groups}
|
||||||
|
selfId={g.id}
|
||||||
|
value={g.parent_ids || []}
|
||||||
|
onChange={(ids) => patch(g, { parent_ids: ids })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="muted">
|
||||||
|
{(g.parent_ids || []).map((id) => nameById[id]).filter(Boolean).join(", ") || "–"}
|
||||||
|
</span>
|
||||||
|
)) },
|
||||||
|
{ key: "products", header: "Produkte", width: 120, align: "num",
|
||||||
|
sortValue: (g) => g.product_count,
|
||||||
|
render: (g) => (
|
||||||
|
<span className="muted">
|
||||||
|
{g.product_count}
|
||||||
|
{g.direct_product_count !== g.product_count && (
|
||||||
|
<span className="small"> ({g.direct_product_count} direkt)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) },
|
||||||
|
// Bestand steht in der Einheit, in der die Gruppe ZÄHLT – bei aktivem
|
||||||
|
// Gruppen-Gebinde also in Gläsern, nicht in Gramm. Vorher klebte hier
|
||||||
|
// immer min_stock_unit_name dahinter, was aus 790 g „4,16 Gramm" machte.
|
||||||
|
{ key: "stock", header: "Bestand", width: 140, align: "num",
|
||||||
|
sortValue: (g) => g.stock,
|
||||||
|
render: (g) => `${fmt(g.stock)} ${grpUnit(g, g.stock)}` },
|
||||||
{ key: "min", header: "Mindestbestand", width: 150, sortValue: (g) => g.min_stock ?? -1,
|
{ key: "min", header: "Mindestbestand", width: 150, sortValue: (g) => g.min_stock ?? -1,
|
||||||
render: (g) => (isAdmin ? (
|
render: (g) => (isAdmin ? (
|
||||||
<input type="number" step="any" defaultValue={g.min_stock ?? ""} style={{ marginTop: 0, minWidth: 90 }}
|
<span className="field-inline" style={{ gap: "var(--sp-1)", flexWrap: "nowrap" }}>
|
||||||
onBlur={(e) => { const v = e.target.value; if (v !== String(g.min_stock ?? "")) patch(g, { min_stock: v === "" ? null : Number(v) }); }} />
|
<input type="number" step="any" defaultValue={g.min_stock ?? ""} style={{ marginTop: 0, minWidth: 70 }}
|
||||||
) : (g.min_stock != null ? fmt(g.min_stock) : "–")) },
|
onBlur={(e) => { const v = e.target.value; if (v !== String(g.min_stock ?? "")) patch(g, { min_stock: v === "" ? null : Number(v) }); }} />
|
||||||
|
<span className="muted small" style={{ alignSelf: "center" }}>{grpUnit(g, g.min_stock ?? 1)}</span>
|
||||||
|
</span>
|
||||||
|
) : (g.min_stock != null ? `${fmt(g.min_stock)} ${grpUnit(g, g.min_stock)}` : "–")) },
|
||||||
{ key: "unit", header: "Einheit", width: 150, filterText: (g) => g.min_stock_unit_name || "",
|
{ key: "unit", header: "Einheit", width: 150, filterText: (g) => g.min_stock_unit_name || "",
|
||||||
render: (g) => (isAdmin ? (
|
render: (g) => (
|
||||||
<select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
|
<span className="cell-col">
|
||||||
onChange={(e) => patch(g, { min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value) })}>
|
{isAdmin ? (
|
||||||
<option value="">– Basiseinheit –</option>
|
<select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
|
||||||
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
onChange={(e) => patch(g, { min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value) })}>
|
||||||
</select>
|
<option value="">– Basiseinheit –</option>
|
||||||
) : (g.min_stock_unit_name || "–")) },
|
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (g.min_stock_unit_name || "–")}
|
||||||
|
{/* Zählt die Gruppe im Gebinde, sagt die Einheit darüber nur die
|
||||||
|
halbe Wahrheit – der Zusatz nennt den Umrechnungswert. */}
|
||||||
|
{grpPkgMode(g) && (
|
||||||
|
<span className="muted small">
|
||||||
|
zählt in {grpUnit(g, 2)} à {fmt(g.package_size)} {kindShort(g.kind)}
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<button type="button" className="link-btn"
|
||||||
|
title="Bestand und Mindestbestand wieder in der Einheit oben lesen"
|
||||||
|
onClick={() => patch(g, { min_stock_in_packages: false })}>
|
||||||
|
in {g.min_stock_unit_name || kindShort(g.kind)} rechnen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) },
|
||||||
{ key: "codes", header: "EAN-Codes", width: 130, align: "num",
|
{ key: "codes", header: "EAN-Codes", width: 130, align: "num",
|
||||||
render: (g) => (
|
render: (g) => (
|
||||||
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
|
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
|
||||||
@@ -140,67 +246,72 @@ export default function Groups() {
|
|||||||
</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>}
|
||||||
|
|
||||||
<div className="grid-2 wide-aside">
|
{/* Die Tabelle bekommt die volle Breite – bei vielen Spalten (Obergruppen,
|
||||||
<div className="card">
|
Bestand, Einheit) war die rechte Spalte teurer als sie half. Alles
|
||||||
<DataTable id="groups" columns={columns} rows={groups} loading={loading}
|
Übrige steht darunter. */}
|
||||||
getRowKey={(g) => g.id} empty="Noch keine Gruppen." />
|
<div className="card">
|
||||||
</div>
|
<DataTable id="groups" columns={columns} rows={baumZeilen} loading={loading}
|
||||||
|
getRowKey={(g) => g._pfad}
|
||||||
|
tree={{ column: "name", idOf: (g) => g._pfad, parentIdOf: (g) => g._elternPfad }}
|
||||||
|
empty="Noch keine Gruppen." />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Karten zur AUSGEWÄHLTEN Gruppe – erscheinen erst beim „verwalten". */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<section className="card">
|
<div className="grid-2" style={{ marginTop: "var(--sp-3)" }}>
|
||||||
<div className="card-head">
|
<section className="card">
|
||||||
<Icon name="search" />
|
<div className="card-head">
|
||||||
<h2>EAN-Codes der Gruppe: {selected.name}</h2>
|
<Icon name="search" />
|
||||||
</div>
|
<h2>EAN-Codes der Gruppe: {selected.name}</h2>
|
||||||
<BarcodeList
|
</div>
|
||||||
barcodes={selected.barcodes || []}
|
<BarcodeList
|
||||||
productBarcodes={selected.product_barcodes || []}
|
barcodes={selected.barcodes || []}
|
||||||
disabled={!isAdmin}
|
productBarcodes={selected.product_barcodes || []}
|
||||||
hint={"Codes der zugeordneten Artikel stehen automatisch hier, mit Marke und " +
|
disabled={!isAdmin}
|
||||||
"Artikelnamen; entfernen lassen sie sich nur, indem der Artikel die Gruppe " +
|
hint={"Codes der zugeordneten Artikel stehen automatisch hier, mit Marke und " +
|
||||||
"wechselt. Zusätzlich sind Codes möglich, die noch zu keinem Artikel " +
|
"Artikelnamen; entfernen lassen sie sich nur, indem der Artikel die Gruppe " +
|
||||||
"gehören – für die gibt es eine Notiz, weil ihnen der Artikelname fehlt. " +
|
"wechselt. Zusätzlich sind Codes möglich, die noch zu keinem Artikel " +
|
||||||
"Wird so ein Code beim Einlagern gescannt, landet der neu angelegte Artikel " +
|
"gehören – für die gibt es eine Notiz, weil ihnen der Artikelname fehlt. " +
|
||||||
"automatisch hier."}
|
"Wird so ein Code beim Einlagern gescannt, landet der neu angelegte Artikel " +
|
||||||
onAdd={async (body) => {
|
"automatisch hier."}
|
||||||
try {
|
onAdd={async (body) => {
|
||||||
await api.addGroupBarcode(selected.id, body);
|
try {
|
||||||
await load();
|
await api.addGroupBarcode(selected.id, body);
|
||||||
} catch (err) { setError(err.message); }
|
await load();
|
||||||
}}
|
} catch (err) { setError(err.message); }
|
||||||
onEditNote={async (code, note) => {
|
}}
|
||||||
try {
|
onEditNote={async (code, note) => {
|
||||||
await api.updateGroupBarcodeNote(selected.id, code, { note });
|
try {
|
||||||
await load();
|
await api.updateGroupBarcodeNote(selected.id, code, { note });
|
||||||
} catch (err) { setError(err.message); }
|
await load();
|
||||||
}}
|
} catch (err) { setError(err.message); }
|
||||||
onDelete={async (code) => {
|
}}
|
||||||
try {
|
onDelete={async (code) => {
|
||||||
await api.deleteGroupBarcode(selected.id, code);
|
try {
|
||||||
await load();
|
await api.deleteGroupBarcode(selected.id, code);
|
||||||
} catch (err) { setError(err.message); }
|
await load();
|
||||||
}}
|
} catch (err) { setError(err.message); }
|
||||||
/>
|
}}
|
||||||
<button className="btn ghost" onClick={() => setSelectedId(null)}>Schließen</button>
|
/>
|
||||||
</section>
|
<button className="btn ghost" onClick={() => setSelectedId(null)}>Schließen</button>
|
||||||
)}
|
</section>
|
||||||
|
|
||||||
{selected && isAdmin && (
|
{isAdmin && (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<Icon name="location" />
|
<Icon name="location" />
|
||||||
<h2>Mindestbestand je Lagerort</h2>
|
<h2>Mindestbestand</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted small mt-0">
|
<p className="muted small mt-0">
|
||||||
Bedarf dieser Gruppe je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum
|
Bedarf dieser Gruppe je Lagerort — „Überall“ heißt: egal wo, Hauptsache
|
||||||
Gesamt-Mindestbestand{selected.min_stock_unit_name ? ` (in ${selected.min_stock_unit_name})` : ""}.
|
die Menge ist im Haus. Käufe für einen Lagerort decken „Überall“ mit ab.
|
||||||
|
Mengen in {kindShort(selected.kind) || "Basiseinheiten"}.
|
||||||
</p>
|
</p>
|
||||||
<LocationMinStock
|
<LocationMinStock
|
||||||
key={selected.id}
|
key={selected.id}
|
||||||
locations={locations}
|
locations={locations}
|
||||||
initial={selected.location_min_stocks || []}
|
initial={selected.location_min_stocks || []}
|
||||||
unitLabel={selected.min_stock_unit_name || ""}
|
unitLabel={kindShort(selected.kind)}
|
||||||
onError={setError}
|
onError={setError}
|
||||||
onSave={async (list) => {
|
onSave={async (list) => {
|
||||||
await api.setGroupLocationMinStock(selected.id, list);
|
await api.setGroupLocationMinStock(selected.id, list);
|
||||||
@@ -209,39 +320,52 @@ export default function Groups() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<form className="card" onSubmit={add}>
|
|
||||||
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input placeholder="z.B. Mehl" value={form.name}
|
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
|
||||||
</label>
|
|
||||||
<div className="row">
|
|
||||||
<label className="grow">
|
|
||||||
Mindestbestand (optional)
|
|
||||||
<input type="number" step="any" value={form.min_stock}
|
|
||||||
onChange={(e) => setForm({ ...form, min_stock: e.target.value })} placeholder="z.B. 2" />
|
|
||||||
</label>
|
|
||||||
<label className="grow">
|
|
||||||
Einheit
|
|
||||||
<select value={form.min_stock_unit_id}
|
|
||||||
onChange={(e) => setForm({ ...form, min_stock_unit_id: e.target.value })}>
|
|
||||||
<option value="">– Basiseinheit –</option>
|
|
||||||
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
|
||||||
<p className="muted small">
|
|
||||||
Der Bestand der Gruppe summiert nur Produkte, die zur gewählten Einheit passen.
|
|
||||||
Namen lassen sich in der Tabelle direkt ändern.
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<form className="card" onSubmit={add}
|
||||||
|
style={{ marginTop: "var(--sp-3)", maxWidth: 560 }}>
|
||||||
|
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input placeholder="z.B. Mehl" value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||||
|
</label>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
Mindestbestand (optional)
|
||||||
|
<input type="number" step="any" value={form.min_stock}
|
||||||
|
onChange={(e) => setForm({ ...form, min_stock: e.target.value })} placeholder="z.B. 2" />
|
||||||
|
</label>
|
||||||
|
<label className="grow">
|
||||||
|
Einheit
|
||||||
|
<select value={form.min_stock_unit_id}
|
||||||
|
onChange={(e) => setForm({ ...form, min_stock_unit_id: e.target.value })}>
|
||||||
|
<option value="">– Basiseinheit –</option>
|
||||||
|
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Obergruppen (optional)
|
||||||
|
<GroupParentSelect
|
||||||
|
groups={groups}
|
||||||
|
selfId={null}
|
||||||
|
value={form.parent_ids}
|
||||||
|
onChange={(ids) => setForm({ ...form, parent_ids: ids })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||||||
|
<p className="muted small">
|
||||||
|
Bestand und Mindestbestand einer Gruppe zählen auch die Artikel ihrer
|
||||||
|
Untergruppen. Eine Gruppe darf unter mehreren Obergruppen hängen —
|
||||||
|
„Grillwurst“ etwa unter „Wurst“ <em>und</em> unter „Grillgut“.
|
||||||
|
Gezählt werden nur Artikel, die zur gewählten Einheit passen, auch in
|
||||||
|
den Untergruppen. Namen lassen sich in der Tabelle direkt ändern.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import BewegungIcon from "../components/BewegungIcon";
|
||||||
import { articlePrimary, articleSecondary } from "../units";
|
import { articlePrimary, articleSecondary } from "../units";
|
||||||
|
|
||||||
const TYPE_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
|
|
||||||
|
|
||||||
export default function History() {
|
export default function History() {
|
||||||
const [movements, setMovements] = useState([]);
|
const [movements, setMovements] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -28,8 +27,10 @@ export default function History() {
|
|||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* Die Bewegungsart zuerst: sie sagt in einem Blick, worum
|
||||||
|
es geht, und braucht als Zeichen kaum Breite. */}
|
||||||
|
<th style={{ width: 64 }}>Aktion</th>
|
||||||
<th>Zeitpunkt</th>
|
<th>Zeitpunkt</th>
|
||||||
<th>Aktion</th>
|
|
||||||
<th>Produkt</th>
|
<th>Produkt</th>
|
||||||
<th className="num">Menge</th>
|
<th className="num">Menge</th>
|
||||||
<th>Benutzer</th>
|
<th>Benutzer</th>
|
||||||
@@ -38,13 +39,11 @@ export default function History() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{movements.map((m) => (
|
{movements.map((m) => (
|
||||||
<tr key={m.id}>
|
<tr key={m.id}>
|
||||||
|
{/* Nur das Sinnbild – die Tabelle hat noch Menge und
|
||||||
|
Benutzer zu zeigen, und das Wort stand ohnehin dreimal
|
||||||
|
untereinander. Titel und aria-label tragen es weiter. */}
|
||||||
|
<td data-label="Aktion"><BewegungIcon typ={m.type} /></td>
|
||||||
<td data-label="Zeitpunkt" className="muted">{new Date(m.created_at).toLocaleString("de-DE")}</td>
|
<td data-label="Zeitpunkt" className="muted">{new Date(m.created_at).toLocaleString("de-DE")}</td>
|
||||||
<td data-label="Aktion">
|
|
||||||
<span className={`badge ${m.type === "in" ? "ok" : m.type === "out" ? "warn" : ""}`}>
|
|
||||||
<Icon name={m.type === "in" ? "checkin" : m.type === "out" ? "checkout" : "edit"} size={13} />
|
|
||||||
{TYPE_LABEL[m.type] || m.type}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td data-label="Produkt">{m.product_name}</td>
|
<td data-label="Produkt">{m.product_name}</td>
|
||||||
<td data-label="Menge" className="num">
|
<td data-label="Menge" className="num">
|
||||||
{articlePrimary(m)}
|
{articlePrimary(m)}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function ItemList() {
|
|||||||
render: (it) => preis(it) || "–" },
|
render: (it) => preis(it) || "–" },
|
||||||
{ key: "note", header: "Notiz", width: 160, filterText: (it) => it.note || "",
|
{ key: "note", header: "Notiz", width: 160, filterText: (it) => it.note || "",
|
||||||
render: (it) => <span className="muted">{it.note || "–"}</span> },
|
render: (it) => <span className="muted">{it.note || "–"}</span> },
|
||||||
{ key: "open", header: "", label: "Produkt öffnen", fixed: true, width: 84, align: "num",
|
{ key: "open", header: "Artikel", label: "Produkt öffnen", width: 90, align: "num",
|
||||||
render: (it) => <Link to={`/products/${it.product_id}`}>Produkt</Link> },
|
render: (it) => <Link to={`/products/${it.product_id}`}>Produkt</Link> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Icon from "../components/Icon";
|
|||||||
import BrandMark from "../components/BrandMark";
|
import BrandMark from "../components/BrandMark";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { login } = useAuth();
|
const { login, abgelaufen } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -33,6 +33,11 @@ export default function Login() {
|
|||||||
<BrandMark />
|
<BrandMark />
|
||||||
</div>
|
</div>
|
||||||
<p className="lead">Lebensmittel-Lagerverwaltung</p>
|
<p className="lead">Lebensmittel-Lagerverwaltung</p>
|
||||||
|
{abgelaufen && !error && (
|
||||||
|
<div className="alert info"><Icon name="alert" size={16} />
|
||||||
|
Deine Sitzung ist abgelaufen – bitte melde dich neu an.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="alert error"><Icon name="alert" size={16} />{error}</div>
|
<div className="alert error"><Icon name="alert" size={16} />{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ import { api } from "../api";
|
|||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import DataTable from "../components/DataTable";
|
|
||||||
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
|
|
||||||
import { categoryInfoMap } from "../categoryPath";
|
|
||||||
import { locationOptions, locationPathById } from "../locationPath";
|
import { locationOptions, locationPathById } from "../locationPath";
|
||||||
import { fmt } from "../units";
|
import { gruppenOptionen } from "../groupGraph";
|
||||||
|
import { anzahlWort, fmt, gebinde, grpFactor, grpPkgMode, grpUnit, kindShort } from "../units";
|
||||||
|
|
||||||
// Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm,
|
// Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm,
|
||||||
// Milliliter, Stück) – NICHT die Packung. „1 Packung" ist nichtssagend, weil eine
|
// Milliliter, Stück) – NICHT die Packung. „1 Packung" ist nichtssagend, weil eine
|
||||||
@@ -18,16 +16,39 @@ const dispLabel = (p) => (p.unit_name || "Stück");
|
|||||||
// Mindestbestand gibt es nur bei Charge-Artikeln: Lebensmittel und
|
// Mindestbestand gibt es nur bei Charge-Artikeln: Lebensmittel und
|
||||||
// Verbrauchsgegenstände (bulk). „Menge je Lagerort" und Einzelstücke nicht.
|
// Verbrauchsgegenstände (bulk). „Menge je Lagerort" und Einzelstücke nicht.
|
||||||
const canHaveMin = (p) => p.tracking !== "object" || p.bulk === true;
|
const canHaveMin = (p) => p.tracking !== "object" || p.bulk === true;
|
||||||
// Artikeleinheit (Packung, sonst Anzeigeeinheit): nur noch nötig, um die je-Lagerort-
|
// Gebinde-Umschaltung: hat das Produkt eine Packungsgröße, lässt sich der
|
||||||
// Mindestbestände umzurechnen, die in Artikeleinheiten gespeichert sind.
|
// Mindestbestand wahlweise in Packungen (Glas/Dose) statt in g/ml erfassen.
|
||||||
const articleUnit = (p) => (p.package_size && p.package_size > 0 ? p.package_size : (p.unit_factor || 1));
|
const hasPkg = (p) => !!(p.package_size && p.package_size > 0);
|
||||||
|
const pkgMode = (p) => hasPkg(p) && !!p.min_stock_in_packages;
|
||||||
|
// Basiseinheiten je gewählter Erfassungseinheit (Packung ODER Anzeigeeinheit).
|
||||||
|
const effFactor = (p) => (pkgMode(p) ? p.package_size : dispFactor(p));
|
||||||
|
// Einheitslabel der gewählten Erfassungseinheit, passend zur Menge (1 Glas / 3 Gläser).
|
||||||
|
const effUnit = (p, menge = 1) => (pkgMode(p) ? gebinde(menge, p.package_label || "Packung") : dispLabel(p));
|
||||||
|
|
||||||
|
// Gruppen rechnen in ihrer verwalteten Einheit ODER im Gruppen-Gebinde –
|
||||||
|
// grpPkgMode/grpFactor/grpUnit stehen in units.js, weil Groups.jsx sie genauso
|
||||||
|
// braucht (und ohne sie den Bestand falsch beschriftet hat).
|
||||||
|
|
||||||
|
// „Überall" ist der Ort NULL. Im <select> braucht es einen Wert, deshalb ein
|
||||||
|
// Platzhalter, der beim Speichern wieder zu null wird.
|
||||||
|
const UEBERALL = "__ueberall__";
|
||||||
|
const zuId = (wert) => (wert === UEBERALL || wert === "" ? null : wert);
|
||||||
|
const vonId = (id) => (id == null ? UEBERALL : String(id));
|
||||||
|
const gleicherOrt = (a, b) => vonId(a) === vonId(b);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zentrale Liste aller Mindestbestände: je Produkt/Gruppe der Gesamt-Wert und die
|
* Mindestbestände – nach LAGERORT gruppiert.
|
||||||
* Bedarfe je Lagerort, inline editierbar. Ergänzt die einzelnen Produkt-/Gruppen-
|
*
|
||||||
* Formulare um einen Überblick an einem Ort.
|
* Ein Mindestbestand ist immer ein Bedarf an einem Ort. „Überall" (egal wo,
|
||||||
|
* Hauptsache im Haus) ist dabei ein Ort wie jeder andere, nämlich der oberste:
|
||||||
|
* Käufe für einen einzelnen Lagerort decken ihn mit ab. Den früheren separaten
|
||||||
|
* „Gesamt"-Wert gibt es deshalb nicht mehr.
|
||||||
|
*
|
||||||
|
* Alle Mengen kommen vom Server in Basiseinheiten und werden hier in die
|
||||||
|
* Erfassungseinheit des Ziels umgerechnet – eine einzige Umrechnung statt der
|
||||||
|
* drei verschiedenen, die es vorher gab.
|
||||||
*/
|
*/
|
||||||
const EMPTY_DRAFT = { kind: "product", targetId: "", scope: "global", locId: "", menge: "" };
|
const EMPTY_DRAFT = { locId: UEBERALL, kind: "product", targetId: "", menge: "", pkg: false };
|
||||||
|
|
||||||
export default function MinStock() {
|
export default function MinStock() {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
@@ -35,195 +56,186 @@ export default function MinStock() {
|
|||||||
const [products, setProducts] = useState([]);
|
const [products, setProducts] = useState([]);
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
const [locations, setLocations] = useState([]);
|
const [locations, setLocations] = useState([]);
|
||||||
const [categories, setCategories] = useState([]);
|
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
// „+ hinzufügen"-Dialog: Ziel (Produkt/Gruppe) → Geltung (Gesamt/Lagerort) → Menge.
|
// „+ Mindestbestand": Ort steht meist schon fest (Abschnitt), dann fehlen nur
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
// noch Ziel und Menge.
|
||||||
const [draft, setDraft] = useState(EMPTY_DRAFT);
|
const [draft, setDraft] = useState(null);
|
||||||
|
// Gruppen-Gebinde festlegen/ändern: { id, size, label, baseShort }.
|
||||||
|
const [gebindeDlg, setGebindeDlg] = useState(null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [ps, gs, ls, cs] = await Promise.all([
|
const [ps, gs, ls] = await Promise.all([
|
||||||
api.listProducts("", ""), api.listGroups(), api.listLocations(), api.listCategories(),
|
api.listProducts("", ""), api.listGroups(), api.listLocations(),
|
||||||
]);
|
]);
|
||||||
setProducts(ps); setGroups(gs); setLocations(ls); setCategories(cs);
|
setProducts(ps); setGroups(gs); setLocations(ls);
|
||||||
} catch (err) { setError(err.message); } finally { setLoading(false); }
|
} catch (err) { setError(err.message); } finally { setLoading(false); }
|
||||||
}
|
}
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
const catInfo = useMemo(() => categoryInfoMap(categories), [categories]);
|
// Eine Zeile je vorhandenem Eintrag – keine Leerzeilen mehr für jeden Artikel.
|
||||||
|
const zeilen = useMemo(() => {
|
||||||
const rows = useMemo(() => {
|
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const p of products.filter(canHaveMin)) {
|
const bauen = (kind, entity, name, faktor, einheit) => {
|
||||||
out.push({
|
for (const e of entity.location_min_stocks || []) {
|
||||||
key: `p:${p.id}:global`, kind: "product", scope: "global", entity: p,
|
const soll = e.min_stock / faktor;
|
||||||
name: p.name, catId: p.category_id, ort: "(Gesamt)",
|
|
||||||
// Alles in der Anzeigeeinheit (Gramm/Milliliter/Stück): min_stock ist in
|
|
||||||
// Basiseinheiten gespeichert.
|
|
||||||
soll: p.min_stock != null ? p.min_stock / dispFactor(p) : null,
|
|
||||||
unit: dispLabel(p),
|
|
||||||
bestand: p.stock / dispFactor(p), bestandUnit: dispLabel(p),
|
|
||||||
});
|
|
||||||
for (const l of (p.location_min_stocks || [])) {
|
|
||||||
out.push({
|
out.push({
|
||||||
key: `p:${p.id}:${l.location_id}`, kind: "product", scope: "loc", entity: p,
|
key: `${kind}:${entity.id}:${vonId(e.location_id)}`,
|
||||||
locId: l.location_id, name: p.name, catId: p.category_id,
|
kind, entity, name, locId: e.location_id,
|
||||||
ort: locationPathById(l.location_id, locations) || "?",
|
soll, unit: einheit(soll),
|
||||||
// je-Lagerort ist in Artikeleinheiten gespeichert → in Anzeigeeinheit umrechnen.
|
bestand: e.stock != null ? e.stock / faktor : null,
|
||||||
soll: (l.min_stock * articleUnit(p)) / dispFactor(p), unit: dispLabel(p), bestand: null,
|
bestandUnit: einheit(e.stock != null ? e.stock / faktor : 1),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
for (const p of products.filter(canHaveMin)) {
|
||||||
|
bauen("product", p, p.name, effFactor(p), (m) => effUnit(p, m));
|
||||||
}
|
}
|
||||||
for (const g of groups) {
|
for (const g of groups) {
|
||||||
out.push({
|
bauen("group", g, g.name, grpFactor(g), (m) => grpUnit(g, m));
|
||||||
key: `g:${g.id}:global`, kind: "group", scope: "global", entity: g,
|
|
||||||
name: g.name, catId: null, ort: "(Gesamt)",
|
|
||||||
soll: g.min_stock, unit: g.min_stock_unit_name || "", bestand: g.stock, bestandUnit: g.min_stock_unit_name || "",
|
|
||||||
});
|
|
||||||
for (const l of (g.location_min_stocks || [])) {
|
|
||||||
out.push({
|
|
||||||
key: `g:${g.id}:${l.location_id}`, kind: "group", scope: "loc", entity: g,
|
|
||||||
locId: l.location_id, name: g.name, catId: null,
|
|
||||||
ort: locationPathById(l.location_id, locations) || "?",
|
|
||||||
soll: l.min_stock, unit: g.min_stock_unit_name || "", bestand: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [products, groups, locations]);
|
}, [products, groups]);
|
||||||
|
|
||||||
// Eine Lagerort-Bedarfsliste mit einem geänderten/neuen/entfernten Eintrag neu
|
// Abschnitte: „Überall" zuerst, danach die Lagerorte alphabetisch nach Pfad.
|
||||||
// bauen. Der Endpunkt ersetzt die komplette Liste – neue Lagerorte müssen also
|
const abschnitte = useMemo(() => {
|
||||||
// ergänzt (nicht nur bestehende geändert) werden, sonst wird nie einer angelegt.
|
const orte = [{ id: UEBERALL, label: "Überall (egal wo)" }, ...locationOptions(locations)];
|
||||||
const mergeLoc = (list, locId, num) => {
|
const belegt = new Set(zeilen.map((r) => vonId(r.locId)));
|
||||||
|
return orte
|
||||||
|
.filter((o) => belegt.has(String(o.id)) || o.id === UEBERALL)
|
||||||
|
.map((o) => ({
|
||||||
|
...o,
|
||||||
|
zeilen: zeilen
|
||||||
|
.filter((r) => vonId(r.locId) === String(o.id))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, "de")),
|
||||||
|
}));
|
||||||
|
}, [zeilen, locations]);
|
||||||
|
|
||||||
|
// Eine Bedarfsliste mit einem geänderten/neuen/entfernten Eintrag neu bauen.
|
||||||
|
// Der Endpunkt ersetzt die komplette Liste – neue Orte müssen also ergänzt
|
||||||
|
// (nicht nur bestehende geändert) werden, sonst wird nie einer angelegt.
|
||||||
|
const mergeLoc = (list, locId, menge) => {
|
||||||
const out = (list || []).map((e) => ({ location_id: e.location_id, min_stock: e.min_stock }));
|
const out = (list || []).map((e) => ({ location_id: e.location_id, min_stock: e.min_stock }));
|
||||||
const hit = out.find((e) => String(e.location_id) === String(locId));
|
const treffer = out.find((e) => gleicherOrt(e.location_id, locId));
|
||||||
if (hit) hit.min_stock = num;
|
if (treffer) treffer.min_stock = menge;
|
||||||
else out.push({ location_id: locId, min_stock: num });
|
else out.push({ location_id: zuId(vonId(locId)), min_stock: menge });
|
||||||
return out.filter((e) => e.min_stock != null && e.min_stock > 0);
|
return out.filter((e) => e.min_stock != null && e.min_stock > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const faktorVon = (row) => (row.kind === "product" ? effFactor(row.entity) : grpFactor(row.entity));
|
||||||
|
|
||||||
|
async function speichern(kind, entity, locId, mengeBase) {
|
||||||
|
const liste = mergeLoc(entity.location_min_stocks, locId, mengeBase);
|
||||||
|
if (kind === "product") await api.setProductLocationMinStock(entity.id, liste);
|
||||||
|
else await api.setGroupLocationMinStock(entity.id, liste);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSoll(row, value) {
|
async function saveSoll(row, value) {
|
||||||
const roh = String(value).trim().replace(",", ".");
|
const roh = String(value).trim().replace(",", ".");
|
||||||
const num = roh === "" ? null : Number(roh);
|
const num = roh === "" ? null : Number(roh);
|
||||||
if (num !== null && (Number.isNaN(num) || num < 0)) return;
|
if (num !== null && (Number.isNaN(num) || num < 0)) return;
|
||||||
if (num === (row.soll ?? null)) return;
|
if (num === (row.soll ?? null)) return;
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
if (row.kind === "product" && row.scope === "global") {
|
await speichern(row.kind, row.entity, row.locId, num == null ? null : num * faktorVon(row));
|
||||||
const p = row.entity;
|
} catch (err) { setError(err.message); }
|
||||||
// Eingabe in der Anzeigeeinheit → Basiseinheiten speichern.
|
}
|
||||||
await api.updateProduct(p.id, {
|
|
||||||
min_stock: num == null ? null : Math.round(num * dispFactor(p)),
|
// Einheit umschalten (Anzeigeeinheit ↔ Packung). Der gespeicherte Wert bleibt
|
||||||
min_stock_in_packages: false,
|
// physisch gleich – er steht in Basiseinheiten, es wechselt nur die Anzeige.
|
||||||
min_stock_unit_id: p.display_unit_id ?? null,
|
async function toggleProductUnit(p, wantPkg) {
|
||||||
});
|
if (wantPkg === pkgMode(p)) return;
|
||||||
} else if (row.kind === "product" && row.scope === "loc") {
|
setError(null);
|
||||||
const p = row.entity;
|
try {
|
||||||
// Eingabe in der Anzeigeeinheit → Artikeleinheiten (so gespeichert).
|
await api.updateProduct(p.id, { min_stock_in_packages: wantPkg });
|
||||||
const artikel = num == null ? null : (num * dispFactor(p)) / articleUnit(p);
|
await load();
|
||||||
await api.setProductLocationMinStock(p.id, mergeLoc(p.location_min_stocks, row.locId, artikel));
|
} catch (err) { setError(err.message); }
|
||||||
} else if (row.kind === "group" && row.scope === "global") {
|
}
|
||||||
await api.updateGroup(row.entity.id, { min_stock: num });
|
|
||||||
} else if (row.kind === "group" && row.scope === "loc") {
|
async function toggleGroupPkg(g, wantPkg) {
|
||||||
await api.setGroupLocationMinStock(row.entity.id, mergeLoc(row.entity.location_min_stocks, row.locId, num));
|
if (wantPkg === grpPkgMode(g)) return;
|
||||||
}
|
if (wantPkg && !(g.package_size > 0)) { openGebinde(g); return; }
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.updateGroup(g.id, { min_stock_in_packages: wantPkg });
|
||||||
|
await load();
|
||||||
|
} catch (err) { setError(err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGebinde(g) {
|
||||||
|
setGebindeDlg({
|
||||||
|
id: g.id, size: g.package_size ?? "", label: g.package_label ?? "",
|
||||||
|
baseShort: kindShort(g.kind),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGebinde() {
|
||||||
|
if (!gebindeDlg) return;
|
||||||
|
const size = Number(String(gebindeDlg.size).trim().replace(",", "."));
|
||||||
|
if (!size || size <= 0) { setError("Bitte eine Gebinde-Größe größer 0 angeben."); return; }
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.updateGroup(gebindeDlg.id, {
|
||||||
|
package_size: size,
|
||||||
|
package_label: (gebindeDlg.label || "Packung").trim(),
|
||||||
|
min_stock_in_packages: true,
|
||||||
|
});
|
||||||
|
setGebindeDlg(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (err) { setError(err.message); }
|
} catch (err) { setError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mindestbestand ganz entfernen: Gesamt-Wert auf NULL, je-Lagerort-Eintrag raus.
|
|
||||||
// Das Feld leeren tut dasselbe – aber ein eigener Knopf macht das Löschen
|
|
||||||
// eindeutig (und bei Gesamt-Werten sichtbar, da die Zeile sonst bestehen bleibt).
|
|
||||||
async function deleteRow(row) {
|
async function deleteRow(row) {
|
||||||
const ziel = row.kind === "group" ? `Gruppe „${row.name}"` : `„${row.name}"`;
|
const ziel = row.kind === "group" ? `Gruppe „${row.name}“` : `„${row.name}“`;
|
||||||
const wo = row.scope === "loc" ? ` am Lagerort „${row.ort}"` : "";
|
const ort = row.locId == null ? "„Überall“" : `am Lagerort „${locationPathById(row.locId, locations)}“`;
|
||||||
if (!(await confirm({
|
if (!(await confirm({
|
||||||
title: "Mindestbestand löschen?",
|
title: "Mindestbestand löschen?",
|
||||||
message: `Mindestbestand von ${ziel}${wo} entfernen?`,
|
message: `Mindestbestand von ${ziel} ${ort} entfernen?`,
|
||||||
confirmLabel: "Löschen", danger: true,
|
confirmLabel: "Löschen", danger: true,
|
||||||
}))) return;
|
}))) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
if (row.kind === "product" && row.scope === "global") {
|
await speichern(row.kind, row.entity, row.locId, null);
|
||||||
await api.updateProduct(row.entity.id, {
|
|
||||||
min_stock: null, min_stock_in_packages: false,
|
|
||||||
min_stock_unit_id: row.entity.display_unit_id ?? null,
|
|
||||||
});
|
|
||||||
} else if (row.kind === "product" && row.scope === "loc") {
|
|
||||||
await api.setProductLocationMinStock(row.entity.id, mergeLoc(row.entity.location_min_stocks, row.locId, null));
|
|
||||||
} else if (row.kind === "group" && row.scope === "global") {
|
|
||||||
await api.updateGroup(row.entity.id, { min_stock: null });
|
|
||||||
} else if (row.kind === "group" && row.scope === "loc") {
|
|
||||||
await api.setGroupLocationMinStock(row.entity.id, mergeLoc(row.entity.location_min_stocks, row.locId, null));
|
|
||||||
}
|
|
||||||
await load();
|
|
||||||
} catch (err) { setError(err.message); }
|
} catch (err) { setError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Einen bestehenden Mindestbestand auf einen anderen Ort verschieben: „(Gesamt)"
|
// Einen Eintrag an einen anderen Ort verschieben. Der Wert bleibt (beides in
|
||||||
// (ziel = "") oder ein Lagerort. Der Wert bleibt, der bisherige Geltungsbereich
|
// Basiseinheiten), der alte Ort fällt weg – so entsteht keine Dublette.
|
||||||
// wird geleert – so entsteht keine Dublette. Produkt-Werte werden zwischen
|
async function moveRow(row, zielOrt) {
|
||||||
// Anzeige-/Artikel-/Basiseinheit umgerechnet (wie in saveSoll), Gruppen nicht.
|
if (gleicherOrt(row.locId, zielOrt)) return;
|
||||||
async function moveRow(row, ziel) {
|
const belegt = (row.entity.location_min_stocks || [])
|
||||||
const aktuell = row.scope === "loc" ? row.locId : "";
|
.some((e) => gleicherOrt(e.location_id, zielOrt));
|
||||||
if (String(ziel) === String(aktuell)) return;
|
if (belegt) {
|
||||||
if (row.soll == null) return; // nichts zu verschieben
|
|
||||||
const ent = row.entity;
|
|
||||||
// Zielort schon mit einem Bedarf belegt? Dann würde er überschrieben.
|
|
||||||
if (ziel && (ent.location_min_stocks || []).some((e) => String(e.location_id) === String(ziel))) {
|
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: "Lagerort schon belegt",
|
title: "Ort schon belegt",
|
||||||
message: `Für „${row.name}" gibt es an diesem Lagerort bereits einen Mindestbestand. Mit dem verschobenen Wert überschreiben?`,
|
message: `Für „${row.name}“ gibt es dort bereits einen Mindestbestand. Mit dem verschobenen Wert überschreiben?`,
|
||||||
confirmLabel: "Überschreiben", danger: true,
|
confirmLabel: "Überschreiben", danger: true,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
if (row.kind === "product") {
|
const base = row.soll * faktorVon(row);
|
||||||
const p = ent;
|
let liste = mergeLoc(row.entity.location_min_stocks, row.locId, null);
|
||||||
const artikel = (row.soll * dispFactor(p)) / articleUnit(p);
|
liste = mergeLoc(liste, zielOrt, base);
|
||||||
let liste = p.location_min_stocks || [];
|
if (row.kind === "product") await api.setProductLocationMinStock(row.entity.id, liste);
|
||||||
if (row.scope === "loc") liste = mergeLoc(liste, row.locId, null); // alten Ort raus
|
else await api.setGroupLocationMinStock(row.entity.id, liste);
|
||||||
if (ziel) liste = mergeLoc(liste, ziel, artikel); // Zielort rein
|
|
||||||
// Gesamt-Wert setzen (Umzug nach Gesamt) oder leeren (Umzug weg von Gesamt).
|
|
||||||
if (row.scope === "global" || !ziel) {
|
|
||||||
await api.updateProduct(p.id, {
|
|
||||||
min_stock: ziel ? null : Math.round(row.soll * dispFactor(p)),
|
|
||||||
min_stock_in_packages: false,
|
|
||||||
min_stock_unit_id: p.display_unit_id ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (row.scope === "loc" || ziel) await api.setProductLocationMinStock(p.id, liste);
|
|
||||||
} else {
|
|
||||||
const g = ent; // Gruppe: Gesamt und Ort teilen dieselbe Einheit – keine Umrechnung
|
|
||||||
let liste = g.location_min_stocks || [];
|
|
||||||
if (row.scope === "loc") liste = mergeLoc(liste, row.locId, null);
|
|
||||||
if (ziel) liste = mergeLoc(liste, ziel, row.soll);
|
|
||||||
if (row.scope === "global" || !ziel) {
|
|
||||||
await api.updateGroup(g.id, { min_stock: ziel ? null : row.soll });
|
|
||||||
}
|
|
||||||
if (row.scope === "loc" || ziel) await api.setGroupLocationMinStock(g.id, liste);
|
|
||||||
}
|
|
||||||
await load();
|
await load();
|
||||||
} catch (err) { setError(err.message); }
|
} catch (err) { setError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ausgewähltes Ziel des Dialogs + dessen Einheit (Menge wird darin erfasst).
|
// ---- Hinzufügen ----
|
||||||
const draftTarget = draft.kind === "product"
|
|
||||||
? products.find((p) => String(p.id) === String(draft.targetId))
|
|
||||||
: groups.find((g) => String(g.id) === String(draft.targetId));
|
|
||||||
const draftUnit = draft.kind === "product"
|
|
||||||
? (draftTarget ? dispLabel(draftTarget) : "")
|
|
||||||
: (draftTarget ? (draftTarget.min_stock_unit_name || "Stück") : "");
|
|
||||||
|
|
||||||
function resetAdd() {
|
const draftTarget = !draft ? null : (draft.kind === "product"
|
||||||
setShowAdd(false);
|
? products.find((p) => String(p.id) === String(draft.targetId))
|
||||||
setDraft(EMPTY_DRAFT);
|
: groups.find((g) => String(g.id) === String(draft.targetId)));
|
||||||
}
|
const draftUsePkg = !!draft && draft.kind === "product" && !!draftTarget && hasPkg(draftTarget) && draft.pkg;
|
||||||
|
const draftUnit = !draftTarget ? "" : (draft.kind === "product"
|
||||||
|
? (draftUsePkg ? (draftTarget.package_label || "Packung") : dispLabel(draftTarget))
|
||||||
|
: (grpUnit(draftTarget) || "Stück"));
|
||||||
|
|
||||||
async function saveNew() {
|
async function saveNew() {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -231,82 +243,87 @@ export default function MinStock() {
|
|||||||
const num = roh === "" ? null : Number(roh);
|
const num = roh === "" ? null : Number(roh);
|
||||||
if (!draft.targetId) { setError("Bitte ein Ziel wählen."); return; }
|
if (!draft.targetId) { setError("Bitte ein Ziel wählen."); return; }
|
||||||
if (num == null || Number.isNaN(num) || num <= 0) { setError("Bitte eine Menge größer 0 angeben."); return; }
|
if (num == null || Number.isNaN(num) || num <= 0) { setError("Bitte eine Menge größer 0 angeben."); return; }
|
||||||
if (draft.scope === "loc" && !draft.locId) { setError("Bitte einen Lagerort wählen."); return; }
|
|
||||||
try {
|
try {
|
||||||
if (draft.kind === "product") {
|
const faktor = draft.kind === "product"
|
||||||
const p = draftTarget;
|
? (draftUsePkg ? draftTarget.package_size : dispFactor(draftTarget))
|
||||||
if (draft.scope === "global") {
|
: grpFactor(draftTarget);
|
||||||
// Menge in der Anzeigeeinheit erfasst → in Basiseinheiten speichern.
|
await speichern(draft.kind, draftTarget, zuId(draft.locId), num * faktor);
|
||||||
await api.updateProduct(p.id, {
|
setDraft(null);
|
||||||
min_stock: Math.round(num * dispFactor(p)),
|
|
||||||
min_stock_in_packages: false,
|
|
||||||
min_stock_unit_id: p.display_unit_id ?? null,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Anzeigeeinheit → Artikeleinheiten (so gespeichert).
|
|
||||||
const artikel = (num * dispFactor(p)) / articleUnit(p);
|
|
||||||
await api.setProductLocationMinStock(p.id, mergeLoc(p.location_min_stocks, draft.locId, artikel));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const g = draftTarget;
|
|
||||||
if (draft.scope === "global") {
|
|
||||||
await api.updateGroup(g.id, { min_stock: num });
|
|
||||||
} else {
|
|
||||||
await api.setGroupLocationMinStock(g.id, mergeLoc(g.location_min_stocks, draft.locId, num));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resetAdd();
|
|
||||||
await load();
|
|
||||||
} catch (err) { setError(err.message); }
|
} catch (err) { setError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const zielListe = !draft ? [] : (draft.kind === "product"
|
||||||
{ key: "art", header: "Art", width: 96, filterText: (r) => (r.kind === "group" ? "Gruppe" : "Produkt"),
|
? products.filter(canHaveMin).slice().sort((a, b) => a.name.localeCompare(b.name, "de"))
|
||||||
render: (r) => <span className="badge nowrap">{r.kind === "group" ? "Gruppe" : "Produkt"}</span> },
|
: groups.slice().sort((a, b) => a.name.localeCompare(b.name, "de")));
|
||||||
{ key: "name", header: "Name", grow: true, min: 180,
|
|
||||||
filterText: (r) => r.name, sortValue: (r) => r.name,
|
function zeile(r) {
|
||||||
render: (r) => <span className="strong">{r.name}</span> },
|
const g = r.kind === "group" ? r.entity : null;
|
||||||
{ key: "category", header: "Kategorie", width: 200,
|
return (
|
||||||
filterText: (r) => (r.catId != null ? (catInfo.get(r.catId)?.path || "") : ""),
|
<div className="min-zeile" key={r.key}>
|
||||||
filterValues: (r) => (r.catId != null ? (catInfo.get(r.catId)?.tokens || [""]) : [""]),
|
<span className="name">
|
||||||
filterOptionLabel: (v) => (v === "" ? "(Leere)" : <CategoryPathLabel parts={pathParts(v)} />),
|
<span className="strong">{r.name}</span>
|
||||||
render: (r) => (r.catId != null
|
{g && <span className="badge accent" style={{ marginLeft: 6 }}>Gruppe</span>}
|
||||||
? <CategoryPathLabel parts={catInfo.get(r.catId)?.parts} fallback="–" />
|
{g?.child_ids?.length > 0 && (
|
||||||
: <span className="muted">–</span>) },
|
<span className="badge" style={{ marginLeft: 6 }}
|
||||||
{ key: "ort", header: "Ort", width: 240, filterText: (r) => r.ort, sortValue: (r) => r.ort,
|
title="Bestand und Mindestbestand zählen die Untergruppen mit.">
|
||||||
render: (r) => {
|
inkl. {anzahlWort(g.child_ids.length, "Untergruppe", "Untergruppen")}
|
||||||
// Editierbar, wenn es einen Wert zu verschieben gibt (leere Produktzeilen
|
</span>
|
||||||
// ohne Bedarf bleiben Text – dort legt man über „+ Mindestbestand" an).
|
)}
|
||||||
if (!isAdmin || r.soll == null) {
|
</span>
|
||||||
return r.scope === "global" ? <span className="muted">{r.ort}</span> : r.ort;
|
|
||||||
}
|
{isAdmin ? (
|
||||||
return (
|
<input type="number" step="any" min="0"
|
||||||
<select value={r.scope === "loc" ? r.locId : ""} style={{ marginTop: 0, minWidth: 150 }}
|
|
||||||
title="Lagerort ändern – der Mindestbestand wird verschoben"
|
|
||||||
onChange={(e) => moveRow(r, e.target.value)}>
|
|
||||||
<option value="">(Gesamt)</option>
|
|
||||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
} },
|
|
||||||
{ key: "soll", header: "Mindestbestand", width: 190,
|
|
||||||
sortValue: (r) => r.soll ?? -1,
|
|
||||||
render: (r) => (isAdmin ? (
|
|
||||||
<div className="field-inline" style={{ gap: "var(--sp-1)", flexWrap: "nowrap" }}>
|
|
||||||
<input type="number" step="any" min="0" style={{ marginTop: 0, minWidth: 80 }}
|
|
||||||
key={r.soll ?? "none"} defaultValue={r.soll ?? ""}
|
key={r.soll ?? "none"} defaultValue={r.soll ?? ""}
|
||||||
onBlur={(e) => saveSoll(r, e.target.value)} />
|
onBlur={(e) => saveSoll(r, e.target.value)} />
|
||||||
{r.soll != null && (
|
) : <span>{fmt(r.soll)}</span>}
|
||||||
|
|
||||||
|
{/* Einheit: bei Packungsartikeln und Gruppen mit Einheit umschaltbar. */}
|
||||||
|
{isAdmin && r.kind === "product" && hasPkg(r.entity) ? (
|
||||||
|
<select value={pkgMode(r.entity) ? "pkg" : "disp"}
|
||||||
|
title="Einheit für den Mindestbestand umschalten"
|
||||||
|
onChange={(e) => toggleProductUnit(r.entity, e.target.value === "pkg")}>
|
||||||
|
<option value="disp">{dispLabel(r.entity)}</option>
|
||||||
|
<option value="pkg">{r.entity.package_label || "Packung"}</option>
|
||||||
|
</select>
|
||||||
|
) : isAdmin && g && g.min_stock_unit_id ? (
|
||||||
|
<select value={grpPkgMode(g) ? "pkg" : "unit"}
|
||||||
|
title="Einheit für den Mindestbestand umschalten"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (v === "unit") toggleGroupPkg(g, false);
|
||||||
|
else if (v === "pkg") toggleGroupPkg(g, true);
|
||||||
|
else openGebinde(g);
|
||||||
|
}}>
|
||||||
|
<option value="unit">{g.min_stock_unit_name || "Einheit"}</option>
|
||||||
|
{g.package_size > 0 && <option value="pkg">{g.package_label || "Packung"}</option>}
|
||||||
|
<option value="edit">{g.package_size > 0 ? "Gebinde ändern…" : "Gebinde festlegen…"}</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="muted small">{r.unit || "–"}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="muted small bestand">
|
||||||
|
{r.bestand != null ? `Bestand ${fmt(r.bestand)} ${r.bestandUnit || ""}` : ""}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<select value={vonId(r.locId)}
|
||||||
|
title="Ort ändern – der Mindestbestand wird verschoben"
|
||||||
|
onChange={(e) => moveRow(r, e.target.value)}>
|
||||||
|
<option value={UEBERALL}>Überall</option>
|
||||||
|
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
<button type="button" className="btn-icon danger" title="Mindestbestand löschen"
|
<button type="button" className="btn-icon danger" title="Mindestbestand löschen"
|
||||||
onClick={() => deleteRow(r)}><Icon name="trash" size={15} /></button>
|
onClick={() => deleteRow(r)}><Icon name="trash" size={15} /></button>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
) : (r.soll != null ? fmt(r.soll) : "–")) },
|
{/* Ohne Adminrechte bleiben die letzten beiden Spalten leer, damit die
|
||||||
{ key: "unit", header: "Einheit", width: 120, filterText: (r) => r.unit || "",
|
Zeilen trotzdem fluchten. */}
|
||||||
render: (r) => <span className="muted">{r.unit || "–"}</span> },
|
{!isAdmin && (<><span /><span /></>)}
|
||||||
{ key: "bestand", header: "Bestand", width: 120, align: "num",
|
</div>
|
||||||
render: (r) => (r.bestand != null ? `${fmt(r.bestand)} ${r.bestandUnit || ""}` : <span className="muted">–</span>) },
|
);
|
||||||
];
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -314,27 +331,35 @@ export default function MinStock() {
|
|||||||
<div>
|
<div>
|
||||||
<h1>Mindestbestände</h1>
|
<h1>Mindestbestände</h1>
|
||||||
<div className="sub">
|
<div className="sub">
|
||||||
Gesamt- und Lagerort-Bedarfe von Produkten und Gruppen an einem Ort{isAdmin ? " – Werte direkt editierbar" : ""}.
|
Was soll wo immer da sein? „Überall“ heißt: egal wo, Hauptsache im Haus —
|
||||||
|
Käufe für einen Lagerort decken ihn mit ab.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button className="btn primary" onClick={() => (showAdd ? resetAdd() : setShowAdd(true))}>
|
<button className="btn primary" onClick={() => setDraft(draft ? null : { ...EMPTY_DRAFT })}>
|
||||||
<Icon name={showAdd ? "close" : "plus"} size={16} />
|
<Icon name={draft ? "close" : "plus"} size={16} />
|
||||||
{showAdd ? "Abbrechen" : "Mindestbestand"}
|
{draft ? "Abbrechen" : "Mindestbestand"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</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>}
|
||||||
|
|
||||||
{isAdmin && showAdd && (
|
{isAdmin && draft && (
|
||||||
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
|
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
|
||||||
<div className="card-head"><Icon name="plus" /><h2>Mindestbestand hinzufügen</h2></div>
|
<div className="card-head"><Icon name="plus" /><h2>Mindestbestand hinzufügen</h2></div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
|
<label className="grow" style={{ maxWidth: 360 }}>
|
||||||
|
Wo
|
||||||
|
<select value={draft.locId} onChange={(e) => setDraft({ ...draft, locId: e.target.value })}>
|
||||||
|
<option value={UEBERALL}>Überall (egal wo)</option>
|
||||||
|
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="seg-label">
|
<label className="seg-label">
|
||||||
Ziel
|
Was
|
||||||
<div className="segmented">
|
<div className="segmented">
|
||||||
<button type="button" className={draft.kind === "product" ? "active" : ""}
|
<button type="button" className={draft.kind === "product" ? "active" : ""}
|
||||||
onClick={() => setDraft({ ...draft, kind: "product", targetId: "" })}>Gegenstand / Lebensmittel</button>
|
onClick={() => setDraft({ ...draft, kind: "product", targetId: "" })}>Artikel</button>
|
||||||
<button type="button" className={draft.kind === "group" ? "active" : ""}
|
<button type="button" className={draft.kind === "group" ? "active" : ""}
|
||||||
onClick={() => setDraft({ ...draft, kind: "group", targetId: "" })}>Gruppe</button>
|
onClick={() => setDraft({ ...draft, kind: "group", targetId: "" })}>Gruppe</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,42 +367,39 @@ export default function MinStock() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
{draft.kind === "product" ? "Produkt / Gegenstand" : "Gruppe"}
|
{draft.kind === "product" ? "Lebensmittel / Verbrauchsgegenstand" : "Gruppe"}
|
||||||
<select value={draft.targetId} onChange={(e) => setDraft({ ...draft, targetId: e.target.value })}>
|
<select value={draft.targetId} onChange={(e) => setDraft({ ...draft, targetId: e.target.value })}>
|
||||||
<option value="">– wählen –</option>
|
<option value="">– wählen –</option>
|
||||||
{(draft.kind === "product"
|
{/* Gruppen als Baum, damit sichtbar ist, was unter was hängt. */}
|
||||||
? products.filter(canHaveMin).sort((a, b) => a.name.localeCompare(b.name, "de"))
|
{draft.kind === "group"
|
||||||
: [...groups].sort((a, b) => a.name.localeCompare(b.name, "de"))
|
? gruppenOptionen(groups).map((o) => (
|
||||||
).map((t) => (
|
<option key={o.key} value={o.id}>
|
||||||
<option key={t.id} value={t.id}>
|
{o.label}
|
||||||
{t.name}{draft.kind === "product" && t.brand ? ` · ${t.brand}` : ""}
|
{o.gruppe.child_ids?.length
|
||||||
</option>
|
? ` (inkl. ${anzahlWort(o.gruppe.child_ids.length, "Untergruppe", "Untergruppen")})` : ""}
|
||||||
))}
|
</option>
|
||||||
|
))
|
||||||
|
: zielListe.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}{t.brand ? ` · ${t.brand}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="row">
|
{draft.kind === "product" && draftTarget && hasPkg(draftTarget) && (
|
||||||
<label className="seg-label">
|
<div className="row">
|
||||||
Geltung
|
<label className="seg-label">
|
||||||
<div className="segmented">
|
Einheit
|
||||||
<button type="button" className={draft.scope === "global" ? "active" : ""}
|
<div className="segmented">
|
||||||
onClick={() => setDraft({ ...draft, scope: "global", locId: "" })}>Gesamt</button>
|
<button type="button" className={!draft.pkg ? "active" : ""}
|
||||||
<button type="button" className={draft.scope === "loc" ? "active" : ""}
|
onClick={() => setDraft({ ...draft, pkg: false })}>{dispLabel(draftTarget)}</button>
|
||||||
onClick={() => setDraft({ ...draft, scope: "loc" })}>Lagerort</button>
|
<button type="button" className={draft.pkg ? "active" : ""}
|
||||||
</div>
|
onClick={() => setDraft({ ...draft, pkg: true })}>{draftTarget.package_label || "Packung"}</button>
|
||||||
</label>
|
</div>
|
||||||
{draft.scope === "loc" && (
|
|
||||||
<label className="grow" style={{ maxWidth: 360 }}>
|
|
||||||
Lagerort
|
|
||||||
<select value={draft.locId} onChange={(e) => setDraft({ ...draft, locId: e.target.value })}>
|
|
||||||
<option value="">– wählen –</option>
|
|
||||||
{locations.map((l) => (
|
|
||||||
<option key={l.id} value={l.id}>{locationPathById(l.id, locations)}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="grow" style={{ maxWidth: 300 }}>
|
<label className="grow" style={{ maxWidth: 300 }}>
|
||||||
Menge{draftUnit ? ` (in ${draftUnit})` : ""}
|
Menge{draftUnit ? ` (in ${draftUnit})` : ""}
|
||||||
@@ -387,15 +409,62 @@ export default function MinStock() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
||||||
<button className="btn primary" onClick={saveNew}>Speichern</button>
|
<button className="btn primary" onClick={saveNew}>Speichern</button>
|
||||||
<button className="btn" onClick={resetAdd}>Abbrechen</button>
|
<button className="btn" onClick={() => setDraft(null)}>Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card">
|
{isAdmin && gebindeDlg && (
|
||||||
<DataTable id="min-stock" columns={columns} rows={rows} loading={loading}
|
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
|
||||||
getRowKey={(r) => r.key} empty="Noch keine Mindestbestände." />
|
<div className="card-head"><Icon name="package" /><h2>Gruppen-Gebinde festlegen</h2></div>
|
||||||
</div>
|
<p className="muted small" style={{ marginTop: 0 }}>
|
||||||
|
Wie viel zählt „1 Packung“ dieser Gruppe? Ein Richtwert – die Produkte
|
||||||
|
der Gruppe dürfen unterschiedlich große Packungen haben.
|
||||||
|
</p>
|
||||||
|
<div className="row">
|
||||||
|
<label style={{ maxWidth: 200 }}>
|
||||||
|
Bezeichnung
|
||||||
|
<input value={gebindeDlg.label} placeholder="z.B. Glas"
|
||||||
|
onChange={(e) => setGebindeDlg({ ...gebindeDlg, label: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label style={{ maxWidth: 220 }}>
|
||||||
|
Größe{gebindeDlg.baseShort ? ` (in ${gebindeDlg.baseShort})` : ""}
|
||||||
|
<input type="number" step="any" min="0" value={gebindeDlg.size} placeholder="z.B. 150"
|
||||||
|
onChange={(e) => setGebindeDlg({ ...gebindeDlg, size: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
||||||
|
<button className="btn primary" onClick={saveGebinde}>Speichern</button>
|
||||||
|
<button className="btn" onClick={() => setGebindeDlg(null)}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <div className="card"><p className="muted">Wird geladen…</p></div>}
|
||||||
|
|
||||||
|
{!loading && abschnitte.map((a) => (
|
||||||
|
<section className="card" key={a.id} style={{ marginBottom: "var(--sp-3)" }}>
|
||||||
|
<div className="card-head">
|
||||||
|
<Icon name="location" />
|
||||||
|
<h2>{a.label}</h2>
|
||||||
|
<span className="muted small">{a.zeilen.length} {a.zeilen.length === 1 ? "Eintrag" : "Einträge"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stack">
|
||||||
|
{a.zeilen.length === 0 && (
|
||||||
|
<p className="muted small mt-0">Hier ist noch nichts hinterlegt.</p>
|
||||||
|
)}
|
||||||
|
{a.zeilen.map(zeile)}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="field-inline" style={{ marginBottom: 0 }}>
|
||||||
|
<button type="button" className="btn"
|
||||||
|
onClick={() => setDraft({ ...EMPTY_DRAFT, locId: String(a.id) })}>
|
||||||
|
<Icon name="plus" size={16} />hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,17 @@ import ObjektBestand from "../components/ObjektBestand";
|
|||||||
import Einzelstuecke from "../components/Einzelstuecke";
|
import Einzelstuecke from "../components/Einzelstuecke";
|
||||||
import SplitLotDialog from "../components/SplitLotDialog";
|
import SplitLotDialog from "../components/SplitLotDialog";
|
||||||
import { locationOptions, locationPathById } from "../locationPath";
|
import { locationOptions, locationPathById } from "../locationPath";
|
||||||
|
import { gruppenOptionen } from "../groupGraph";
|
||||||
import {
|
import {
|
||||||
daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry,
|
BASE_UNITS, daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired,
|
||||||
toMonthInput, unitShort,
|
relativeExpiry, toMonthInput, unitShort, zweitFaktor,
|
||||||
} from "../units";
|
} from "../units";
|
||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
barcode: "", name: "", brand: "", image_url: "",
|
barcode: "", name: "", brand: "", image_url: "",
|
||||||
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "",
|
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "",
|
||||||
group_id: "", category_id: "", shop_id: "", product_url: "", individual: false, bulk: false,
|
group_id: "", category_id: "", shop_id: "", product_url: "", individual: false, bulk: false,
|
||||||
|
secondary_base: "", secondary_count: "", secondary_amount: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes)
|
// (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes)
|
||||||
@@ -429,6 +431,10 @@ export default function ProductForm() {
|
|||||||
product_url: p.product_url || "",
|
product_url: p.product_url || "",
|
||||||
individual: Boolean(p.individual),
|
individual: Boolean(p.individual),
|
||||||
bulk: Boolean(p.bulk),
|
bulk: Boolean(p.bulk),
|
||||||
|
// Zweiteinheit so, wie sie eingegeben wurde („3 ≙ 250").
|
||||||
|
secondary_base: p.secondary_base || "",
|
||||||
|
secondary_count: p.secondary_count ?? "",
|
||||||
|
secondary_amount: p.secondary_amount ?? "",
|
||||||
});
|
});
|
||||||
setFieldValues(p.field_values || {});
|
setFieldValues(p.field_values || {});
|
||||||
setModus(p.tracking === "object" ? "object" : "food");
|
setModus(p.tracking === "object" ? "object" : "food");
|
||||||
@@ -500,6 +506,14 @@ export default function ProductForm() {
|
|||||||
package_label: form.package_label.trim() || null,
|
package_label: form.package_label.trim() || null,
|
||||||
date_precision: form.date_precision || "day",
|
date_precision: form.date_precision || "day",
|
||||||
group_id: form.group_id === "" ? null : Number(form.group_id),
|
group_id: form.group_id === "" ? null : Number(form.group_id),
|
||||||
|
// Zweiteinheit nur vollstaendig – eine halbe Angabe waere keine Bruecke.
|
||||||
|
...(form.secondary_base && form.secondary_count !== "" && form.secondary_amount !== ""
|
||||||
|
? {
|
||||||
|
secondary_base: form.secondary_base,
|
||||||
|
secondary_count: Number(form.secondary_count),
|
||||||
|
secondary_amount: Number(form.secondary_amount),
|
||||||
|
}
|
||||||
|
: { secondary_base: null }),
|
||||||
...minStock,
|
...minStock,
|
||||||
...(isObject ? { field_values: fieldPayload } : {}),
|
...(isObject ? { field_values: fieldPayload } : {}),
|
||||||
};
|
};
|
||||||
@@ -517,6 +531,8 @@ export default function ProductForm() {
|
|||||||
min_stock: null,
|
min_stock: null,
|
||||||
min_stock_unit_id: null,
|
min_stock_unit_id: null,
|
||||||
min_stock_in_packages: false,
|
min_stock_in_packages: false,
|
||||||
|
// Zweiteinheit gibt es nur bei Charge-Artikeln.
|
||||||
|
secondary_base: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,6 +589,55 @@ export default function ProductForm() {
|
|||||||
|
|
||||||
const readOnly = !isAdmin;
|
const readOnly = !isAdmin;
|
||||||
const BASE_SHORT_OF_KIND = { count: "Stk", weight: "g", volume: "ml" };
|
const BASE_SHORT_OF_KIND = { count: "Stk", weight: "g", volume: "ml" };
|
||||||
|
// Basiseinheit je Art – die eigene Art fliegt aus der Zweiteinheit-Auswahl.
|
||||||
|
const BASE_OF_KIND = { count: "piece", weight: "gram", volume: "milliliter" };
|
||||||
|
// Die Umrechnung heißt nach dem, was sie liefert – „Zweiteinheit" musste man
|
||||||
|
// erst übersetzen. Ohne gewählte Zielart ein neutraler Titel.
|
||||||
|
const ZWEIT_TITEL = { gram: "Gewicht", milliliter: "Volumen", piece: "Stückzahl" };
|
||||||
|
// Verb passend zur Zielart und zur Anzahl: „1 Stück wiegt" / „3 Stück wiegen".
|
||||||
|
const ZWEIT_VERB = {
|
||||||
|
gram: ["wiegt", "wiegen"], milliliter: ["fasst", "fassen"], piece: ["ist", "sind"],
|
||||||
|
};
|
||||||
|
const zweitTitel = ZWEIT_TITEL[form.secondary_base] || "Umrechnung";
|
||||||
|
const zweitVerb = (() => {
|
||||||
|
const paar = ZWEIT_VERB[form.secondary_base];
|
||||||
|
if (!paar) return "";
|
||||||
|
return Number(form.secondary_count) === 1 ? paar[0] : paar[1];
|
||||||
|
})();
|
||||||
|
// Das Gebinde beim Namen nennen: „1 Packung sind …". Ohne Auswahl der
|
||||||
|
// Standard, sonst stünde dort „1 sind 3 Stück".
|
||||||
|
const gebindeName = form.package_label.trim() || "Packung";
|
||||||
|
|
||||||
|
// Zielart wählen: die linke Zahl einmalig mit der Packungsgröße vorbelegen –
|
||||||
|
// im Normalfall ist sie genau das („1 Packung = 3 Stück, und die wiegen
|
||||||
|
// 250 g"). BEWUSST keine dauerhafte Kopplung: wer die Packung später auf 4
|
||||||
|
// ändert, hat dieselbe Wurst, und ein mitgezogener Wert würde stillschweigend
|
||||||
|
// behaupten, sie sei leichter geworden.
|
||||||
|
function waehleZweitart(basis) {
|
||||||
|
if (basis && form.secondary_count === "") {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
secondary_base: basis,
|
||||||
|
secondary_count: f.package_size !== "" ? f.package_size : "1",
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set("secondary_base", basis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ausgerechneter Wert als Kontrolle: „1 Stk ≈ 83,33 g".
|
||||||
|
const zweitHinweis = (() => {
|
||||||
|
const f = zweitFaktor({
|
||||||
|
secondary_base: form.secondary_base,
|
||||||
|
secondary_count: form.secondary_count,
|
||||||
|
secondary_amount: form.secondary_amount,
|
||||||
|
});
|
||||||
|
if (!f) return "";
|
||||||
|
return `1 ${BASE_SHORT_OF_KIND[selectedUnit?.kind] || "Einheit"} \u2248 `
|
||||||
|
+ `${fmt(f)} ${unitShort(form.secondary_base)}. `
|
||||||
|
+ "Damit zählt der Artikel auch in Gruppen, die in dieser Einheit rechnen. "
|
||||||
|
+ "Bestände bleiben beim Ändern unverändert – nur ihre Umrechnung verschiebt sich.";
|
||||||
|
})();
|
||||||
const baseShort = selectedUnit
|
const baseShort = selectedUnit
|
||||||
? BASE_SHORT_OF_KIND[selectedUnit.kind]
|
? BASE_SHORT_OF_KIND[selectedUnit.kind]
|
||||||
: product ? unitShort(product.base_unit) : "";
|
: product ? unitShort(product.base_unit) : "";
|
||||||
@@ -770,9 +835,17 @@ export default function ProductForm() {
|
|||||||
{/* Lebensmittel ODER Verbrauchsgegenstand: Einheit, Packung, MHD,
|
{/* Lebensmittel ODER Verbrauchsgegenstand: Einheit, Packung, MHD,
|
||||||
Mindestbestand (mit Einheit), Gruppe, Mindestbestand je Lagerort. */}
|
Mindestbestand (mit Einheit), Gruppe, Mindestbestand je Lagerort. */}
|
||||||
{foodLike && (<>
|
{foodLike && (<>
|
||||||
|
{/* Alles rund um Einheit, Packung und Umrechnung steht zusammen und
|
||||||
|
liest sich als Satz. Vorher lagen die Felder verstreut, und die
|
||||||
|
Packungsgroesse stand als blanke Zahl neben einer zweiten, gleich
|
||||||
|
aussehenden Zahl aus der Umrechnung – das war nicht zu trennen. */}
|
||||||
|
<div className="card-head" style={{ marginBottom: "var(--sp-1)" }}>
|
||||||
|
<Icon name="package" size={16} />
|
||||||
|
<h3 style={{ margin: 0 }}>Einheit und Menge</h3>
|
||||||
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
Einheit
|
Gezählt wird in
|
||||||
<select value={form.unit_id} onChange={(e) => set("unit_id", e.target.value)} disabled={readOnly} required>
|
<select value={form.unit_id} onChange={(e) => set("unit_id", e.target.value)} disabled={readOnly} required>
|
||||||
<option value="">– wählen –</option>
|
<option value="">– wählen –</option>
|
||||||
{KIND_ORDER.filter((k) => units.some((u) => u.kind === k)).map((k) => (
|
{KIND_ORDER.filter((k) => units.some((u) => u.kind === k)).map((k) => (
|
||||||
@@ -784,13 +857,8 @@ export default function ProductForm() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="grow">
|
{/* Gebinde VOR der Packungsgröße: die Zeile darunter nennt es beim
|
||||||
Packungsgröße{baseShort ? ` (in ${baseShort})` : ""}
|
Namen („1 Packung sind …"). */}
|
||||||
<input type="number" step="any" value={form.package_size}
|
|
||||||
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} placeholder="z.B. 500" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="row">
|
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
<span className="tip" title="Verwaltet unter Verwaltung → Gebinde, dort auch mit Mehrzahl.">
|
<span className="tip" title="Verwaltet unter Verwaltung → Gebinde, dort auch mit Mehrzahl.">
|
||||||
Gebinde
|
Gebinde
|
||||||
@@ -808,6 +876,83 @@ export default function ProductForm() {
|
|||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow">
|
||||||
|
1 {gebindeName} sind
|
||||||
|
<div className="field-inline">
|
||||||
|
<input type="number" step="any" style={{ maxWidth: 120 }} value={form.package_size}
|
||||||
|
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} placeholder="z.B. 500" />
|
||||||
|
<span className="muted small" style={{ alignSelf: "center" }}>{baseShort}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Umrechnung in die ANDERE Art: „3 Stück wiegen 250 g". Solange
|
||||||
|
keine Zielart gewählt ist, steht hier nur eine Auswahl. */}
|
||||||
|
<div className="card-head" style={{ marginBottom: "var(--sp-1)", marginTop: "var(--sp-2)" }}>
|
||||||
|
<Icon name="split" size={16} />
|
||||||
|
<h3 style={{ margin: 0 }}>{zweitTitel} (optional)</h3>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<label className="grow" style={{ maxWidth: 260 }}>
|
||||||
|
<span className="tip" title="Zweite Lesart desselben Artikels – damit zählt er auch in Gruppen, die in einer anderen Einheit rechnen.">
|
||||||
|
Umrechnen in
|
||||||
|
</span>
|
||||||
|
<select value={form.secondary_base} disabled={readOnly}
|
||||||
|
onChange={(e) => waehleZweitart(e.target.value)}>
|
||||||
|
<option value="">– keine –</option>
|
||||||
|
{BASE_UNITS
|
||||||
|
.filter((b) => !selectedUnit || b.value !== BASE_OF_KIND[selectedUnit.kind])
|
||||||
|
.map((b) => <option key={b.value} value={b.value}>{b.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/* Der Satz traegt sich selbst – eine Beschriftung davor waere nur
|
||||||
|
ein weiteres Wort zwischen zwei Zahlen. */}
|
||||||
|
{form.secondary_base && (
|
||||||
|
<div className="row">
|
||||||
|
<div className="field-inline">
|
||||||
|
<input type="number" step="any" min="0" style={{ maxWidth: 90 }}
|
||||||
|
placeholder="3" value={form.secondary_count} disabled={readOnly}
|
||||||
|
onChange={(e) => set("secondary_count", e.target.value)} />
|
||||||
|
<span style={{ alignSelf: "center" }}>
|
||||||
|
{baseShort || "Einheiten"} {zweitVerb}
|
||||||
|
</span>
|
||||||
|
<input type="number" step="any" min="0" style={{ maxWidth: 90 }}
|
||||||
|
placeholder="250" value={form.secondary_amount} disabled={readOnly}
|
||||||
|
onChange={(e) => set("secondary_amount", e.target.value)} />
|
||||||
|
<span style={{ alignSelf: "center" }}>{unitShort(form.secondary_base)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{zweitHinweis && <p className="muted small mt-0">{zweitHinweis}</p>}
|
||||||
|
<div className="row">
|
||||||
|
{/* Beim Anlegen gibt es den Artikel noch nicht, also auch keine
|
||||||
|
Ort-Liste – hier bleibt das eine Feld, das als „Überall“ landet.
|
||||||
|
Beim Bearbeiten steht darunter die vollständige Ort-Liste. */}
|
||||||
|
{isNew && (
|
||||||
|
<label className="grow">
|
||||||
|
<span className="tip" title="Gilt „überall“ – egal wo im Haus. Weitere Orte lassen sich nach dem Anlegen ergänzen.">
|
||||||
|
Mindestbestand (überall)
|
||||||
|
</span>
|
||||||
|
<div className="field-inline">
|
||||||
|
<input type="number" step="any" value={form.min_stock}
|
||||||
|
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
|
||||||
|
<select value={minUnit || form.unit_id} onChange={(e) => changeMinUnit(e.target.value)}
|
||||||
|
disabled={readOnly} style={{ marginTop: 0 }}>
|
||||||
|
{units
|
||||||
|
.filter((u) => !selectedUnit || u.kind === selectedUnit.kind)
|
||||||
|
.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
|
{form.package_size && (
|
||||||
|
<option value="package">
|
||||||
|
{form.package_label.trim() || "Packung"} à {fmt(form.package_size)} {baseShort}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
<span className="tip" title="Voreinstellung beim Einlagern – z.B. Konserven tragen oft nur „09/2026“.">
|
<span className="tip" title="Voreinstellung beim Einlagern – z.B. Konserven tragen oft nur „09/2026“.">
|
||||||
MHD-Angabe
|
MHD-Angabe
|
||||||
@@ -818,33 +963,15 @@ export default function ProductForm() {
|
|||||||
<option value="month">nur Monat/Jahr</option>
|
<option value="month">nur Monat/Jahr</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
<div className="row">
|
|
||||||
<label className="grow">
|
|
||||||
Mindestbestand
|
|
||||||
<div className="field-inline">
|
|
||||||
<input type="number" step="any" value={form.min_stock}
|
|
||||||
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
|
|
||||||
<select value={minUnit || form.unit_id} onChange={(e) => changeMinUnit(e.target.value)}
|
|
||||||
disabled={readOnly} style={{ marginTop: 0 }}>
|
|
||||||
{units
|
|
||||||
.filter((u) => !selectedUnit || u.kind === selectedUnit.kind)
|
|
||||||
.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
|
||||||
{form.package_size && (
|
|
||||||
<option value="package">
|
|
||||||
{form.package_label.trim() || "Packung"} à {fmt(form.package_size)} {baseShort}
|
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
<span className="tip" title="Zählt Bestände mehrerer Artikel zusammen. Der EAN-Code dieses Artikels erscheint danach automatisch bei der Gruppe.">
|
<span className="tip" title="Zählt Bestände mehrerer Artikel zusammen. Der EAN-Code dieses Artikels erscheint danach automatisch bei der Gruppe.">
|
||||||
Gruppe
|
Gruppe
|
||||||
</span>
|
</span>
|
||||||
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
|
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)} disabled={readOnly}>
|
||||||
<option value="">– keine –</option>
|
<option value="">– keine –</option>
|
||||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
{gruppenOptionen(groups).map((o) => (
|
||||||
|
<option key={o.key} value={o.id}>{o.label}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -853,25 +980,27 @@ export default function ProductForm() {
|
|||||||
<div style={{ marginTop: "var(--sp-2)", marginBottom: "var(--sp-5)" }}>
|
<div style={{ marginTop: "var(--sp-2)", marginBottom: "var(--sp-5)" }}>
|
||||||
<div className="card-head" style={{ marginBottom: "var(--sp-1)" }}>
|
<div className="card-head" style={{ marginBottom: "var(--sp-1)" }}>
|
||||||
<Icon name="location" size={16} />
|
<Icon name="location" size={16} />
|
||||||
<h3 style={{ margin: 0 }}>Mindestbestand je Lagerort</h3>
|
<h3 style={{ margin: 0 }}>Mindestbestand</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted small mt-0">
|
<p className="muted small mt-0">
|
||||||
Zusätzlich zum Gesamt-Mindestbestand: eigener Bedarf je Lagerort (z.B.
|
Was soll wo immer da sein? „Überall“ heißt: egal wo, Hauptsache im
|
||||||
Ferienhaus, Zuhause). Menge in {product.unit_name || "Stück"}.
|
Haus — Käufe für einen Lagerort decken ihn mit ab. Menge in{" "}
|
||||||
Wird separat gespeichert.
|
{product.unit_name || "Stück"}. Wird sofort gespeichert.
|
||||||
</p>
|
</p>
|
||||||
<LocationMinStock
|
<LocationMinStock
|
||||||
locations={locations}
|
locations={locations}
|
||||||
|
// Der Server liefert Basiseinheiten; hier in der Anzeigeeinheit
|
||||||
|
// des Artikels erfassen (g/ml/Stück), wie die Beschriftung sagt.
|
||||||
initial={(product.location_min_stocks || []).map(
|
initial={(product.location_min_stocks || []).map(
|
||||||
(e) => ({ ...e, min_stock: e.min_stock * artToDisp(product) }))}
|
(e) => ({ ...e, min_stock: e.min_stock / (product.unit_factor || 1) }))}
|
||||||
unitLabel={product.unit_name || "Stück"}
|
unitLabel={product.unit_name || "Stück"}
|
||||||
onError={(m) => toast(m, "warn")}
|
onError={(m) => toast(m, "warn")}
|
||||||
onSave={async (list) => {
|
onSave={async (list) => {
|
||||||
const f = artToDisp(product);
|
const f = product.unit_factor || 1;
|
||||||
const artList = list.map((e) => ({ ...e, min_stock: e.min_stock / f }));
|
const basisListe = list.map((e) => ({ ...e, min_stock: e.min_stock * f }));
|
||||||
const updated = await api.setProductLocationMinStock(product.id, artList);
|
const updated = await api.setProductLocationMinStock(product.id, basisListe);
|
||||||
setProduct(updated);
|
setProduct(updated);
|
||||||
toast("Bedarfe je Lagerort gespeichert.");
|
toast("Mindestbestände gespeichert.");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -917,7 +1046,9 @@ export default function ProductForm() {
|
|||||||
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)}
|
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)}
|
||||||
disabled={readOnly}>
|
disabled={readOnly}>
|
||||||
<option value="">– keine –</option>
|
<option value="">– keine –</option>
|
||||||
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
{gruppenOptionen(groups).map((o) => (
|
||||||
|
<option key={o.key} value={o.id}>{o.label}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import DataTable from "../components/DataTable";
|
|||||||
import { ProduktThumb } from "../components/ProduktBild";
|
import { ProduktThumb } from "../components/ProduktBild";
|
||||||
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
|
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
|
||||||
import { categoryInfoMap } from "../categoryPath";
|
import { categoryInfoMap } from "../categoryPath";
|
||||||
|
import { gruppenInfoMap } from "../groupGraph";
|
||||||
import { fmt, gebinde, unitShort } from "../units";
|
import { fmt, gebinde, unitShort } from "../units";
|
||||||
|
|
||||||
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
|
// Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit).
|
||||||
@@ -43,6 +44,8 @@ export default function Products({ fixedType = null }) {
|
|||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const [products, setProducts] = useState([]);
|
const [products, setProducts] = useState([]);
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
|
// Gruppen nur fuer die Pfad-Anzeige der Gruppen-Spalte.
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
|
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
|
||||||
const [typ, setTyp] = useState("");
|
const [typ, setTyp] = useState("");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -56,11 +59,13 @@ export default function Products({ fixedType = null }) {
|
|||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
api.listCategories().then(setCategories).catch(() => {});
|
api.listCategories().then(setCategories).catch(() => {});
|
||||||
|
api.listGroups().then(setGroups).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Kategorie als Pfad + Tokens (jede Ebene), damit "Klamotten" auch die
|
// Kategorie als Pfad + Tokens (jede Ebene), damit "Klamotten" auch die
|
||||||
// Unterkategorien findet und die Oberkategorie im Filter wählbar ist.
|
// Unterkategorien findet und die Oberkategorie im Filter wählbar ist.
|
||||||
const catInfo = useMemo(() => categoryInfoMap(categories), [categories]);
|
const catInfo = useMemo(() => categoryInfoMap(categories), [categories]);
|
||||||
|
const grpInfo = useMemo(() => gruppenInfoMap(groups), [groups]);
|
||||||
// Zwei getrennte Seiten (Lebensmittel/Gegenstände) fixieren den Typ; sonst der
|
// Zwei getrennte Seiten (Lebensmittel/Gegenstände) fixieren den Typ; sonst der
|
||||||
// Umschalter oben.
|
// Umschalter oben.
|
||||||
const activeTyp = fixedType || typ;
|
const activeTyp = fixedType || typ;
|
||||||
@@ -91,6 +96,21 @@ export default function Products({ fixedType = null }) {
|
|||||||
filterOptionLabel: (v) => (v === "" ? "(Leere)" : <CategoryPathLabel parts={pathParts(v)} />),
|
filterOptionLabel: (v) => (v === "" ? "(Leere)" : <CategoryPathLabel parts={pathParts(v)} />),
|
||||||
sortValue: (p) => catInfo.get(p.category_id)?.path || p.category_name || "",
|
sortValue: (p) => catInfo.get(p.category_id)?.path || p.category_name || "",
|
||||||
render: (p) => <CategoryPathLabel parts={catInfo.get(p.category_id)?.parts} fallback={p.category_name || "–"} /> },
|
render: (p) => <CategoryPathLabel parts={catInfo.get(p.category_id)?.parts} fallback={p.category_name || "–"} /> },
|
||||||
|
// Gruppe: zählt Bestände mehrerer Marken zusammen. Mit ihrem Weg von oben
|
||||||
|
// dargestellt, wie die Kategorie – ein Etikett verschwiege, dass „Grillwurst"
|
||||||
|
// unter „Wurst" hängt. Gefiltert wird über ALLE Wege, damit „Wurst" auch die
|
||||||
|
// Artikel aus den Untergruppen zeigt.
|
||||||
|
{ key: "gruppe", header: "Gruppe", width: 200,
|
||||||
|
filterText: (p) => grpInfo.get(p.group_id)?.path || p.group_name || "",
|
||||||
|
filterValues: (p) => grpInfo.get(p.group_id)?.tokens
|
||||||
|
|| (p.group_name ? [p.group_name] : [""]),
|
||||||
|
filterOptionLabel: (v) => (v === "" ? "(Ohne Gruppe)" : <CategoryPathLabel parts={pathParts(v)} />),
|
||||||
|
sortValue: (p) => grpInfo.get(p.group_id)?.path || p.group_name || "",
|
||||||
|
render: (p) => (p.group_id != null
|
||||||
|
? <span title={(grpInfo.get(p.group_id)?.alle || []).join(" · ")}>
|
||||||
|
<CategoryPathLabel parts={grpInfo.get(p.group_id)?.parts} fallback={p.group_name || "–"} />
|
||||||
|
</span>
|
||||||
|
: <span className="muted">–</span>) },
|
||||||
// Nur in der Gegenstände-Liste: Menge je Lagerort / Einzelstücke / Verbrauchsgegenstand.
|
// Nur in der Gegenstände-Liste: Menge je Lagerort / Einzelstücke / Verbrauchsgegenstand.
|
||||||
activeTyp === "object" && { key: "verwaltung", header: "Verwaltung", label: "Verwaltung", width: 190,
|
activeTyp === "object" && { key: "verwaltung", header: "Verwaltung", label: "Verwaltung", width: 190,
|
||||||
filterText: (p) => verwaltungLabel(p),
|
filterText: (p) => verwaltungLabel(p),
|
||||||
@@ -121,7 +141,9 @@ export default function Products({ fixedType = null }) {
|
|||||||
sortValue: (p) => (p.updated_at ? new Date(p.updated_at).getTime() : 0),
|
sortValue: (p) => (p.updated_at ? new Date(p.updated_at).getTime() : 0),
|
||||||
filterText: (p) => fmtDateTime(p.updated_at),
|
filterText: (p) => fmtDateTime(p.updated_at),
|
||||||
render: (p) => <span className="muted nowrap">{fmtDateTime(p.updated_at)}</span> },
|
render: (p) => <span className="muted nowrap">{fmtDateTime(p.updated_at)}</span> },
|
||||||
{ key: "details", header: "", label: "Details", fixed: true, width: 84, align: "num",
|
// Kein fixed: die Spalte soll sich verschieben lassen. Dafuer braucht sie
|
||||||
|
// einen sichtbaren Titel – an einem leeren Kopf gibt es nichts zu greifen.
|
||||||
|
{ key: "details", header: "Details", label: "Details", width: 90, align: "num",
|
||||||
render: (p) => <Link to={`/products/${p.id}`}>Details</Link> },
|
render: (p) => <Link to={`/products/${p.id}`}>Details</Link> },
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import { DATE_FORMATS, formatDate } from "../units";
|
|||||||
import { applyFavicon } from "../branding";
|
import { applyFavicon } from "../branding";
|
||||||
|
|
||||||
const EXPIRY_KEY = "expiry_warning_days";
|
const EXPIRY_KEY = "expiry_warning_days";
|
||||||
|
const RECEIPT_KEY = "receipt_match_threshold";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { reload } = useSettings();
|
const { reload } = useSettings();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [days, setDays] = useState("");
|
const [days, setDays] = useState("");
|
||||||
|
const [threshold, setThreshold] = useState("");
|
||||||
const [dateFormat, setDateFormat] = useState("de");
|
const [dateFormat, setDateFormat] = useState("de");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -23,6 +25,7 @@ export default function Settings() {
|
|||||||
try {
|
try {
|
||||||
const settings = await api.listSettings();
|
const settings = await api.listSettings();
|
||||||
setDays(settings.find((s) => s.key === EXPIRY_KEY)?.value ?? "7");
|
setDays(settings.find((s) => s.key === EXPIRY_KEY)?.value ?? "7");
|
||||||
|
setThreshold(settings.find((s) => s.key === RECEIPT_KEY)?.value ?? "45");
|
||||||
setDateFormat(settings.find((s) => s.key === DATE_FORMAT_KEY)?.value ?? "de");
|
setDateFormat(settings.find((s) => s.key === DATE_FORMAT_KEY)?.value ?? "de");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -36,6 +39,7 @@ export default function Settings() {
|
|||||||
setError(null); setBusy(true);
|
setError(null); setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.setSetting(EXPIRY_KEY, String(parseInt(days, 10)));
|
await api.setSetting(EXPIRY_KEY, String(parseInt(days, 10)));
|
||||||
|
await api.setSetting(RECEIPT_KEY, String(Math.max(0, Math.min(100, parseInt(threshold, 10) || 0))));
|
||||||
await api.setSetting(DATE_FORMAT_KEY, dateFormat);
|
await api.setSetting(DATE_FORMAT_KEY, dateFormat);
|
||||||
await reload();
|
await reload();
|
||||||
toast("Gespeichert.");
|
toast("Gespeichert.");
|
||||||
@@ -71,6 +75,20 @@ export default function Settings() {
|
|||||||
bereits überschritten ist), erscheinen auf der Übersicht unter „Bald ablaufend“.
|
bereits überschritten ist), erscheinen auf der Übersicht unter „Bald ablaufend“.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="card-head" style={{ marginTop: "var(--sp-5)" }}>
|
||||||
|
<Icon name="cart" /><h2>Kassenzettel-Scan</h2>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Trefferschwelle (%)
|
||||||
|
<input type="number" min="0" max="100" step="1" value={threshold}
|
||||||
|
onChange={(e) => setThreshold(e.target.value)} style={{ maxWidth: 160 }} />
|
||||||
|
</label>
|
||||||
|
<p className="muted small mt-0">
|
||||||
|
Beim Scannen eines Kassenzettels (iOS) werden je Zeile nur Artikel vorgeschlagen,
|
||||||
|
deren Übereinstimmung mindestens so hoch ist. Höher = strenger (weniger, dafür
|
||||||
|
sicherere Vorschläge), niedriger = mehr Vorschläge.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="card-head" style={{ marginTop: "var(--sp-5)" }}>
|
<div className="card-head" style={{ marginTop: "var(--sp-5)" }}>
|
||||||
<Icon name="settings" /><h2>Darstellung</h2>
|
<Icon name="settings" /><h2>Darstellung</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { amountText, fmt, unitShort } from "../units";
|
import ShoppingNeedText from "../components/ShoppingNeedText";
|
||||||
|
import { anzahlWort } from "../units";
|
||||||
|
|
||||||
export default function ShoppingList() {
|
export default function ShoppingList() {
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
@@ -33,7 +34,7 @@ export default function ShoppingList() {
|
|||||||
{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 && (
|
{byLocation.length > 0 && (
|
||||||
<div className="sub" style={{ marginBottom: "var(--sp-2)" }}>Gesamt</div>
|
<div className="sub" style={{ marginBottom: "var(--sp-2)" }}>Überall (egal wo)</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -48,11 +49,15 @@ export default function ShoppingList() {
|
|||||||
<label>
|
<label>
|
||||||
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
|
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
|
||||||
<span className="badge accent">Gruppe</span>
|
<span className="badge accent">Gruppe</span>
|
||||||
|
{it.subgroup_count > 0 && (
|
||||||
|
<span className="badge" title="Bestand und Fehlmenge zählen die Untergruppen mit; Käufe für Untergruppen sind bereits abgezogen.">
|
||||||
|
inkl. {anzahlWort(it.subgroup_count, "Untergruppe", "Untergruppen")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
fehlt <strong>{fmt(it.deficit)} {it.unit_name}</strong>{" "}
|
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_name} />
|
||||||
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)} {it.unit_name})
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -66,8 +71,7 @@ export default function ShoppingList() {
|
|||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
fehlt <strong>{amountText(it.deficit, it.package_size, it.base_unit)}</strong>{" "}
|
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.base_unit} />
|
||||||
(Bestand {amountText(it.stock, it.package_size, it.base_unit)})
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -90,11 +94,15 @@ export default function ShoppingList() {
|
|||||||
<label>
|
<label>
|
||||||
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
|
<input type="checkbox" checked={!!checked[key]} onChange={(e) => toggle(key, e.target.checked)} />
|
||||||
<span className="badge accent">Gruppe</span>
|
<span className="badge accent">Gruppe</span>
|
||||||
|
{it.subgroup_count > 0 && (
|
||||||
|
<span className="badge" title="Bestand und Fehlmenge zählen die Untergruppen mit; Käufe für Untergruppen sind bereits abgezogen.">
|
||||||
|
inkl. {anzahlWort(it.subgroup_count, "Untergruppe", "Untergruppen")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
fehlt <strong>{fmt(it.deficit)} {it.unit_name}</strong>{" "}
|
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_name} />
|
||||||
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -108,8 +116,7 @@ export default function ShoppingList() {
|
|||||||
<span className="item-name">{it.name}</span>
|
<span className="item-name">{it.name}</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
fehlt <strong>{fmt(it.deficit)} {it.unit_label}</strong>{" "}
|
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_label} />
|
||||||
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
|
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -122,6 +129,14 @@ export default function ShoppingList() {
|
|||||||
<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>
|
||||||
|
<p className="muted small">
|
||||||
|
Bedarfe sind gegeneinander verrechnet: Was du für einen Lagerort oder eine
|
||||||
|
Untergruppe kaufst, deckt „Überall“ bzw. die Obergruppe mit ab. Hängt
|
||||||
|
dieselbe Untergruppe unter zwei Obergruppen, die einander <em>nicht</em>
|
||||||
|
enthalten (Grillwurst unter Wurst und unter Grillgut), stehen beide Bedarfe
|
||||||
|
getrennt da — ein Kauf kann dann beide decken. Ein eigener Mindestbestand auf
|
||||||
|
der gemeinsamen Untergruppe verrechnet auch das.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { api } from "../api";
|
|||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { asTree } from "../categoryTree";
|
import { asTree } from "../categoryTree";
|
||||||
|
import { itemDeepLink } from "../qr";
|
||||||
|
|
||||||
// Baut die Etiketten-CSV mit wählbarem Trennzeichen (BOM für Excel-Umlaute).
|
// Baut die Etiketten-CSV mit wählbarem Trennzeichen (BOM für Excel-Umlaute).
|
||||||
// QR-Inhalt = Link aufs Stück.
|
// QR-Inhalt = Link aufs Stück.
|
||||||
function buildLabelCsv(rows, origin, delim = ",") {
|
function buildLabelCsv(rows, delim = ",") {
|
||||||
const head = ["QR-Inhalt", "UID", "Produkt", "Marke", "Kategorie", "Lagerort"];
|
const head = ["QR-Inhalt", "UID", "Produkt", "Marke", "Kategorie", "Lagerort"];
|
||||||
const delimRe = delim === "\t" ? "\\t" : delim;
|
const delimRe = delim === "\t" ? "\\t" : delim;
|
||||||
const needsQuote = new RegExp(`[${delimRe}"\\n\\r]`);
|
const needsQuote = new RegExp(`[${delimRe}"\\n\\r]`);
|
||||||
@@ -20,7 +21,7 @@ function buildLabelCsv(rows, origin, delim = ",") {
|
|||||||
};
|
};
|
||||||
const lines = [head.map(esc).join(delim)];
|
const lines = [head.map(esc).join(delim)];
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
lines.push([`${origin}/i/${r.uid}`, r.uid, r.product, r.brand, r.category, r.location]
|
lines.push([itemDeepLink(r.uid), r.uid, r.product, r.brand, r.category, r.location]
|
||||||
.map(esc).join(delim));
|
.map(esc).join(delim));
|
||||||
}
|
}
|
||||||
return "" + lines.join("\r\n");
|
return "" + lines.join("\r\n");
|
||||||
@@ -161,7 +162,7 @@ export default function Transfer() {
|
|||||||
try {
|
try {
|
||||||
const rows = await api.labelRows([...selCats]);
|
const rows = await api.labelRows([...selCats]);
|
||||||
if (!rows.length) { setLabelInfo("Keine Einzelstücke in der Auswahl."); return; }
|
if (!rows.length) { setLabelInfo("Keine Einzelstücke in der Auswahl."); return; }
|
||||||
const csv = buildLabelCsv(rows, window.location.origin, labelDelim);
|
const csv = buildLabelCsv(rows, labelDelim);
|
||||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR-Inhalt für ein Einzelstück. Bewusst das Custom-Scheme `vorrania://` statt
|
||||||
|
* einer https-URL: Die normale Kamera-App öffnet damit trotzdem die App, aber
|
||||||
|
* OHNE an eine feste Domain gebunden zu sein. Das ist für einen selbstgehosteten
|
||||||
|
* Dienst nötig (jede Instanz hat eine andere URL) und der QR verrät keinen Server.
|
||||||
|
* Die App öffnet die UID am gerade aktiven Server.
|
||||||
|
*/
|
||||||
|
export const itemDeepLink = (uid) => `vorrania://i/${uid}`;
|
||||||
|
|
||||||
/** Kleines QR-Bild für einen Wert (asynchron erzeugt). */
|
/** Kleines QR-Bild für einen Wert (asynchron erzeugt). */
|
||||||
export function QrImg({ text, size = 60 }) {
|
export function QrImg({ text, size = 60 }) {
|
||||||
const [url, setUrl] = useState(null);
|
const [url, setUrl] = useState(null);
|
||||||
|
|||||||
@@ -40,6 +40,31 @@ body {
|
|||||||
font-size: 14.5px;
|
font-size: 14.5px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
/* Pfeil als Standard – sonst zeigt der Browser über jedem Text den Text-Cursor
|
||||||
|
(I-Beam), und alles sieht aus wie ein Eingabefeld. cursor wird vererbt. */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text-Cursor nur dort, wo man wirklich tippt. */
|
||||||
|
textarea,
|
||||||
|
[contenteditable="true"],
|
||||||
|
input:where([type="text"], [type="number"], [type="search"], [type="email"],
|
||||||
|
[type="password"], [type="url"], [type="tel"]),
|
||||||
|
input:not([type]) { cursor: text; }
|
||||||
|
|
||||||
|
/* Hand für klar Klickbares, das noch keine eigene Regel hat (Buttons, Sortier-
|
||||||
|
Header, Kacheln, Menüoptionen, klickbare Zeilen bringen sie schon selbst mit). */
|
||||||
|
a[href], select, summary,
|
||||||
|
input[type="checkbox"], input[type="radio"], input[type="file"],
|
||||||
|
.checklist label, .check-inline, .enforce-toggle { cursor: pointer; }
|
||||||
|
|
||||||
|
/* Reiner UI-Text (Titel, Labels, Bedienelemente) lässt sich nicht markieren – beim
|
||||||
|
Klicken poppt dort sonst ein blinkendes Text-Caret auf, das wie ein Eingabefeld
|
||||||
|
wirkt. Inhalte, die man wirklich kopieren will, bleiben markierbar: Tabellen-
|
||||||
|
zellen, Eingabefelder und Code/Token (sowie alles mit .selectable). */
|
||||||
|
body { -webkit-user-select: none; user-select: none; }
|
||||||
|
.table td, input, textarea, [contenteditable="true"], code, pre, .selectable {
|
||||||
|
-webkit-user-select: text; user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
@@ -303,6 +328,26 @@ td .field-inline { margin-bottom: 0; flex-wrap: wrap; }
|
|||||||
132px machte schmale Spalten (z.B. "Einheit") unnoetig breit. */
|
132px machte schmale Spalten (z.B. "Einheit") unnoetig breit. */
|
||||||
td select { width: auto; min-width: 0; max-width: 100%; }
|
td select { width: auto; min-width: 0; max-width: 100%; }
|
||||||
/* Button-Paare (z.B. Speichern/Abbrechen) gleich breit halten. */
|
/* Button-Paare (z.B. Speichern/Abbrechen) gleich breit halten. */
|
||||||
|
/* Mindestbestände: eine Zeile je Eintrag in FESTEN Spalten.
|
||||||
|
Vorher lag hier ein .field-inline – dort wachsen Name und Eingabefeld beide
|
||||||
|
(.field-inline > input { flex: 1 1 auto }) und teilen sich den Restplatz.
|
||||||
|
Die Felder standen dadurch je nach Namenslänge woanders. */
|
||||||
|
.min-zeile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 110px 150px 150px 160px 36px;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.min-zeile > * { min-width: 0; }
|
||||||
|
.min-zeile input, .min-zeile select { margin-top: 0; width: 100%; min-width: 0; }
|
||||||
|
.min-zeile .bestand { text-align: right; }
|
||||||
|
/* Schmal: Name über die volle Breite, darunter die Felder nebeneinander. */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.min-zeile { grid-template-columns: 1fr 1fr 1fr 36px; }
|
||||||
|
.min-zeile > .name { grid-column: 1 / -1; }
|
||||||
|
.min-zeile .bestand { text-align: left; }
|
||||||
|
}
|
||||||
|
|
||||||
.btn-pair { display: flex; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; }
|
.btn-pair { display: flex; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; }
|
||||||
.btn-pair .btn { flex: 1 1 auto; min-width: 104px; justify-content: center; }
|
.btn-pair .btn { flex: 1 1 auto; min-width: 104px; justify-content: center; }
|
||||||
|
|
||||||
@@ -362,6 +407,8 @@ td select { width: auto; min-width: 0; max-width: 100%; }
|
|||||||
/* Zelle mit Eingabe plus Kennzeichen (z.B. "niedrig"): eine Zeile, damit das
|
/* Zelle mit Eingabe plus Kennzeichen (z.B. "niedrig"): eine Zeile, damit das
|
||||||
Kennzeichen immer an derselben Stelle sitzt statt mal darunter zu rutschen. */
|
Kennzeichen immer an derselben Stelle sitzt statt mal darunter zu rutschen. */
|
||||||
.cell-row { display: flex; align-items: center; gap: 6px; }
|
.cell-row { display: flex; align-items: center; gap: 6px; }
|
||||||
|
/* Zwei Zeilen in einer Zelle: Steuerelement oben, Erläuterung darunter. */
|
||||||
|
.cell-col { display: flex; flex-direction: column; gap: 2px; align-items: stretch; }
|
||||||
/* Beschriftung mit Erklaerung im Tooltip: die gepunktete Linie zeigt an, dass
|
/* Beschriftung mit Erklaerung im Tooltip: die gepunktete Linie zeigt an, dass
|
||||||
dort etwas steht - so bleibt das Formular kurz, ohne Wissen zu verstecken. */
|
dort etwas steht - so bleibt das Formular kurz, ohne Wissen zu verstecken. */
|
||||||
.tip { border-bottom: 1px dotted var(--border-strong); }
|
.tip { border-bottom: 1px dotted var(--border-strong); }
|
||||||
@@ -440,6 +487,27 @@ td select { width: auto; min-width: 0; max-width: 100%; }
|
|||||||
background: var(--surface-2); color: var(--muted);
|
background: var(--surface-2); color: var(--muted);
|
||||||
}
|
}
|
||||||
.badge.accent { background: var(--accent-soft); color: var(--accent); }
|
.badge.accent { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Verlauf: die Bewegungsart als Sinnbild statt als Wort.
|
||||||
|
BEWUSST fuer alle drei Arten dieselbe Farbe: Gruen fuer Einlagern und Orange
|
||||||
|
fuer Auslagern las sich wie gut und schlecht, dabei ist Auslagern schlicht
|
||||||
|
der Normalfall. Die Pfeilrichtung im Icon traegt die Bedeutung. */
|
||||||
|
.bewegung-icon {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 34px; height: 34px; border-radius: 10px; flex: 0 0 auto;
|
||||||
|
background: var(--accent-soft); color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eine Bewegung als Zeile: Kachel links, Zeitpunkt ueber Produkt. */
|
||||||
|
.bewegung-liste { display: flex; flex-direction: column; }
|
||||||
|
.bewegung-zeile {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
padding: var(--sp-2) 0; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.bewegung-zeile:last-child { border-bottom: 0; }
|
||||||
|
.bewegung-zeile .text { min-width: 0; }
|
||||||
|
.bewegung-zeile .zeit { color: var(--muted); font-size: 0.82rem; }
|
||||||
|
.bewegung-zeile .produkt { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.badge.danger { background: var(--danger-soft); color: var(--danger); }
|
.badge.danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
.badge.warn { background: var(--warn-soft); color: var(--warn); }
|
.badge.warn { background: var(--warn-soft); color: var(--warn); }
|
||||||
.badge.ok { background: #e6f4ec; color: var(--ok); }
|
.badge.ok { background: #e6f4ec; color: var(--ok); }
|
||||||
@@ -578,7 +646,9 @@ td select { width: auto; min-width: 0; max-width: 100%; }
|
|||||||
}
|
}
|
||||||
.dt-pop-search { width: 100%; margin: 0 0 var(--sp-2); padding: 5px 8px; font-size: 0.85rem; }
|
.dt-pop-search { width: 100%; margin: 0 0 var(--sp-2); padding: 5px 8px; font-size: 0.85rem; }
|
||||||
.dt-pop-actions { display: flex; gap: var(--sp-3); margin-bottom: var(--sp-2); }
|
.dt-pop-actions { display: flex; gap: var(--sp-3); margin-bottom: var(--sp-2); }
|
||||||
.dt-pop-list { max-height: 240px; overflow-y: auto; display: flex; flex-direction: column; }
|
/* Die Hoehe setzt DataTable je nach freiem Platz (siehe chooserPos); hier
|
||||||
|
nur der Notnagel, falls sie einmal fehlt. */
|
||||||
|
.dt-pop-list { max-height: 60vh; overflow-y: auto; display: flex; flex-direction: column; }
|
||||||
.dt-pop-opt { display: flex; align-items: center; gap: 8px; padding: 3px 4px; font-size: 0.85rem; cursor: pointer; margin: 0; }
|
.dt-pop-opt { display: flex; align-items: center; gap: 8px; padding: 3px 4px; font-size: 0.85rem; cursor: pointer; margin: 0; }
|
||||||
.dt-pop-opt:hover { background: var(--surface-2); }
|
.dt-pop-opt:hover { background: var(--surface-2); }
|
||||||
/* Ohne diese Ausnahme erben die Checkboxen die globale input-Regel (width:100%,
|
/* Ohne diese Ausnahme erben die Checkboxen die globale input-Regel (width:100%,
|
||||||
@@ -674,6 +744,10 @@ td select { width: auto; min-width: 0; max-width: 100%; }
|
|||||||
|
|
||||||
.dash-card {
|
.dash-card {
|
||||||
margin-bottom: 0; /* Abstand macht das Raster */
|
margin-bottom: 0; /* Abstand macht das Raster */
|
||||||
|
/* Die feste Hoehe der Rasterzelle bis hierher durchreichen: erst dadurch
|
||||||
|
begrenzt die Flex-Kette darunter, sodass Inhalt IN der Karte scrollt (z.B.
|
||||||
|
nur die Ring-Legende) statt die ganze Karte zu strecken. */
|
||||||
|
height: 100%;
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
overflow: hidden; padding: 0;
|
overflow: hidden; padding: 0;
|
||||||
}
|
}
|
||||||
@@ -773,24 +847,21 @@ select.zeitraum:hover { color: var(--text); }
|
|||||||
/* ---------- Diagramme ---------- */
|
/* ---------- Diagramme ---------- */
|
||||||
/* Der Ring fuellt die Karte und sitzt mittig – vorher klebte er oben und
|
/* Der Ring fuellt die Karte und sitzt mittig – vorher klebte er oben und
|
||||||
darunter blieb eine grosse leere Flaeche stehen. */
|
darunter blieb eine grosse leere Flaeche stehen. */
|
||||||
|
/* Ring links (feste Größe, mittig) + eigener Scroll-Bereich rechts für die
|
||||||
|
Legende. Der Ring-Bereich füllt die feste Kartenhöhe; nur die Legende scrollt,
|
||||||
|
nicht die ganze Karte. */
|
||||||
.chart-donut {
|
.chart-donut {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: stretch; justify-content: center;
|
||||||
gap: var(--sp-4); flex-wrap: wrap;
|
gap: var(--sp-4);
|
||||||
flex: 1; min-height: 0;
|
flex: 1; min-height: 0; overflow: hidden;
|
||||||
}
|
}
|
||||||
/* Ablauf-Ring mit „Ohne MHD"-Umschalter darueber; der Ring fuellt den Rest. */
|
.chart-legend-scroll { flex: 1 1 0; min-width: 0; min-height: 0; max-height: 100%; overflow-y: auto; }
|
||||||
.ring-mit-schalter { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
.donut-svg { width: 160px; height: 160px; flex: 0 0 auto; align-self: center; }
|
||||||
.ring-schalter { flex: 0 0 auto; justify-content: flex-end; margin: 0 0 4px; font-size: 0.78rem; color: var(--muted); }
|
|
||||||
/* Der Umschalter ist eine Karten-Einstellung: nur sichtbar, wenn man das
|
|
||||||
Dashboard gerade bearbeitet. Die Wahl wirkt trotzdem in der normalen Ansicht. */
|
|
||||||
.ring-mit-schalter .ring-schalter { display: none; }
|
|
||||||
.dash-grid.editing .ring-mit-schalter .ring-schalter { display: flex; }
|
|
||||||
.donut-svg { width: 160px; height: 160px; flex: 0 0 auto; }
|
|
||||||
.donut-svg circle { transition: stroke-width 120ms ease; cursor: default; }
|
.donut-svg circle { transition: stroke-width 120ms ease; cursor: default; }
|
||||||
.donut-value { font-size: 20px; font-weight: 680; fill: var(--text); }
|
.donut-value { font-size: 20px; font-weight: 680; fill: var(--text); }
|
||||||
.donut-label { font-size: 10px; fill: var(--muted); }
|
.donut-label { font-size: 10px; fill: var(--muted); }
|
||||||
|
|
||||||
.chart-legend { list-style: none; margin: 0; padding: 0; flex: 1; min-width: 140px; }
|
.chart-legend { list-style: none; margin: 0; padding: 0; }
|
||||||
.chart-legend li {
|
.chart-legend li {
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
padding: 3px 0; font-size: 0.82rem;
|
padding: 3px 0; font-size: 0.82rem;
|
||||||
@@ -866,8 +937,35 @@ select.zeitraum:hover { color: var(--text); }
|
|||||||
/* Wer Bewegung reduziert haben moechte, bekommt sie eingeblendet ohne Fahrt. */
|
/* Wer Bewegung reduziert haben moechte, bekommt sie eingeblendet ohne Fahrt. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.toast { animation: none; }
|
.toast { animation: none; }
|
||||||
|
.skeleton { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Skeleton (schimmernde Ladeplatzhalter) ---------- */
|
||||||
|
/* Farben aus Tokens -> passt sich Hell/Dunkel von allein an. Ueber den grauen
|
||||||
|
Grundton wandert ein hellerer Streifen (Sweep) von rechts nach links. */
|
||||||
|
.skeleton {
|
||||||
|
display: block;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
var(--surface-2) 25%, var(--border) 37%, var(--surface-2) 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: skeleton-glanz 1.4s ease infinite;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
@keyframes skeleton-glanz {
|
||||||
|
from { background-position: 100% 0; }
|
||||||
|
to { background-position: 0 0; }
|
||||||
|
}
|
||||||
|
.skel-stack { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
.skel-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
.skel-row .skeleton { flex: none; }
|
||||||
|
/* Diagramm-Platzhalter fuellt den Kartenkoerper. */
|
||||||
|
.skel-chart { flex: 1; min-height: 120px; height: auto; }
|
||||||
|
/* Tabellen-Platzhalterzeilen: kein Hover-Hintergrund. */
|
||||||
|
.table tbody tr.skel-tr:hover { background: transparent; }
|
||||||
|
|
||||||
/* ---------- Login ---------- */
|
/* ---------- Login ---------- */
|
||||||
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
|
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
|
||||||
.login-card { width: 100%; max-width: 360px; }
|
.login-card { width: 100%; max-width: 360px; }
|
||||||
|
|||||||
@@ -46,6 +46,74 @@ export function unitShort(baseUnit) {
|
|||||||
return BASE_UNIT_SHORT[baseUnit] || baseUnit;
|
return BASE_UNIT_SHORT[baseUnit] || baseUnit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Basiseinheit-Kürzel zur Einheiten-ART (weight/volume/count), wie sie Gruppen
|
||||||
|
// über `kind` melden. Mindestbestände stehen immer in dieser Einheit.
|
||||||
|
export const KIND_SHORT = { weight: "g", volume: "ml", count: "Stück" };
|
||||||
|
|
||||||
|
export function kindShort(kind) {
|
||||||
|
return KIND_SHORT[kind] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „1 Untergruppe" / „2 Untergruppen" – Ein-/Mehrzahl an einer Stelle. */
|
||||||
|
export function anzahlWort(n, einzahl, mehrzahl) {
|
||||||
|
return `${n} ${Number(n) === 1 ? einzahl : mehrzahl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Gruppen: in welcher Einheit sie zählen ----
|
||||||
|
// Eine Gruppe rechnet entweder in ihrer verwalteten Einheit (Gramm, Kilogramm)
|
||||||
|
// ODER – wenn ein Gruppen-Gebinde definiert und aktiv ist – in ganzen Packungen.
|
||||||
|
// Beides zusammen an EINER Stelle, weil genau diese Unterscheidung vorher an
|
||||||
|
// zwei Orten stand und in Groups.jsx fehlte: dort klebte immer „Gramm" hinter
|
||||||
|
// einem Bestand, der längst in Gläsern gerechnet war.
|
||||||
|
|
||||||
|
/** Zählt die Gruppe in ihrem Gebinde (statt in der verwalteten Einheit)? */
|
||||||
|
export function grpPkgMode(g) {
|
||||||
|
return !!g?.min_stock_in_packages && !!(g.package_size && g.package_size > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Basiseinheiten je Erfassungseinheit der Gruppe. */
|
||||||
|
export function grpFactor(g) {
|
||||||
|
return grpPkgMode(g) ? g.package_size : (g?.min_stock_unit_factor || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beschriftung der Erfassungseinheit, passend zur Menge (1 Glas / 3 Gläser). */
|
||||||
|
export function grpUnit(g, menge = 1) {
|
||||||
|
return grpPkgMode(g)
|
||||||
|
? gebinde(menge, g.package_label || "Packung")
|
||||||
|
: (g?.min_stock_unit_name || kindShort(g?.kind) || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einheiten-ART je Basiseinheit – Spiegelbild von conversion.py::KIND_OF_BASE.
|
||||||
|
export const KIND_OF_BASE = { piece: "count", gram: "weight", milliliter: "volume" };
|
||||||
|
|
||||||
|
// ---- Zweiteinheit: die Brücke zwischen den Einheiten-Arten ----
|
||||||
|
// Ein Artikel darf zusätzlich zu seiner Basiseinheit eine Äquivalenz tragen:
|
||||||
|
// „3 Stück ≙ 250 g". Der Server liefert den ausgerechneten Faktor als
|
||||||
|
// `secondary_factor` mit; das Paar dahinter bleibt zur Anzeige erhalten.
|
||||||
|
|
||||||
|
/** Zweit-Basiseinheiten je EINER Basiseinheit. 0 = keine Brücke hinterlegt. */
|
||||||
|
export function zweitFaktor(p) {
|
||||||
|
if (!p?.secondary_base) return 0;
|
||||||
|
const f = Number(p.secondary_factor);
|
||||||
|
if (f > 0) return f;
|
||||||
|
// Rückfall, falls das abgeleitete Feld fehlt (ältere Antwort).
|
||||||
|
const n = Number(p.secondary_count);
|
||||||
|
const m = Number(p.secondary_amount);
|
||||||
|
return n > 0 && m > 0 ? m / n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Menge (Basiseinheiten) in der Zweiteinheit lesen: „500 g". */
|
||||||
|
export function zweitText(mengeBase, p) {
|
||||||
|
const f = zweitFaktor(p);
|
||||||
|
return f ? `${fmt(mengeBase * f)} ${unitShort(p.secondary_base)}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bestand mit beiden Lesarten: „6 Stück · 500 g". */
|
||||||
|
export function stockLabelMitZweit(p) {
|
||||||
|
const zweit = zweitText(p.stock || 0, p);
|
||||||
|
return zweit ? `${stockLabel(p)} · ${zweit}` : stockLabel(p);
|
||||||
|
}
|
||||||
|
|
||||||
// Auswahlmöglichkeiten für Menge beim Ein-/Auslagern eines Produkts.
|
// Auswahlmöglichkeiten für Menge beim Ein-/Auslagern eines Produkts.
|
||||||
export function unitOptions(product) {
|
export function unitOptions(product) {
|
||||||
const opts = [{ value: baseUnitToInput(product.base_unit), label: unitShort(product.base_unit) }];
|
const opts = [{ value: baseUnitToInput(product.base_unit), label: unitShort(product.base_unit) }];
|
||||||
@@ -186,8 +254,13 @@ export function amountText(qty, packageSize, baseUnit) {
|
|||||||
|
|
||||||
// Einheiten-Optionen fürs Ein-/Auslagern: verwaltete Einheiten der Produkt-Art + Packung.
|
// Einheiten-Optionen fürs Ein-/Auslagern: verwaltete Einheiten der Produkt-Art + Packung.
|
||||||
export function buildUnitOptions(product, units) {
|
export function buildUnitOptions(product, units) {
|
||||||
|
// Ohne Zweiteinheit nur die eigene Art. Mit Brücke kommen die Einheiten der
|
||||||
|
// Gegenart dazu – das Backend rechnet sie über die am Artikel hinterlegte
|
||||||
|
// Äquivalenz um (services/conversion.py::to_base).
|
||||||
|
const erlaubt = new Set([product.kind]);
|
||||||
|
if (zweitFaktor(product)) erlaubt.add(KIND_OF_BASE[product.secondary_base]);
|
||||||
const opts = (units || [])
|
const opts = (units || [])
|
||||||
.filter((u) => u.kind === product.kind)
|
.filter((u) => erlaubt.has(u.kind))
|
||||||
.map((u) => ({ value: u.name, label: u.name }));
|
.map((u) => ({ value: u.name, label: u.name }));
|
||||||
if (product.package_size) {
|
if (product.package_size) {
|
||||||
// Bezeichnung des Gebindes je Artikel (Glas, Tüte, …); Wert bleibt "package".
|
// Bezeichnung des Gebindes je Artikel (Glas, Tüte, …); Wert bleibt "package".
|
||||||
|
|||||||
Reference in New Issue
Block a user