Compare commits
211 Commits
4730d791e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
48d47f1d39 | ||
|
|
f78ae924de | ||
|
|
39803dfb82 | ||
|
|
05b54dc7ac | ||
|
|
231d86895f | ||
|
|
8cf7cef6e1 | ||
|
|
e28b6a5e38 | ||
|
|
bfc52997dc | ||
|
|
f29bbced8d | ||
|
|
a04315db21 | ||
|
|
66a52bfecb | ||
|
|
8250b5dc84 | ||
|
|
a0979a0780 | ||
|
|
7a7b40ff99 | ||
|
|
e42e11bf23 | ||
|
|
18c07eaf3a | ||
|
|
012e3461ae | ||
|
|
5550897319 | ||
|
|
51c02091ed | ||
|
|
826f4df4bd | ||
|
|
1c88206838 | ||
|
|
227143d13f | ||
|
|
d448754d2a | ||
|
|
85ab62359a | ||
|
|
4728a839ea | ||
|
|
4034b97cf3 | ||
|
|
620e77dff1 | ||
|
|
4ab8d11f92 | ||
|
|
f53d5bd921 | ||
|
|
2c1efba83d | ||
|
|
120dfea66a | ||
|
|
7c359a8f5d | ||
|
|
e981cd8cd4 | ||
|
|
1d173bb9c0 | ||
|
|
902b597091 | ||
|
|
53492ad4ab | ||
|
|
f96286b3fa | ||
|
|
53d8f18497 | ||
|
|
532695c2ee | ||
|
|
4f8e6e3185 | ||
|
|
d029c882f1 | ||
|
|
03c0f4043d | ||
|
|
31137cd884 | ||
|
|
45031df1f4 | ||
|
|
9ca7291c68 | ||
|
|
c67998d78b | ||
|
|
4141781ffb | ||
|
|
27f84003f3 | ||
|
|
3ff7d2a35a | ||
|
|
49a96d7fdb | ||
|
|
275611160b | ||
|
|
f0c64466e2 | ||
|
|
0ea188374a | ||
|
|
c28ac7d7dd | ||
|
|
10a948df15 | ||
|
|
24ea55c396 | ||
|
|
46f9c69a39 | ||
|
|
8d34e2e8d8 | ||
|
|
72fa2ce37d | ||
|
|
f287f8f768 | ||
|
|
4087277bb0 | ||
|
|
6c870584dd | ||
|
|
1dc249eb79 | ||
|
|
9551751732 | ||
|
|
c52e88f856 | ||
|
|
189b1f8e6b | ||
|
|
912f3dac6f | ||
|
|
01a3528227 | ||
|
|
4aa6d9597b | ||
|
|
e2d2347524 | ||
|
|
08ee1f20f5 | ||
|
|
487c5f5f38 | ||
|
|
76dc29ac72 | ||
|
|
050496a82d | ||
|
|
ffbe33bec4 | ||
|
|
6b7ada33b3 | ||
|
|
76acf57edf | ||
|
|
4887596b43 | ||
|
|
89d8ba4eff | ||
|
|
72256f4fe9 | ||
|
|
34149ff633 | ||
|
|
5991a807d2 | ||
|
|
1db2d6389c | ||
|
|
c8c1686395 | ||
|
|
21ab0a825a | ||
|
|
4607e3a6a8 | ||
|
|
d518b4aa23 | ||
|
|
6e48cd0a7a | ||
|
|
636982f6c7 | ||
|
|
625b6ee263 | ||
|
|
4a950acd85 | ||
|
|
88b9bdcbea | ||
|
|
a38c0651c5 | ||
|
|
4e7512b4bc | ||
|
|
bbf10b36c6 | ||
|
|
729dfe766b | ||
|
|
5d3ae185ce | ||
|
|
dff7677e50 | ||
|
|
67bc2dd87d | ||
|
|
d0d854be5a | ||
|
|
2bc871f229 | ||
|
|
761c131286 | ||
|
|
944edbccd0 | ||
|
|
2449f03e13 | ||
|
|
d993a48711 | ||
|
|
3453471716 | ||
|
|
3befaad850 | ||
|
|
41e8cce612 | ||
|
|
40e12a574e | ||
|
|
e0e0323f99 | ||
|
|
f8c0cec918 | ||
|
|
b9709d29bf | ||
|
|
ba65e48d47 | ||
|
|
61270e6d00 | ||
|
|
9a11e3cfc6 | ||
|
|
f88a459e12 | ||
|
|
b49546b297 | ||
|
|
3f47e273ad | ||
|
|
272609e70a | ||
|
|
dc66a6d7ff | ||
|
|
956a472c04 | ||
|
|
949c395fbf | ||
|
|
6b597313d5 | ||
|
|
2a507851d9 | ||
|
|
dbdd977bad | ||
|
|
a70a99b387 | ||
|
|
f35126cb7b | ||
|
|
914118e33a | ||
|
|
dcee7cab6c | ||
|
|
1a5a5bc16c | ||
|
|
2d7cfa7fcb | ||
|
|
b4b203a117 | ||
|
|
2ebdf94b16 | ||
|
|
7b2d4965ff | ||
|
|
e8471bd951 | ||
|
|
7f9895245c | ||
|
|
953e0aa483 | ||
|
|
7f7129c87a | ||
|
|
6fb5c47de6 | ||
|
|
5ce4411d1e | ||
|
|
68e27c1868 | ||
|
|
928342d352 | ||
|
|
5edb830afd | ||
|
|
c532492286 | ||
|
|
8d75ad63ce | ||
|
|
2a7f98d61c | ||
|
|
65b0792148 | ||
|
|
5c8b46a0d5 | ||
|
|
a664410ac0 | ||
|
|
044f63446f | ||
|
|
5b952524d7 | ||
|
|
580afcc133 | ||
|
|
96172c6fa0 | ||
|
|
85ac3dfa50 | ||
|
|
2ad197df42 | ||
|
|
89598147ed | ||
|
|
f62289f975 | ||
|
|
b2a38501d2 | ||
|
|
ff158a1e5f | ||
|
|
794a81f58b |
103
README.md
103
README.md
@@ -1,44 +1,78 @@
|
|||||||
# Vorrania – Selfhostbare Lebensmittel-Lagerverwaltung
|
# Vorrania – Selfhostbare Vorrats- und Gegenstandsverwaltung
|
||||||
|
|
||||||
Ein selbst gehosteter Dienst zur Verwaltung deines Lebensmittelvorrats:
|
Ein selbst gehosteter Dienst, um sowohl deinen **Lebensmittelvorrat** als auch
|
||||||
Ein-/Auslagern per Barcode, MHD-/Ablaufverwaltung mit Chargen, Mindestbestände,
|
allgemeine **Gegenstände** (Haushalt, Elektronik, Kleidung, Verbrauchsmaterial …)
|
||||||
automatische Einkaufsliste – mit **Web-UI** und (in Vorbereitung) **nativer iOS-App**.
|
zu verwalten: Ein-/Auslagern per Barcode, Chargen mit MHD, Einzelstücke mit
|
||||||
|
eigener UID/QR, Mindestbestände, automatische Einkaufsliste, verschachtelte
|
||||||
|
Lagerorte, Gruppen, Kategorien und Dashboards – mit **Web-UI** und **nativer
|
||||||
|
iOS-App**, die beide dieselbe REST-API nutzen.
|
||||||
|
|
||||||
> Dies ist **Schritt 1** (Fundament). Der komplette Fahrplan steht in
|
## Was Vorrania verwaltet
|
||||||
> [docs/ROADMAP.md](docs/ROADMAP.md).
|
|
||||||
|
|
||||||
## Features (Schritt 1)
|
**Lebensmittel** laufen als **Chargen** mit Mindesthaltbarkeitsdatum. Beim
|
||||||
- **Barcode-Lookup** über [Open Food Facts](https://world.openfoodfacts.org) mit
|
Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**).
|
||||||
lokalem Fallback (unbekannte Produkte selbst anlegen).
|
|
||||||
- **Chargen mit MHD:** Jedes Einlagern erzeugt eine Charge mit eigenem
|
**Gegenstände** kennen drei Arten:
|
||||||
Mindesthaltbarkeitsdatum. Beim Auslagern wird automatisch die zuerst ablaufende
|
- **Menge je Lagerort** – reines Zählen (z. B. Unterhosen, Batterien).
|
||||||
Charge zuerst entnommen (**FEFO** – First Expired, First Out).
|
- **Einzelstücke** – jedes Exemplar mit eigener **UID/QR**, Kaufdatum, Garantie,
|
||||||
- **Einheiten:** Basiseinheit Stück / Gramm / Milliliter plus optionale
|
Bezugsquelle und Beleg (z. B. Powerbank, Kamera).
|
||||||
Packungsgröße → Ein-/Auslagern in Packungen **oder** Teilmengen (z. B. 200 g).
|
- **Verbrauchsgegenstand** – wie ein Lebensmittel als Charge mit Menge und
|
||||||
- **Mindestbestände** pro Produkt → automatische **Einkaufsliste**.
|
Einheit geführt (z. B. Sonnencreme in ml), nur ohne eindeutigen Code.
|
||||||
- **Ablaufwarnung** für bald ablaufende Chargen (Frist konfigurierbar).
|
|
||||||
- **Lagerorte** (flach; Unterlagerorte folgen in Schritt 3).
|
## Features
|
||||||
- **Mehrbenutzer mit Rollen:**
|
- **Barcode-Lookup** über [Open Food Facts](https://world.openfoodfacts.org) und
|
||||||
- **Admin** – verwaltet Produkte, Lagerorte, Benutzer, Mindestbestände.
|
Open Products Facts mit lokalem Fallback; unbekannte Produkte selbst anlegen,
|
||||||
- **Nutzer** – nur ein-/auslagern und ansehen.
|
Bild wird automatisch geholt.
|
||||||
- Bewegungen werden protokolliert (wer hat wann was ein-/ausgelagert).
|
- **Chargen mit MHD & FEFO**, Mengen in Packungen **oder** Teilmengen
|
||||||
|
(Basiseinheit Stück / Gramm / Milliliter plus Packungsgröße/Gebinde).
|
||||||
|
- **Lagerorte** beliebig verschachtelt (Keller → Regal 2 → Fach A), mit
|
||||||
|
**QR-Etiketten**, **Inhaltsansicht** („was liegt hier?“), Umlagern und
|
||||||
|
Entfernen mit Grund. Chargen und Einzelstücke sind einem Lagerort zugeordnet.
|
||||||
|
- **Kategorien** verschachtelt und pro Typ (Lebensmittel/Gegenstand); für
|
||||||
|
Gegenstände mit **eigenen Feldern** (z. B. Kapazität, Größe, Farbe).
|
||||||
|
- **Gruppen** rechnen Bestände mehrerer Marken/Artikel zusammen (z. B. „5 kg
|
||||||
|
Mehl“) mit eigenem Mindestbestand und Einheit.
|
||||||
|
- **Mindestbestände** je Produkt und Gruppe – gesamt **und je Lagerort** – mit
|
||||||
|
zentraler Übersichtsseite, daraus die automatische **Einkaufsliste** und
|
||||||
|
**Ablaufwarnung** für bald ablaufende Chargen.
|
||||||
|
- **Übergreifende Listen** für Produkte (getrennt Lebensmittel/Gegenstände),
|
||||||
|
Einzelstücke und Chargen – filter-, sortier- und spaltenkonfigurierbar; bei
|
||||||
|
Chargen lässt sich der Lagerort mehrerer auf einmal setzen.
|
||||||
|
- **Dashboards** mit konfigurierbaren Karten und Diagrammen, **Verlauf** (wer hat
|
||||||
|
wann was ein-/ausgelagert), **Stammdaten** (Einheiten, Gebinde, Shops) und
|
||||||
|
**Import/Export**.
|
||||||
|
- **Mehrbenutzer mit Rollen:** *Admin* verwaltet Stammdaten und Benutzer,
|
||||||
|
*Nutzer* lagert nur ein/aus und sieht Bestände; alle Bewegungen werden
|
||||||
|
protokolliert.
|
||||||
|
|
||||||
|
### In der iOS-App zusätzlich
|
||||||
|
- **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat
|
||||||
|
ohne Jahr bei Frischware), **Lagerort-QR** und **Name/Marke per Text-Scan**.
|
||||||
|
- **Foto direkt beim Anlegen** (Kamera/Galerie), **Push-Benachrichtigungen** bei
|
||||||
|
ablaufenden Produkten, lokale Dashboards und Home-Screen-Schnellaktionen.
|
||||||
|
|
||||||
|
Details zur App: [ios/README.md](ios/README.md).
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
| Teil | Technik |
|
| Teil | Technik |
|
||||||
|---------|--------------------------------------|
|
|---------|----------------------------------------|
|
||||||
| Backend | Python · FastAPI · SQLAlchemy |
|
| Backend | Python · FastAPI · SQLAlchemy |
|
||||||
| DB | PostgreSQL |
|
| DB | PostgreSQL (SQLite für Tests/lokal) |
|
||||||
| Web-UI | React · Vite (via nginx ausgeliefert)|
|
| Web-UI | React · Vite (via nginx ausgeliefert) |
|
||||||
| iOS | SwiftUI (Schritt 2) |
|
| iOS | Native SwiftUI-App (VisionKit-Scanner) |
|
||||||
| Deploy | Docker Compose · `install.sh` |
|
| Deploy | Docker Compose · `install.sh` |
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/ FastAPI-App + Tests
|
backend/ FastAPI-App + Tests
|
||||||
web/ React/Vite SPA
|
web/ React/Vite SPA
|
||||||
deploy/ docker-compose.yml, install.sh, .env.example
|
ios/ Native SwiftUI-App (XcodeGen-Projekt)
|
||||||
|
deploy/ docker-compose.yml, install.sh, update.sh, .env.example
|
||||||
docs/ Roadmap
|
docs/ Roadmap
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Das Schema wird beim Start per idempotenter `ALTER TABLE`-Migration in
|
||||||
|
`backend/app/main.py` nachgezogen (kein Alembic).
|
||||||
|
|
||||||
## Installation (selfhosted, Linux)
|
## Installation (selfhosted, Linux)
|
||||||
Voraussetzung: eine Linux-Maschine (Server, NAS, Raspberry Pi …). Docker wird bei
|
Voraussetzung: eine Linux-Maschine (Server, NAS, Raspberry Pi …). Docker wird bei
|
||||||
Bedarf automatisch installiert.
|
Bedarf automatisch installiert.
|
||||||
@@ -59,6 +93,8 @@ Nach dem Start:
|
|||||||
- Web-UI: `http://<server-ip>:8080` (Port konfigurierbar)
|
- Web-UI: `http://<server-ip>:8080` (Port konfigurierbar)
|
||||||
- Anmeldung mit dem im Installer angezeigten Admin-Benutzer/Passwort.
|
- Anmeldung mit dem im Installer angezeigten Admin-Benutzer/Passwort.
|
||||||
|
|
||||||
|
Die iOS-App wird mit derselben Adresse verbunden (`http://<server-ip>:8080`).
|
||||||
|
|
||||||
## Aktualisieren
|
## Aktualisieren
|
||||||
```bash
|
```bash
|
||||||
cd vorrania/deploy
|
cd vorrania/deploy
|
||||||
@@ -99,13 +135,14 @@ npm install
|
|||||||
npm run dev # http://localhost:5173, proxyt /api → localhost:8000
|
npm run dev # http://localhost:5173, proxyt /api → localhost:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**iOS:** siehe [ios/README.md](ios/README.md) (XcodeGen-Projekt, `xcodegen generate`).
|
||||||
|
|
||||||
**Tests:**
|
**Tests:**
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pytest # prüft FEFO-Abbuchung und Einheiten-Umrechnung
|
DATABASE_URL="sqlite://" pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Nächste Schritte
|
## Weiteres
|
||||||
Native iOS-App (Schritt 2) und fortgeschrittene Features (Gruppen-Intelligenz,
|
Fahrplan und Ideen: [docs/ROADMAP.md](docs/ROADMAP.md).
|
||||||
Unterlagerorte, Push-Benachrichtigungen …) – siehe [docs/ROADMAP.md](docs/ROADMAP.md).
|
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ class Settings(BaseSettings):
|
|||||||
admin_username: str = "admin"
|
admin_username: str = "admin"
|
||||||
admin_password: str = "changeme"
|
admin_password: str = "changeme"
|
||||||
|
|
||||||
# Open Food Facts
|
# Open Food Facts (Lebensmittel)
|
||||||
off_base_url: str = "https://world.openfoodfacts.org"
|
off_base_url: str = "https://world.openfoodfacts.org"
|
||||||
|
# Open Products Facts (allgemeine Produkte / Gegenstände) – gleiche API-Struktur.
|
||||||
|
opf_base_url: str = "https://world.openproductsfacts.org"
|
||||||
off_timeout_seconds: float = 8.0
|
off_timeout_seconds: float = 8.0
|
||||||
|
|
||||||
# Behaviour
|
# Behaviour
|
||||||
|
|||||||
@@ -2,15 +2,85 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .models import Barcode, Lot, Product
|
from .models import (
|
||||||
from .schemas import BarcodeOut, ProductOut
|
Barcode,
|
||||||
from .services.conversion import KIND_OF_BASE, display_unit_info
|
BaseUnit,
|
||||||
from .services.stock import current_stock
|
Category,
|
||||||
|
CategoryTracking,
|
||||||
|
Item,
|
||||||
|
Lot,
|
||||||
|
Product,
|
||||||
|
ProductImage,
|
||||||
|
)
|
||||||
|
from .schemas import BarcodeOut, LocationMinStockOut, ProductOut
|
||||||
|
from .services.conversion import (
|
||||||
|
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:
|
||||||
|
"""Verwaltungsart eines Artikels: aus seiner Kategorie abgeleitet.
|
||||||
|
|
||||||
|
Ohne Kategorie gilt "food" – so bleibt das Verhalten bestehender
|
||||||
|
(reiner Lebensmittel-)Installationen unverändert. Ein Artikel in einer
|
||||||
|
Gegenstands-Kategorie wird als "object" geführt.
|
||||||
|
|
||||||
|
Einzelstücke und Verbrauchsgegenstände sind immer Gegenstände: Diese Arten
|
||||||
|
gibt es nur bei Gegenständen, sie werden aber (anders als "Menge je Lagerort")
|
||||||
|
über ein Flag am Artikel geführt. So bleibt ein als Verbrauchsgegenstand
|
||||||
|
umgestellter Artikel auch ohne (Gegenstands-)Kategorie ein Gegenstand.
|
||||||
|
"""
|
||||||
|
if product.individual or product.bulk:
|
||||||
|
return CategoryTracking.object.value
|
||||||
|
if product.category_id is None:
|
||||||
|
return CategoryTracking.food.value
|
||||||
|
cat = product.category or db.get(Category, product.category_id)
|
||||||
|
return cat.tracking if cat and cat.tracking else CategoryTracking.food.value
|
||||||
|
|
||||||
|
|
||||||
def product_to_out(db: Session, product: Product) -> ProductOut:
|
def product_to_out(db: Session, product: Product) -> ProductOut:
|
||||||
@@ -31,22 +101,137 @@ 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-Route), damit der Client nur geänderte Bilder neu lädt. None = kein Bild.
|
||||||
|
bild_ts = (
|
||||||
|
db.query(ProductImage.updated_at)
|
||||||
|
.filter(ProductImage.product_id == product.id)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
out.image_version = int(bild_ts.timestamp()) if bild_ts is not None else None
|
||||||
|
out.tracking = CategoryTracking(product_tracking(db, product))
|
||||||
|
out.shop_name = product.shop.name if product.shop else None
|
||||||
out.kind = KIND_OF_BASE[product.base_unit].value
|
out.kind = KIND_OF_BASE[product.base_unit].value
|
||||||
name, factor = display_unit_info(product)
|
name, factor = display_unit_info(product)
|
||||||
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 = [
|
||||||
|
LocationMinStockOut(
|
||||||
|
location_id=e.location_id,
|
||||||
|
location_name=UEBERALL_NAME if e.location_id is None else (
|
||||||
|
e.location.name if e.location else None
|
||||||
|
),
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOut]:
|
||||||
|
"""Wie ``product_to_out``, aber für viele Artikel mit **gebündelten** Abfragen
|
||||||
|
statt N+1 – für die Listen-Ansichten. Bestand, abgelaufene Chargen, Codes und
|
||||||
|
Bild-Version werden je einmal für alle Artikel geholt; Relationen (Kategorie,
|
||||||
|
Shop, Einheiten, Bedarfe, Feldwerte) sollten vom Aufrufer eager-geladen sein.
|
||||||
|
"""
|
||||||
|
if not products:
|
||||||
|
return []
|
||||||
|
ids = [p.id for p in products]
|
||||||
|
|
||||||
|
# Bestand: Lot-Summen (Nicht-Einzelstücke) bzw. Item-Anzahl (Einzelstücke).
|
||||||
|
lot_sums = dict(
|
||||||
|
db.query(Lot.product_id, func.coalesce(func.sum(Lot.quantity), 0.0))
|
||||||
|
.filter(Lot.product_id.in_(ids)).group_by(Lot.product_id).all()
|
||||||
|
)
|
||||||
|
item_counts = dict(
|
||||||
|
db.query(Item.product_id, func.count(Item.id))
|
||||||
|
.filter(Item.product_id.in_(ids)).group_by(Item.product_id).all()
|
||||||
|
)
|
||||||
|
# Abgelaufene Chargen je Artikel.
|
||||||
|
heute = date.today()
|
||||||
|
expired = dict(
|
||||||
|
db.query(Lot.product_id, func.count(Lot.id))
|
||||||
|
.filter(
|
||||||
|
Lot.product_id.in_(ids),
|
||||||
|
Lot.best_before.isnot(None),
|
||||||
|
Lot.best_before < heute,
|
||||||
|
Lot.quantity > 0,
|
||||||
|
)
|
||||||
|
.group_by(Lot.product_id).all()
|
||||||
|
)
|
||||||
|
# Zusätzliche EAN-Codes je Artikel.
|
||||||
|
codes: dict[int, list[Barcode]] = defaultdict(list)
|
||||||
|
for b in db.query(Barcode).filter(Barcode.product_id.in_(ids)).order_by(Barcode.id).all():
|
||||||
|
codes[b.product_id].append(b)
|
||||||
|
# Bild-Version (Epoch der letzten Bildänderung) je Artikel.
|
||||||
|
imgs = dict(
|
||||||
|
db.query(ProductImage.product_id, ProductImage.updated_at)
|
||||||
|
.filter(ProductImage.product_id.in_(ids)).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
out: list[ProductOut] = []
|
||||||
|
for product in products:
|
||||||
|
o = ProductOut.model_validate(product)
|
||||||
|
o.stock = (
|
||||||
|
float(item_counts.get(product.id, 0)) if product.individual
|
||||||
|
else float(lot_sums.get(product.id, 0.0))
|
||||||
|
)
|
||||||
|
o.expired_count = expired.get(product.id, 0)
|
||||||
|
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.group_name = product.group.name if product.group else None
|
||||||
|
ts = imgs.get(product.id)
|
||||||
|
o.image_version = int(ts.timestamp()) if ts is not None else None
|
||||||
|
o.tracking = CategoryTracking(product_tracking(db, product))
|
||||||
|
o.shop_name = product.shop.name if product.shop else None
|
||||||
|
o.kind = KIND_OF_BASE[product.base_unit].value
|
||||||
|
name, factor = display_unit_info(product)
|
||||||
|
o.unit_name = name
|
||||||
|
o.unit_factor = factor
|
||||||
|
o.min_stock = lies_ueberall(product.location_min_stocks)
|
||||||
|
if o.min_stock is not None:
|
||||||
|
o.min_stock_display, o.min_stock_unit_label = _min_anzeige(
|
||||||
|
product, o.min_stock, name, factor
|
||||||
|
)
|
||||||
|
_zweit_felder(o, product)
|
||||||
|
o.location_min_stocks = [
|
||||||
|
LocationMinStockOut(
|
||||||
|
location_id=e.location_id,
|
||||||
|
location_name=UEBERALL_NAME if e.location_id is None else (
|
||||||
|
e.location.name if e.location else None
|
||||||
|
),
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
out.append(o)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ from .routers import (
|
|||||||
branding,
|
branding,
|
||||||
categories,
|
categories,
|
||||||
dashboard,
|
dashboard,
|
||||||
|
field_definitions,
|
||||||
groups,
|
groups,
|
||||||
|
items,
|
||||||
locations,
|
locations,
|
||||||
maintenance,
|
maintenance,
|
||||||
package_types,
|
package_types,
|
||||||
products,
|
products,
|
||||||
settings as settings_router,
|
settings as settings_router,
|
||||||
|
shops,
|
||||||
stock,
|
stock,
|
||||||
transfer,
|
transfer,
|
||||||
units,
|
units,
|
||||||
@@ -28,13 +31,105 @@ from .seed import (
|
|||||||
ensure_builtin_categories,
|
ensure_builtin_categories,
|
||||||
ensure_builtin_package_types,
|
ensure_builtin_package_types,
|
||||||
ensure_builtin_units,
|
ensure_builtin_units,
|
||||||
|
ensure_example_object_categories,
|
||||||
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
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_locations_to_code(conn) -> None:
|
||||||
|
"""Einmalige Umstellung: Lagerort-ID von fortlaufender Zahl auf 10-Zeichen-Code.
|
||||||
|
|
||||||
|
Läuft nur, solange ``locations.id`` noch eine Integer-Spalte ist – danach ist
|
||||||
|
die Umstellung erledigt und der Block überspringt sich selbst. Alles passiert
|
||||||
|
innerhalb der umgebenden Transaktion: Bricht ein Schritt ab, wird komplett
|
||||||
|
zurückgerollt und die Datenbank bleibt auf dem alten (funktionierenden) Stand.
|
||||||
|
"""
|
||||||
|
id_type = conn.execute(text(
|
||||||
|
"SELECT data_type FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'locations' AND column_name = 'id'"
|
||||||
|
)).scalar()
|
||||||
|
if id_type not in ("integer", "bigint", "smallint"):
|
||||||
|
return # frische Installation (schon VARCHAR) oder bereits umgestellt
|
||||||
|
|
||||||
|
from .models import generate_location_code
|
||||||
|
|
||||||
|
# 1. Jedem bestehenden Lagerort einen eindeutigen Code geben (id bleibt vorerst).
|
||||||
|
conn.execute(text("ALTER TABLE locations ADD COLUMN IF NOT EXISTS code VARCHAR(10)"))
|
||||||
|
vergeben: set[str] = set()
|
||||||
|
for (lid,) in conn.execute(text("SELECT id FROM locations")).all():
|
||||||
|
code = generate_location_code()
|
||||||
|
while code in vergeben:
|
||||||
|
code = generate_location_code()
|
||||||
|
vergeben.add(code)
|
||||||
|
conn.execute(text("UPDATE locations SET code = :c WHERE id = :i"), {"c": code, "i": lid})
|
||||||
|
|
||||||
|
# 2. Fremdschlüssel-Spalten (Integer) auf den Code umziehen. Beim DROP COLUMN
|
||||||
|
# fallen die alten FK-/Unique-Constraints automatisch mit weg.
|
||||||
|
kinder = [
|
||||||
|
# (Tabelle, ON DELETE, NOT NULL danach, Unique-Constraint danach)
|
||||||
|
("lots", "SET NULL", False, None),
|
||||||
|
("movements", "SET NULL", False, None),
|
||||||
|
("items", "SET NULL", False, None),
|
||||||
|
("product_location_min_stock", "CASCADE", True, ("uq_prod_loc_min", "product_id")),
|
||||||
|
("group_location_min_stock", "CASCADE", True, ("uq_group_loc_min", "group_id")),
|
||||||
|
]
|
||||||
|
vorhanden = []
|
||||||
|
for tabelle, ond, nn, uq in kinder:
|
||||||
|
spalte_da = conn.execute(text(
|
||||||
|
"SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :t AND column_name = 'location_id'"
|
||||||
|
), {"t": tabelle}).scalar()
|
||||||
|
if spalte_da is None:
|
||||||
|
continue # Spalte existiert (noch) nicht – nichts umzuziehen
|
||||||
|
vorhanden.append((tabelle, ond, nn, uq))
|
||||||
|
conn.execute(text(f"ALTER TABLE {tabelle} ADD COLUMN location_code VARCHAR(10)"))
|
||||||
|
conn.execute(text(
|
||||||
|
f"UPDATE {tabelle} t SET location_code = l.code "
|
||||||
|
f"FROM locations l WHERE t.location_id = l.id"
|
||||||
|
))
|
||||||
|
conn.execute(text(f"ALTER TABLE {tabelle} DROP COLUMN location_id"))
|
||||||
|
conn.execute(text(f"ALTER TABLE {tabelle} RENAME COLUMN location_code TO location_id"))
|
||||||
|
|
||||||
|
# 2b. Selbstverweis parent_id ebenso auf den Code umziehen.
|
||||||
|
conn.execute(text("ALTER TABLE locations ADD COLUMN parent_code VARCHAR(10)"))
|
||||||
|
conn.execute(text(
|
||||||
|
"UPDATE locations c SET parent_code = p.code FROM locations p WHERE c.parent_id = p.id"
|
||||||
|
))
|
||||||
|
conn.execute(text("ALTER TABLE locations DROP COLUMN parent_id"))
|
||||||
|
|
||||||
|
# 3. Integer-PK durch den Code ersetzen (DROP COLUMN zieht PK und Sequenz mit).
|
||||||
|
conn.execute(text("ALTER TABLE locations DROP COLUMN id"))
|
||||||
|
conn.execute(text("ALTER TABLE locations RENAME COLUMN code TO id"))
|
||||||
|
conn.execute(text("ALTER TABLE locations ALTER COLUMN id SET NOT NULL"))
|
||||||
|
conn.execute(text("ALTER TABLE locations ADD PRIMARY KEY (id)"))
|
||||||
|
conn.execute(text("ALTER TABLE locations RENAME COLUMN parent_code TO parent_id"))
|
||||||
|
conn.execute(text(
|
||||||
|
"ALTER TABLE locations ADD CONSTRAINT locations_parent_id_fkey "
|
||||||
|
"FOREIGN KEY (parent_id) REFERENCES locations(id) ON DELETE SET NULL"
|
||||||
|
))
|
||||||
|
|
||||||
|
# 4. Fremdschlüssel (und Pflicht-/Unique-Bedingungen) neu setzen – jetzt auf den Code.
|
||||||
|
for tabelle, ond, nn, uq in vorhanden:
|
||||||
|
if nn:
|
||||||
|
# Verwaiste Mindestbestände (Lagerort gelöscht) vorher entfernen.
|
||||||
|
conn.execute(text(f"DELETE FROM {tabelle} WHERE location_id IS NULL"))
|
||||||
|
conn.execute(text(f"ALTER TABLE {tabelle} ALTER COLUMN location_id SET NOT NULL"))
|
||||||
|
conn.execute(text(
|
||||||
|
f"ALTER TABLE {tabelle} ADD CONSTRAINT {tabelle}_location_id_fkey "
|
||||||
|
f"FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE {ond}"
|
||||||
|
))
|
||||||
|
if uq is not None:
|
||||||
|
name, andere = uq
|
||||||
|
conn.execute(text(
|
||||||
|
f"ALTER TABLE {tabelle} ADD CONSTRAINT {name} UNIQUE ({andere}, location_id)"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
def _ensure_schema() -> None:
|
def _ensure_schema() -> None:
|
||||||
"""Schonende Migration: fehlende Spalten auf bestehenden Tabellen nachziehen.
|
"""Schonende Migration: fehlende Spalten auf bestehenden Tabellen nachziehen.
|
||||||
|
|
||||||
@@ -50,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 "
|
||||||
@@ -61,10 +160,73 @@ def _ensure_schema() -> None:
|
|||||||
"NOT NULL DEFAULT 'day'",
|
"NOT NULL DEFAULT 'day'",
|
||||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INTEGER "
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INTEGER "
|
||||||
"REFERENCES categories(id) ON DELETE SET NULL",
|
"REFERENCES categories(id) ON DELETE SET NULL",
|
||||||
|
# Mehrere Dashboards je Benutzer: Name und Reihenfolge kommen dazu.
|
||||||
|
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS name VARCHAR(80) "
|
||||||
|
"NOT NULL DEFAULT 'Übersicht'",
|
||||||
|
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS position INTEGER "
|
||||||
|
"NOT NULL DEFAULT 0",
|
||||||
|
# Gegenstands-Verwaltung: Verwaltungsart je Kategorie. Bestehende
|
||||||
|
# (reine Lebensmittel-)Kategorien werden dabei auf "food" gesetzt.
|
||||||
|
"ALTER TABLE categories ADD COLUMN IF NOT EXISTS tracking VARCHAR(16) "
|
||||||
|
"NOT NULL DEFAULT 'food'",
|
||||||
|
# Bezugsquelle und Onlineshop-Link am Artikel (nur für Gegenstände genutzt).
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS shop_id INTEGER "
|
||||||
|
"REFERENCES shops(id) ON DELETE SET NULL",
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS product_url VARCHAR(1024)",
|
||||||
|
# Bewegungen: Lagerort (Gegenstands-Buchungen) und Entnahmegrund.
|
||||||
|
# location_id ist ein Lagerort-Code (VARCHAR(10)), siehe
|
||||||
|
# _migrate_locations_to_code – deshalb hier gleich als VARCHAR anlegen.
|
||||||
|
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id VARCHAR(10) "
|
||||||
|
"REFERENCES locations(id) ON DELETE SET NULL",
|
||||||
|
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
|
||||||
|
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN "
|
||||||
|
"NOT NULL DEFAULT FALSE",
|
||||||
|
# Verbrauchsgegenstand: Gegenstand wie ein Lebensmittel führen (Chargen).
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS bulk BOOLEAN "
|
||||||
|
"NOT NULL DEFAULT FALSE",
|
||||||
|
# Zeitpunkt der letzten Änderung an den Stammdaten (für „Zuletzt geändert").
|
||||||
|
# Nullable anlegen und Altbestände auf created_at zurücksetzen; neue Zeilen
|
||||||
|
# füllt die ORM-Spalte (default/onupdate).
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ",
|
||||||
|
"UPDATE products SET updated_at = created_at WHERE updated_at IS NULL",
|
||||||
|
# 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 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),
|
||||||
|
# damit die folgenden ADD-COLUMN-Verweise auf die neue VARCHAR-id passen.
|
||||||
|
_migrate_locations_to_code(conn)
|
||||||
for stmt in stmts:
|
for stmt in stmts:
|
||||||
conn.execute(text(stmt))
|
conn.execute(text(stmt))
|
||||||
|
# Solange user_id eindeutig war, ging genau ein Dashboard je Benutzer.
|
||||||
|
# Der Name der Beschraenkung haengt davon ab, wie die Tabelle entstanden
|
||||||
|
# ist, deshalb wird er nachgeschlagen statt geraten.
|
||||||
|
namen = conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT conname FROM pg_constraint "
|
||||||
|
"WHERE conrelid = 'dashboard_layouts'::regclass AND contype = 'u'"
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for name in namen:
|
||||||
|
conn.execute(text(f'ALTER TABLE dashboard_layouts DROP CONSTRAINT "{name}"'))
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -77,9 +239,16 @@ async def lifespan(app: FastAPI):
|
|||||||
ensure_builtin_units(db)
|
ensure_builtin_units(db)
|
||||||
ensure_builtin_package_types(db)
|
ensure_builtin_package_types(db)
|
||||||
ensure_builtin_categories(db)
|
ensure_builtin_categories(db)
|
||||||
|
ensure_example_object_categories(db)
|
||||||
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)
|
||||||
|
# einmalig zusammenfassen – ab jetzt geschieht das beim Umlagern selbst.
|
||||||
|
consolidate_duplicate_lots(db)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
yield
|
yield
|
||||||
@@ -120,3 +289,6 @@ app.include_router(branding.router)
|
|||||||
app.include_router(categories.router)
|
app.include_router(categories.router)
|
||||||
app.include_router(maintenance.router)
|
app.include_router(maintenance.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
|
app.include_router(shops.router)
|
||||||
|
app.include_router(field_definitions.router)
|
||||||
|
app.include_router(items.router)
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
|
import secrets
|
||||||
from datetime import date, datetime, timezone
|
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
|
||||||
|
|
||||||
@@ -60,6 +65,39 @@ class UnitKind(str, enum.Enum):
|
|||||||
volume = "volume" # Basis: Milliliter
|
volume = "volume" # Basis: Milliliter
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryTracking(str, enum.Enum):
|
||||||
|
"""Wie Artikel einer Kategorie verwaltet werden.
|
||||||
|
|
||||||
|
food = Lebensmittel: Chargen mit MHD und FEFO (bestehende Logik, unverändert).
|
||||||
|
object = Gegenstand: nur Menge pro Lagerort, kein MHD, keine Chargen.
|
||||||
|
|
||||||
|
Bewusst als kurzer String gespeichert (wie DatePrecision), damit sich die
|
||||||
|
Spalte per ADD COLUMN nachziehen lässt, ohne einen Postgres-Enumtyp anzulegen.
|
||||||
|
"""
|
||||||
|
food = "food"
|
||||||
|
object = "object"
|
||||||
|
|
||||||
|
|
||||||
|
class FieldType(str, enum.Enum):
|
||||||
|
"""Art eines selbst definierten Feldes an einer Kategorie."""
|
||||||
|
text = "text" # einzeilig
|
||||||
|
textarea = "textarea" # mehrzeilig
|
||||||
|
number = "number" # Zahl, optional mit Einheit
|
||||||
|
date = "date" # Datum (z.B. Kaufdatum)
|
||||||
|
select = "select" # Auswahlliste (options = JSON-Liste)
|
||||||
|
boolean = "boolean" # Ja/Nein
|
||||||
|
|
||||||
|
|
||||||
|
class RemovalReason(str, enum.Enum):
|
||||||
|
"""Grund einer Gegenstands-Entnahme aus dem Bestand."""
|
||||||
|
lost = "lost" # verloren
|
||||||
|
broken = "broken" # kaputt
|
||||||
|
given_away = "given_away" # verschenkt
|
||||||
|
sold = "sold" # verkauft
|
||||||
|
used_up = "used_up" # aufgebraucht
|
||||||
|
other = "other" # sonstiges
|
||||||
|
|
||||||
|
|
||||||
class Unit(Base):
|
class Unit(Base):
|
||||||
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
|
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
|
||||||
|
|
||||||
@@ -85,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"
|
||||||
|
|
||||||
@@ -95,19 +154,69 @@ 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()
|
||||||
|
location_min_stocks: Mapped[list["GroupLocationMinStock"]] = relationship(
|
||||||
|
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
|
||||||
|
# fortlaufenden Zahl. So kollidieren Sicherung/Import zwischen zwei Instanzen
|
||||||
|
# praktisch nie, und der Code IST zugleich der Inhalt des QR /l/<code>.
|
||||||
|
# Alphabet ohne 0/O/1/I/L – wie bei den Einzelstück-UIDs gut ablesbar.
|
||||||
|
_LOCATION_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_location_code() -> str:
|
||||||
|
"""Zufälliger 10-Zeichen-Code für einen Lagerort (Kollision vernachlässigbar)."""
|
||||||
|
return "".join(secrets.choice(_LOCATION_CODE_ALPHABET) for _ in range(10))
|
||||||
|
|
||||||
|
|
||||||
class Location(Base):
|
class Location(Base):
|
||||||
__tablename__ = "locations"
|
__tablename__ = "locations"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(10), primary_key=True, default=generate_location_code
|
||||||
|
)
|
||||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
# Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat.
|
# Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat.
|
||||||
parent_id: Mapped[int | None] = mapped_column(
|
parent_id: Mapped[str | None] = mapped_column(
|
||||||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -130,6 +239,15 @@ class Category(Base):
|
|||||||
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
# Verwaltungsart der Artikel dieser Kategorie: "food" (Chargen+MHD) oder
|
||||||
|
# "object" (Menge je Lagerort). Neue Kategorien sind Gegenstände; bestehende
|
||||||
|
# (reine Lebensmittel-Installationen) werden bei der Migration auf "food"
|
||||||
|
# gesetzt. Als kurzer String wie date_precision, damit per ADD COLUMN nachziehbar.
|
||||||
|
tracking: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False,
|
||||||
|
default=CategoryTracking.object.value,
|
||||||
|
server_default=CategoryTracking.food.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Product(Base):
|
class Product(Base):
|
||||||
@@ -154,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(
|
||||||
@@ -181,15 +311,43 @@ class Product(Base):
|
|||||||
|
|
||||||
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
|
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
|
||||||
off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF
|
off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF
|
||||||
|
|
||||||
|
# Nur für Gegenstände: Bezugsquelle ("gekauft bei") und Link zum Onlineshop.
|
||||||
|
shop_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
product_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
# Gegenstände: als Einzelstücke (Items mit eigener UID/QR) statt als Menge je
|
||||||
|
# Lagerort verwalten. So bekommt jedes physische Stück ein eigenes Kaufdatum,
|
||||||
|
# eine eigene Garantie und Bezugsquelle (z.B. dieselbe Powerbank 2024 + 2025).
|
||||||
|
individual: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
# Gegenstände: als Verbrauchsgegenstand wie ein Lebensmittel führen (Chargen,
|
||||||
|
# Einheit, Packungsgröße, MHD optional, Mindestbestand), aber ohne eindeutigen
|
||||||
|
# Code – z.B. Sonnencreme in ml. Schließt sich mit ``individual`` aus. Für die
|
||||||
|
# Kernlogik gilt: foodLike = kein Gegenstand ODER bulk.
|
||||||
|
bulk: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
# Zeitpunkt der letzten Änderung an den Stammdaten des Artikels (nicht am
|
||||||
|
# Bestand). Wird bei jedem ORM-UPDATE der Zeile automatisch nachgezogen.
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=_now, onupdate=_now
|
||||||
|
)
|
||||||
|
|
||||||
group: Mapped[Group | None] = relationship(back_populates="products")
|
group: Mapped[Group | None] = relationship(back_populates="products")
|
||||||
category: Mapped[Category | None] = relationship()
|
category: Mapped[Category | None] = relationship()
|
||||||
|
shop: Mapped[Shop | None] = relationship()
|
||||||
display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id])
|
display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id])
|
||||||
min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id])
|
min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id])
|
||||||
lots: Mapped[list[Lot]] = relationship(
|
lots: Mapped[list[Lot]] = relationship(
|
||||||
back_populates="product", cascade="all, delete-orphan"
|
back_populates="product", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
field_values: Mapped[list[ProductFieldValue]] = relationship(
|
||||||
|
back_populates="product", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
location_min_stocks: Mapped[list["ProductLocationMinStock"]] = relationship(
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ApiToken(Base):
|
class ApiToken(Base):
|
||||||
@@ -249,8 +407,8 @@ class Lot(Base):
|
|||||||
String(8), nullable=False, default=DatePrecision.day.value,
|
String(8), nullable=False, default=DatePrecision.day.value,
|
||||||
server_default=DatePrecision.day.value,
|
server_default=DatePrecision.day.value,
|
||||||
)
|
)
|
||||||
location_id: Mapped[int | None] = mapped_column(
|
location_id: Mapped[str | None] = mapped_column(
|
||||||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
@@ -274,6 +432,14 @@ class Movement(Base):
|
|||||||
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
|
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
|
||||||
unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered
|
unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered
|
||||||
note: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
note: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
# Bei Gegenstands-Buchungen der betroffene Lagerort (Lebensmittel führen den
|
||||||
|
# Ort an der Charge/Lot). Nullable, damit bestehende Bewegungen gültig bleiben.
|
||||||
|
location_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
# Grund einer Entnahme (lost/broken/…), nur bei type=out aus dem Entfernen-Dialog.
|
||||||
|
# Als kurzer String, damit per ADD COLUMN nachziehbar (kein Postgres-Enumtyp).
|
||||||
|
reason: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
@@ -336,20 +502,28 @@ class BrandingAsset(Base):
|
|||||||
|
|
||||||
|
|
||||||
class DashboardLayout(Base):
|
class DashboardLayout(Base):
|
||||||
"""Anordnung der Karten auf der Startseite.
|
"""Ein Dashboard: benannte Anordnung von Karten.
|
||||||
|
|
||||||
``user_id = NULL`` ist die Vorgabe des Administrators: Sie dient neuen
|
``user_id = NULL`` ist die Vorgabe des Administrators: Sie dient neuen
|
||||||
Benutzern als Ausgangspunkt und lässt sich – falls in den Einstellungen so
|
Benutzern als Ausgangspunkt und lässt sich – falls in den Einstellungen so
|
||||||
gesetzt – auch verbindlich für alle machen.
|
gesetzt – auch verbindlich für alle machen.
|
||||||
|
|
||||||
|
Jeder Benutzer darf **mehrere** Dashboards haben (deshalb steht auf
|
||||||
|
``user_id`` kein unique mehr); ``position`` bestimmt ihre Reihenfolge in der
|
||||||
|
Seitenleiste.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "dashboard_layouts"
|
__tablename__ = "dashboard_layouts"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
user_id: Mapped[int | None] = mapped_column(
|
user_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=True
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||||
)
|
)
|
||||||
# JSON-Text: [{"i": "expiring", "x": 0, "y": 0, "w": 4, "h": 3, "props": {}}]
|
name: Mapped[str] = mapped_column(String(80), nullable=False, default="Übersicht")
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# JSON-Text. "i" ist die Kennung *dieser Karte*, "type" ihre Art - erst
|
||||||
|
# dadurch laesst sich dieselbe Art mehrfach auf ein Dashboard legen:
|
||||||
|
# [{"i": "k3", "type": "expiring", "x": 0, "y": 0, "w": 4, "h": 3, "props": {}}]
|
||||||
layout: Mapped[str] = mapped_column(Text, nullable=False)
|
layout: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=_now, onupdate=_now
|
DateTime(timezone=True), default=_now, onupdate=_now
|
||||||
@@ -361,3 +535,207 @@ class Setting(Base):
|
|||||||
|
|
||||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
value: Mapped[str] = mapped_column(String(255), nullable=False)
|
value: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Shop(Base):
|
||||||
|
"""Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei").
|
||||||
|
|
||||||
|
Vom Admin verwaltbare Liste (wie Lagerorte). Am Artikel optional – "unbekannt"
|
||||||
|
oder "mehrere Herkünfte" bleibt einfach leer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "shops"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||||
|
website: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FieldDefinition(Base):
|
||||||
|
"""Selbst definiertes Feld an einer Kategorie (z.B. „Kapazität“ in mAh).
|
||||||
|
|
||||||
|
Gilt für Artikel dieser Kategorie und – über die Vererbung im Kategorie-Baum –
|
||||||
|
auch für deren Unterkategorien. Der konkrete Wert je Artikel steht in
|
||||||
|
:class:`ProductFieldValue`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "field_definitions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
category_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
label: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
# Maschinenlesbarer Schlüssel (aus dem Label abgeleitet) – für Export/Anzeige.
|
||||||
|
key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
field_type: Mapped[str] = mapped_column(
|
||||||
|
String(16), nullable=False, default=FieldType.text.value
|
||||||
|
)
|
||||||
|
# Einheit für Zahlenfelder (z.B. "mAh", "g", "W").
|
||||||
|
unit: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
# JSON-Liste der Auswahlmöglichkeiten für field_type == "select".
|
||||||
|
options: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductFieldValue(Base):
|
||||||
|
"""Wert eines selbst definierten Feldes für einen konkreten Artikel."""
|
||||||
|
|
||||||
|
__tablename__ = "product_field_values"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"product_id", "field_definition_id", name="uq_pfv_product_field"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
product_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
field_definition_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("field_definitions.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
# Immer als Text gespeichert; typgerecht interpretiert wird beim Lesen/Schreiben.
|
||||||
|
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
product: Mapped[Product] = relationship(back_populates="field_values")
|
||||||
|
field_definition: Mapped[FieldDefinition] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class Item(Base):
|
||||||
|
"""Ein physisches Einzelstück eines Gegenstands (Instanz) mit eigener UID/QR.
|
||||||
|
|
||||||
|
Nur für Gegenstands-Produkte mit Einzelstück-Verwaltung (``Product.individual``).
|
||||||
|
Je Stück ein eigener Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz –
|
||||||
|
so lässt sich dasselbe Modell mehrfach getrennt führen (Powerbank 2024 + 2025).
|
||||||
|
Der QR-Code trägt die UID; ein Scan öffnet genau dieses Stück.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
# Kurzer, gut lesbarer Code für den QR (ohne verwechselbare Zeichen).
|
||||||
|
uid: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False)
|
||||||
|
product_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
location_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
shop_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
acquired_on: Mapped[date | None] = mapped_column(Date, nullable=True) # gekauft am
|
||||||
|
warranty_until: Mapped[date | None] = mapped_column(Date, nullable=True) # Garantie bis
|
||||||
|
note: Mapped[str | None] = mapped_column(String(255), nullable=True) # Notiz/Zustand
|
||||||
|
# Kaufpreis in kleinster Einheit (Rappen/Cent), damit keine Float-Rundung
|
||||||
|
# entsteht; Währung getrennt (z.B. CHF, EUR).
|
||||||
|
price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
product: Mapped[Product] = relationship()
|
||||||
|
location: Mapped[Location | None] = relationship()
|
||||||
|
shop: Mapped[Shop | None] = relationship()
|
||||||
|
documents: Mapped[list["ItemDocument"]] = relationship(
|
||||||
|
cascade="all, delete-orphan", order_by="ItemDocument.id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDocument(Base):
|
||||||
|
"""Beleg zu einem Einzelstück: Rechnung oder Garantieschein (PDF oder Bild).
|
||||||
|
|
||||||
|
Eigene Tabelle wie :class:`ProductImage`, damit die Binärdaten nicht bei
|
||||||
|
jeder Item-Abfrage mitgezogen werden. Mehrere Belege je Stück sind erlaubt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "item_documents"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
item_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
content_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
# Nur bei Bedarf laden (Download): sonst zoege jede Item-Liste die Belege mit.
|
||||||
|
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, deferred=True)
|
||||||
|
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductLocationMinStock(Base):
|
||||||
|
"""Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||||||
|
|
||||||
|
Der einzige Ort, an dem Artikel-Mindestbestaende stehen. ``location_id``
|
||||||
|
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"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
product_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
location_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
|
||||||
|
location: Mapped[Location | None] = relationship()
|
||||||
|
|
||||||
|
__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):
|
||||||
|
"""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"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
group_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
location_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
|
||||||
|
location: Mapped[Location | None] = relationship()
|
||||||
|
|
||||||
|
__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,33 +25,79 @@ _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_barcode(barcode: str) -> dict | None:
|
def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
|
||||||
"""Fragt Open Food Facts nach einem Barcode.
|
"""Fragt eine Open-Facts-Instanz (OFF oder Open Products Facts) nach einem Barcode.
|
||||||
|
|
||||||
Gibt ein vorbefülltes Produkt-Dict zurück oder None, wenn nicht gefunden.
|
Beide Dienste teilen sich dieselbe API-Struktur; es unterscheidet sich nur die
|
||||||
|
Basis-URL. Gibt ein vorbefülltes Produkt-Dict zurück oder None.
|
||||||
"""
|
"""
|
||||||
# v0-API: liefert bei Treffer status=1 + product, bei Nicht-Treffer status=0
|
# v0-API: liefert bei Treffer status=1 + product, bei Nicht-Treffer status=0
|
||||||
# (HTTP 200). Die v2-API hat kein status-Feld und antwortet mit HTTP 404,
|
# (HTTP 200). Die v2-API hat kein status-Feld und antwortet mit HTTP 404,
|
||||||
# weshalb wir bewusst v0 nutzen.
|
# weshalb wir bewusst v0 nutzen.
|
||||||
url = f"{settings.off_base_url}/api/v0/product/{barcode}.json"
|
url = f"{base_url}/api/v0/product/{barcode}.json"
|
||||||
try:
|
try:
|
||||||
resp = httpx.get(
|
resp = httpx.get(
|
||||||
url,
|
url,
|
||||||
@@ -84,18 +131,46 @@ def lookup_barcode(barcode: 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": "off",
|
"source": source,
|
||||||
"off_raw": json.dumps(product)[:20000],
|
"off_raw": json.dumps(product)[:20000],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_barcode(barcode: str, prefer: str | None = None) -> dict | None:
|
||||||
|
"""Sucht einen Barcode in den offenen Datenbanken.
|
||||||
|
|
||||||
|
Beim Scannen steht die Kategorie noch nicht fest, deshalb werden beide Quellen
|
||||||
|
der Reihe nach befragt: Lebensmittel (Open Food Facts) und allgemeine Produkte
|
||||||
|
(Open Products Facts). ``prefer="object"`` stellt die allgemeine Produkt-DB nach
|
||||||
|
vorn (z.B. beim erneuten Abgleich eines Gegenstands), sonst gewinnt Essen.
|
||||||
|
"""
|
||||||
|
off = (settings.off_base_url, "off")
|
||||||
|
opf = (settings.opf_base_url, "opf")
|
||||||
|
reihenfolge = [opf, off] if prefer == "object" else [off, opf]
|
||||||
|
for base_url, source in reihenfolge:
|
||||||
|
if not base_url:
|
||||||
|
continue
|
||||||
|
treffer = _lookup_at(barcode, base_url, source)
|
||||||
|
if treffer is not None:
|
||||||
|
return treffer
|
||||||
|
return None
|
||||||
|
|||||||
@@ -10,8 +10,17 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..models import Category, Product, User
|
from ..models import (
|
||||||
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate
|
Category,
|
||||||
|
CategoryTracking,
|
||||||
|
FieldDefinition,
|
||||||
|
Product,
|
||||||
|
ProductFieldValue,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut
|
||||||
|
from ..services.fields import effective_field_definitions
|
||||||
|
from .field_definitions import to_out as field_to_out
|
||||||
|
|
||||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||||
|
|
||||||
@@ -61,9 +70,19 @@ def create_category(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_admin),
|
||||||
) -> CategoryOut:
|
) -> CategoryOut:
|
||||||
if payload.parent_id is not None and db.get(Category, payload.parent_id) is None:
|
parent = None
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden")
|
if payload.parent_id is not None:
|
||||||
category = Category(name=payload.name, parent_id=payload.parent_id)
|
parent = db.get(Category, payload.parent_id)
|
||||||
|
if parent is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden")
|
||||||
|
# Modus: ausdrücklich gewählt > vom Elternteil geerbt > neue Oberkategorie = Gegenstand.
|
||||||
|
if payload.tracking is not None:
|
||||||
|
tracking = payload.tracking.value
|
||||||
|
elif parent is not None:
|
||||||
|
tracking = parent.tracking
|
||||||
|
else:
|
||||||
|
tracking = CategoryTracking.object.value
|
||||||
|
category = Category(name=payload.name, parent_id=payload.parent_id, tracking=tracking)
|
||||||
db.add(category)
|
db.add(category)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(category)
|
db.refresh(category)
|
||||||
@@ -95,6 +114,9 @@ def update_category(
|
|||||||
"Unterkategorien untergeordnet werden.",
|
"Unterkategorien untergeordnet werden.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Modus als kurzen String ablegen (Spalte ist String, nicht Enum-Typ).
|
||||||
|
if "tracking" in data and data["tracking"] is not None:
|
||||||
|
data["tracking"] = CategoryTracking(data["tracking"]).value
|
||||||
for field, value in data.items():
|
for field, value in data.items():
|
||||||
setattr(category, field, value)
|
setattr(category, field, value)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -120,6 +142,35 @@ def delete_category(
|
|||||||
db.query(Category).filter(Category.parent_id == category_id).update(
|
db.query(Category).filter(Category.parent_id == category_id).update(
|
||||||
{Category.parent_id: None}
|
{Category.parent_id: None}
|
||||||
)
|
)
|
||||||
|
# Eigene Felder dieser Kategorie samt aller erfassten Werte entfernen.
|
||||||
|
feld_ids = [
|
||||||
|
fid
|
||||||
|
for (fid,) in db.query(FieldDefinition.id)
|
||||||
|
.filter(FieldDefinition.category_id == category_id)
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
if feld_ids:
|
||||||
|
db.query(ProductFieldValue).filter(
|
||||||
|
ProductFieldValue.field_definition_id.in_(feld_ids)
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
db.query(FieldDefinition).filter(
|
||||||
|
FieldDefinition.category_id == category_id
|
||||||
|
).delete(synchronize_session=False)
|
||||||
db.delete(category)
|
db.delete(category)
|
||||||
db.commit()
|
db.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{category_id}/fields", response_model=list[FieldDefinitionOut])
|
||||||
|
def category_fields(
|
||||||
|
category_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> list[FieldDefinitionOut]:
|
||||||
|
"""Alle für eine Kategorie geltenden Felder – inkl. der von Oberkategorien geerbten."""
|
||||||
|
if db.get(Category, category_id) is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
|
||||||
|
return [
|
||||||
|
field_to_out(fd, inherited=inherited)
|
||||||
|
for fd, inherited in effective_field_definitions(db, category_id)
|
||||||
|
]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from ..models import (
|
|||||||
Category,
|
Category,
|
||||||
DashboardLayout,
|
DashboardLayout,
|
||||||
Group,
|
Group,
|
||||||
|
Item,
|
||||||
Lot,
|
Lot,
|
||||||
Movement,
|
Movement,
|
||||||
MovementType,
|
MovementType,
|
||||||
@@ -34,9 +35,11 @@ from ..models import (
|
|||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
ActivityPoint,
|
ActivityPoint,
|
||||||
CategoryShare,
|
CategoryShare,
|
||||||
DashboardLayoutIn,
|
DashboardCreate,
|
||||||
DashboardLayoutOut,
|
DashboardListOut,
|
||||||
|
DashboardOut,
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
|
DashboardUpdate,
|
||||||
ExpirySplit,
|
ExpirySplit,
|
||||||
FlowPoint,
|
FlowPoint,
|
||||||
TimelinePoint,
|
TimelinePoint,
|
||||||
@@ -44,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"])
|
||||||
|
|
||||||
@@ -53,8 +57,8 @@ ENFORCED_KEY = "dashboard_enforced"
|
|||||||
# Höhen zählen Rasterzeilen à 40 px; die Oberfläche hebt zu flache Karten
|
# Höhen zählen Rasterzeilen à 40 px; die Oberfläche hebt zu flache Karten
|
||||||
# zusätzlich auf ihre Mindesthöhe an.
|
# zusätzlich auf ihre Mindesthöhe an.
|
||||||
BUILTIN_LAYOUT: list[dict] = [
|
BUILTIN_LAYOUT: list[dict] = [
|
||||||
{"i": "actions", "x": 0, "y": 0, "w": 4, "h": 3},
|
{"i": "actions", "type": "actions", "x": 0, "y": 0, "w": 4, "h": 3},
|
||||||
{"i": "status", "x": 4, "y": 0, "w": 8, "h": 3},
|
{"i": "status", "type": "status", "x": 4, "y": 0, "w": 8, "h": 3},
|
||||||
# Bewusst die kombinierte Karte: "expiring" zeigt nur noch Laufendes, in der
|
# Bewusst die kombinierte Karte: "expiring" zeigt nur noch Laufendes, in der
|
||||||
# Vorgabe soll aber auch Ueberfaelliges ohne Zutun sichtbar sein.
|
# Vorgabe soll aber auch Ueberfaelliges ohne Zutun sichtbar sein.
|
||||||
{"i": "expiry-all", "x": 0, "y": 3, "w": 6, "h": 6},
|
{"i": "expiry-all", "x": 0, "y": 3, "w": 6, "h": 6},
|
||||||
@@ -70,119 +74,209 @@ def _enforced(db: Session) -> bool:
|
|||||||
return bool(row and row.value == "1")
|
return bool(row and row.value == "1")
|
||||||
|
|
||||||
|
|
||||||
def _load(db: Session, user_id: int | None) -> list[dict] | None:
|
def _normalize(layout: list[dict]) -> list[dict]:
|
||||||
row = (
|
"""Karten auf die Instanz-Form bringen.
|
||||||
db.query(DashboardLayout)
|
|
||||||
.filter(DashboardLayout.user_id.is_(None) if user_id is None else DashboardLayout.user_id == user_id)
|
Frueher war ``i`` zugleich die Kartenart – dadurch ging jede Art genau
|
||||||
.first()
|
einmal. Jetzt ist ``i`` die Kennung dieser einen Karte und ``type`` ihre
|
||||||
)
|
Art. Aeltere gespeicherte Anordnungen haben kein ``type``; fuer sie ist die
|
||||||
if row is None:
|
alte Kennung die Art.
|
||||||
return None
|
"""
|
||||||
|
result: list[dict] = []
|
||||||
|
for eintrag in layout:
|
||||||
|
if not isinstance(eintrag, dict) or "i" not in eintrag:
|
||||||
|
continue
|
||||||
|
karte = dict(eintrag)
|
||||||
|
karte.setdefault("type", karte["i"])
|
||||||
|
karte.setdefault("props", {})
|
||||||
|
result.append(karte)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(row: DashboardLayout) -> list[dict]:
|
||||||
try:
|
try:
|
||||||
data = json.loads(row.layout)
|
data = json.loads(row.layout)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return []
|
||||||
return data if isinstance(data, list) else None
|
return _normalize(data) if isinstance(data, list) else []
|
||||||
|
|
||||||
|
|
||||||
def _store(db: Session, user_id: int | None, layout: list[dict]) -> None:
|
def _rows(db: Session, user_id: int | None) -> list[DashboardLayout]:
|
||||||
row = (
|
query = db.query(DashboardLayout)
|
||||||
db.query(DashboardLayout)
|
query = query.filter(
|
||||||
.filter(DashboardLayout.user_id.is_(None) if user_id is None else DashboardLayout.user_id == user_id)
|
DashboardLayout.user_id.is_(None)
|
||||||
.first()
|
if user_id is None
|
||||||
|
else DashboardLayout.user_id == user_id
|
||||||
)
|
)
|
||||||
text = json.dumps(layout, ensure_ascii=False)
|
return query.order_by(DashboardLayout.position, DashboardLayout.id).all()
|
||||||
if row is None:
|
|
||||||
db.add(DashboardLayout(user_id=user_id, layout=text))
|
|
||||||
else:
|
def _as_out(rows: list[DashboardLayout]) -> list[DashboardOut]:
|
||||||
row.layout = text
|
return [
|
||||||
db.commit()
|
DashboardOut(id=row.id, name=row.name, position=row.position, layout=_parse(row))
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _owned(db: Session, dashboard_id: int, user: User) -> DashboardLayout:
|
||||||
|
row = db.get(DashboardLayout, dashboard_id)
|
||||||
|
if row is None or row.user_id != user.id:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Dashboard nicht gefunden")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_enforced(db: Session) -> None:
|
||||||
|
if _enforced(db) and _rows(db, None):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_403_FORBIDDEN,
|
||||||
|
"Die Startseite ist vom Administrator fest vorgegeben.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Anordnung
|
# Anordnung
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@router.get("/layout", response_model=DashboardLayoutOut)
|
@router.get("/layouts", response_model=DashboardListOut)
|
||||||
def get_layout(
|
def list_dashboards(
|
||||||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
) -> DashboardLayoutOut:
|
) -> DashboardListOut:
|
||||||
"""Eigene Anordnung, sonst die Vorgabe, sonst die eingebaute."""
|
"""Eigene Dashboards, sonst die Vorgabe, sonst das eingebaute."""
|
||||||
erzwungen = _enforced(db)
|
erzwungen = _enforced(db)
|
||||||
vorgabe = _load(db, None)
|
vorgabe = _rows(db, None)
|
||||||
|
|
||||||
if erzwungen and vorgabe is not None:
|
if erzwungen and vorgabe:
|
||||||
return DashboardLayoutOut(layout=vorgabe, source="default", enforced=True, has_default=True)
|
return DashboardListOut(
|
||||||
|
dashboards=_as_out(vorgabe), source="default", enforced=True, has_default=True
|
||||||
|
)
|
||||||
|
|
||||||
eigene = _load(db, user.id)
|
eigene = _rows(db, user.id)
|
||||||
if eigene is not None:
|
if eigene:
|
||||||
return DashboardLayoutOut(
|
return DashboardListOut(
|
||||||
layout=eigene, source="user", enforced=erzwungen, has_default=vorgabe is not None
|
dashboards=_as_out(eigene), source="user",
|
||||||
|
enforced=erzwungen, has_default=bool(vorgabe),
|
||||||
)
|
)
|
||||||
if vorgabe is not None:
|
if vorgabe:
|
||||||
return DashboardLayoutOut(
|
return DashboardListOut(
|
||||||
layout=vorgabe, source="default", enforced=erzwungen, has_default=True
|
dashboards=_as_out(vorgabe), source="default",
|
||||||
|
enforced=erzwungen, has_default=True,
|
||||||
)
|
)
|
||||||
return DashboardLayoutOut(
|
# Ohne alles das eingebaute Dashboard - mit id 0, weil es keine Zeile hat.
|
||||||
layout=BUILTIN_LAYOUT, source="builtin", enforced=erzwungen, has_default=False
|
return DashboardListOut(
|
||||||
|
dashboards=[DashboardOut(id=0, name="Übersicht", position=0,
|
||||||
|
layout=_normalize(BUILTIN_LAYOUT))],
|
||||||
|
source="builtin", enforced=erzwungen, has_default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/layout", response_model=DashboardLayoutOut)
|
@router.post("/layouts", response_model=DashboardOut, status_code=status.HTTP_201_CREATED)
|
||||||
def put_layout(
|
def create_dashboard(
|
||||||
payload: DashboardLayoutIn,
|
payload: DashboardCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> DashboardLayoutOut:
|
) -> DashboardOut:
|
||||||
if _enforced(db) and _load(db, None) is not None:
|
_guard_enforced(db)
|
||||||
raise HTTPException(
|
bestehende = _rows(db, user.id)
|
||||||
status.HTTP_403_FORBIDDEN,
|
# Legt der Benutzer sein erstes eigenes Dashboard an, waehrend er noch auf
|
||||||
"Die Startseite ist vom Administrator fest vorgegeben.",
|
# der Vorgabe sitzt, wuerde diese sonst schlagartig verschwinden. Deshalb
|
||||||
)
|
# wird sie vorher als eigene Dashboards uebernommen.
|
||||||
_store(db, user.id, payload.layout)
|
if not bestehende:
|
||||||
return get_layout(db=db, user=user)
|
for row in _rows(db, None):
|
||||||
|
db.add(DashboardLayout(user_id=user.id, name=row.name,
|
||||||
|
position=row.position, layout=row.layout))
|
||||||
|
db.flush()
|
||||||
|
bestehende = _rows(db, user.id)
|
||||||
|
|
||||||
|
position = max((row.position for row in bestehende), default=-1) + 1
|
||||||
|
neu = DashboardLayout(
|
||||||
|
user_id=user.id, name=payload.name.strip(), position=position,
|
||||||
|
layout=json.dumps(_normalize(payload.layout), ensure_ascii=False),
|
||||||
|
)
|
||||||
|
db.add(neu)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(neu)
|
||||||
|
return DashboardOut(id=neu.id, name=neu.name, position=neu.position, layout=_parse(neu))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/layout")
|
@router.patch("/layouts/{dashboard_id}", response_model=DashboardOut)
|
||||||
def reset_layout(
|
def update_dashboard(
|
||||||
|
dashboard_id: int,
|
||||||
|
payload: DashboardUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> DashboardOut:
|
||||||
|
_guard_enforced(db)
|
||||||
|
row = _owned(db, dashboard_id, user)
|
||||||
|
|
||||||
|
daten = payload.model_dump(exclude_unset=True)
|
||||||
|
if daten.get("name"):
|
||||||
|
row.name = daten["name"].strip()
|
||||||
|
if daten.get("layout") is not None:
|
||||||
|
row.layout = json.dumps(_normalize(daten["layout"]), ensure_ascii=False)
|
||||||
|
if daten.get("position") is not None:
|
||||||
|
row.position = daten["position"]
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return DashboardOut(id=row.id, name=row.name, position=row.position, layout=_parse(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/layouts/{dashboard_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_dashboard(
|
||||||
|
dashboard_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> Response:
|
||||||
|
_guard_enforced(db)
|
||||||
|
row = _owned(db, dashboard_id, user)
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/layouts")
|
||||||
|
def reset_dashboards(
|
||||||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Eigene Anordnung verwerfen – danach gilt wieder die Vorgabe.
|
"""Alle eigenen Dashboards verwerfen – danach gilt wieder die Vorgabe.
|
||||||
|
|
||||||
Der Statuscode steht bewusst am Response und nicht im Dekorator: Durch
|
Der Statuscode steht bewusst am Response und nicht im Dekorator: Durch
|
||||||
``from __future__ import annotations`` wird ``-> None`` zu einer Zeichenkette,
|
``from __future__ import annotations`` wird ``-> None`` zu einer Zeichenkette,
|
||||||
die FastAPI zu ``NoneType`` auflöst und als Antwortmodell wertet – zusammen
|
die FastAPI zu ``NoneType`` auflöst und als Antwortmodell wertet – zusammen
|
||||||
mit 204 (das keinen Rumpf haben darf) bricht der Start dann ab.
|
mit 204 (das keinen Rumpf haben darf) bricht der Start dann ab.
|
||||||
"""
|
"""
|
||||||
row = db.query(DashboardLayout).filter(DashboardLayout.user_id == user.id).first()
|
for row in _rows(db, user.id):
|
||||||
if row is not None:
|
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/layout/default", response_model=DashboardLayoutOut)
|
@router.put("/layouts/default", response_model=DashboardListOut)
|
||||||
def put_default_layout(
|
def put_default_dashboards(
|
||||||
payload: DashboardLayoutIn,
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: User = Depends(require_admin),
|
admin: User = Depends(require_admin),
|
||||||
) -> DashboardLayoutOut:
|
) -> DashboardListOut:
|
||||||
_store(db, None, payload.layout)
|
"""Die eigenen Dashboards zur Vorgabe für alle machen."""
|
||||||
return get_layout(db=db, user=admin)
|
for row in _rows(db, None):
|
||||||
|
db.delete(row)
|
||||||
|
for row in _rows(db, admin.id):
|
||||||
|
db.add(DashboardLayout(user_id=None, name=row.name,
|
||||||
|
position=row.position, layout=row.layout))
|
||||||
|
db.commit()
|
||||||
|
return list_dashboards(db=db, user=admin)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/layout/enforced", response_model=DashboardLayoutOut)
|
@router.put("/layout/enforced", response_model=DashboardListOut)
|
||||||
def set_enforced(
|
def set_enforced(
|
||||||
value: bool,
|
value: bool,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: User = Depends(require_admin),
|
admin: User = Depends(require_admin),
|
||||||
) -> DashboardLayoutOut:
|
) -> DashboardListOut:
|
||||||
row = db.get(Setting, ENFORCED_KEY)
|
row = db.get(Setting, ENFORCED_KEY)
|
||||||
if row is None:
|
if row is None:
|
||||||
db.add(Setting(key=ENFORCED_KEY, value="1" if value else "0"))
|
db.add(Setting(key=ENFORCED_KEY, value="1" if value else "0"))
|
||||||
else:
|
else:
|
||||||
row.value = "1" if value else "0"
|
row.value = "1" if value else "0"
|
||||||
db.commit()
|
db.commit()
|
||||||
return get_layout(db=db, user=admin)
|
return list_dashboards(db=db, user=admin)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -208,6 +302,35 @@ def _lots_mit_produkt(db: Session) -> list[tuple[Lot, Product]]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bestand_beitraege(
|
||||||
|
db: Session, heute: date, warnfrist: int
|
||||||
|
) -> list[tuple[Product, float, str]]:
|
||||||
|
"""Alle Bestands-Beiträge als (Produkt, Artikeleinheiten, Ablaufzustand).
|
||||||
|
|
||||||
|
Deckt beide Speicherformen ab, damit die Auswertungen vollständig sind:
|
||||||
|
- Chargen (Lebensmittel und Mengen-Gegenstände) je Lot mit ihrem MHD-Zustand.
|
||||||
|
- Einzelstücke: jedes Item zählt als ein Stück ohne MHD ("no_date"). Diese
|
||||||
|
liegen als Items und nicht als Lots vor – ohne sie fehlten Einzelstück-
|
||||||
|
Produkte (z.B. eine Kamera) in jeder Kategorie-/Ablauf-Auswertung.
|
||||||
|
"""
|
||||||
|
beitraege: list[tuple[Product, float, str]] = []
|
||||||
|
for lot, product in _lots_mit_produkt(db):
|
||||||
|
# Einzelstücke werden über ihre Items gezählt (siehe unten), nie über Lots.
|
||||||
|
if product.individual:
|
||||||
|
continue
|
||||||
|
faktor, _label = article_unit(product)
|
||||||
|
menge = lot.quantity / (faktor or 1.0)
|
||||||
|
beitraege.append((product, menge, _zustand(lot.best_before, heute, warnfrist)))
|
||||||
|
|
||||||
|
for product in db.query(Product).filter(Product.individual.is_(True)).all():
|
||||||
|
anzahl = db.query(Item).filter(Item.product_id == product.id).count()
|
||||||
|
if anzahl <= 0:
|
||||||
|
continue
|
||||||
|
faktor, _label = article_unit(product)
|
||||||
|
beitraege.append((product, anzahl / (faktor or 1.0), "no_date"))
|
||||||
|
return beitraege
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_model=DashboardStats)
|
@router.get("/stats", response_model=DashboardStats)
|
||||||
def stats(
|
def stats(
|
||||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||||
@@ -218,29 +341,18 @@ def stats(
|
|||||||
einheiten = 0.0
|
einheiten = 0.0
|
||||||
bestand_produkte: set[int] = set()
|
bestand_produkte: set[int] = set()
|
||||||
bald = abgelaufen = 0
|
bald = abgelaufen = 0
|
||||||
for lot, product in _lots_mit_produkt(db):
|
for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
||||||
faktor, _label = article_unit(product)
|
einheiten += menge
|
||||||
einheiten += lot.quantity / faktor if faktor else 0.0
|
|
||||||
bestand_produkte.add(product.id)
|
bestand_produkte.add(product.id)
|
||||||
zustand = _zustand(lot.best_before, heute, warnfrist)
|
|
||||||
if zustand == "expired":
|
if zustand == "expired":
|
||||||
abgelaufen += 1
|
abgelaufen += 1
|
||||||
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),
|
||||||
@@ -259,29 +371,54 @@ def expiry_split(
|
|||||||
heute = date.today()
|
heute = date.today()
|
||||||
warnfrist = get_expiry_warning_days(db)
|
warnfrist = get_expiry_warning_days(db)
|
||||||
summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0}
|
summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0}
|
||||||
for lot, product in _lots_mit_produkt(db):
|
for _product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
||||||
faktor, _label = article_unit(product)
|
summen[zustand] += menge
|
||||||
summen[_zustand(lot.best_before, heute, warnfrist)] += lot.quantity / (faktor or 1.0)
|
|
||||||
return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()})
|
return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()})
|
||||||
|
|
||||||
|
|
||||||
@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 lot, product in _lots_mit_produkt(db):
|
for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
||||||
faktor, _label = article_unit(product)
|
topf = eimer[rollup(product.category_id)]
|
||||||
menge = lot.quantity / (faktor or 1.0)
|
|
||||||
topf = eimer[product.category_id]
|
|
||||||
topf["article_units"] += menge
|
topf["article_units"] += menge
|
||||||
topf[_zustand(lot.best_before, heute, warnfrist)] += menge
|
topf[zustand] += menge
|
||||||
|
|
||||||
ergebnis = [
|
ergebnis = [
|
||||||
CategoryShare(
|
CategoryShare(
|
||||||
|
|||||||
143
backend/app/routers/field_definitions.py
Normal file
143
backend/app/routers/field_definitions.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Verwaltung der selbst definierten Felder je Kategorie."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, require_admin
|
||||||
|
from ..models import Category, FieldDefinition, FieldType, ProductFieldValue, User
|
||||||
|
from ..schemas import FieldDefinitionCreate, FieldDefinitionOut, FieldDefinitionUpdate
|
||||||
|
from ..services.fields import options_list, slugify
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/field-definitions", tags=["field-definitions"])
|
||||||
|
|
||||||
|
|
||||||
|
def to_out(fd: FieldDefinition, inherited: bool = False) -> FieldDefinitionOut:
|
||||||
|
return FieldDefinitionOut(
|
||||||
|
id=fd.id,
|
||||||
|
category_id=fd.category_id,
|
||||||
|
label=fd.label,
|
||||||
|
key=fd.key,
|
||||||
|
field_type=FieldType(fd.field_type),
|
||||||
|
unit=fd.unit,
|
||||||
|
options=options_list(fd),
|
||||||
|
required=fd.required,
|
||||||
|
position=fd.position,
|
||||||
|
is_builtin=fd.is_builtin,
|
||||||
|
inherited=inherited,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_key(db: Session, category_id: int, label: str, exclude_id: int | None = None) -> str:
|
||||||
|
"""Eindeutigen Schlüssel je Kategorie erzeugen (kapazitaet, kapazitaet_2, …)."""
|
||||||
|
basis = slugify(label)
|
||||||
|
kandidat = basis
|
||||||
|
n = 1
|
||||||
|
while True:
|
||||||
|
query = db.query(FieldDefinition).filter(
|
||||||
|
FieldDefinition.category_id == category_id, FieldDefinition.key == kandidat
|
||||||
|
)
|
||||||
|
if exclude_id is not None:
|
||||||
|
query = query.filter(FieldDefinition.id != exclude_id)
|
||||||
|
if query.first() is None:
|
||||||
|
return kandidat
|
||||||
|
n += 1
|
||||||
|
kandidat = f"{basis}_{n}"
|
||||||
|
|
||||||
|
|
||||||
|
def _options_json(field_type: FieldType, options: list[str] | None) -> str | None:
|
||||||
|
if field_type != FieldType.select:
|
||||||
|
return None
|
||||||
|
return json.dumps([o.strip() for o in (options or []) if o.strip()])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[FieldDefinitionOut])
|
||||||
|
def list_field_definitions(
|
||||||
|
category_id: int | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> list[FieldDefinitionOut]:
|
||||||
|
"""Felder auflisten – ohne category_id alle, sonst nur die dieser Kategorie."""
|
||||||
|
query = db.query(FieldDefinition)
|
||||||
|
if category_id is not None:
|
||||||
|
query = query.filter(FieldDefinition.category_id == category_id)
|
||||||
|
rows = query.order_by(
|
||||||
|
FieldDefinition.category_id, FieldDefinition.position, FieldDefinition.id
|
||||||
|
).all()
|
||||||
|
return [to_out(fd) for fd in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=FieldDefinitionOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_field_definition(
|
||||||
|
payload: FieldDefinitionCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> FieldDefinitionOut:
|
||||||
|
if db.get(Category, payload.category_id) is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
|
||||||
|
fd = FieldDefinition(
|
||||||
|
category_id=payload.category_id,
|
||||||
|
label=payload.label.strip(),
|
||||||
|
key=_unique_key(db, payload.category_id, payload.label),
|
||||||
|
field_type=payload.field_type.value,
|
||||||
|
unit=(payload.unit or None),
|
||||||
|
options=_options_json(payload.field_type, payload.options),
|
||||||
|
required=bool(payload.required),
|
||||||
|
position=payload.position,
|
||||||
|
)
|
||||||
|
db.add(fd)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(fd)
|
||||||
|
return to_out(fd)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{field_id}", response_model=FieldDefinitionOut)
|
||||||
|
def update_field_definition(
|
||||||
|
field_id: int,
|
||||||
|
payload: FieldDefinitionUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> FieldDefinitionOut:
|
||||||
|
fd = db.get(FieldDefinition, field_id)
|
||||||
|
if fd is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Feld nicht gefunden")
|
||||||
|
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
if "label" in data and data["label"]:
|
||||||
|
fd.label = data["label"].strip()
|
||||||
|
fd.key = _unique_key(db, fd.category_id, fd.label, exclude_id=fd.id)
|
||||||
|
if "field_type" in data and data["field_type"] is not None:
|
||||||
|
fd.field_type = data["field_type"].value
|
||||||
|
if "unit" in data:
|
||||||
|
fd.unit = data["unit"] or None
|
||||||
|
if "required" in data and data["required"] is not None:
|
||||||
|
fd.required = bool(data["required"])
|
||||||
|
if "position" in data and data["position"] is not None:
|
||||||
|
fd.position = data["position"]
|
||||||
|
# Optionen immer passend zum (ggf. neuen) Typ ablegen.
|
||||||
|
if "options" in data or "field_type" in data:
|
||||||
|
neuer_typ = FieldType(fd.field_type)
|
||||||
|
optionen = data["options"] if "options" in data else options_list(fd)
|
||||||
|
fd.options = _options_json(neuer_typ, optionen)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(fd)
|
||||||
|
return to_out(fd)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{field_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_field_definition(
|
||||||
|
field_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
"""Löscht das Feld samt aller dazu erfassten Werte (ON DELETE CASCADE)."""
|
||||||
|
fd = db.get(FieldDefinition, field_id)
|
||||||
|
if fd is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Feld nicht gefunden")
|
||||||
|
db.query(ProductFieldValue).filter(
|
||||||
|
ProductFieldValue.field_definition_id == field_id
|
||||||
|
).delete()
|
||||||
|
db.delete(fd)
|
||||||
|
db.commit()
|
||||||
@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..models import Barcode, Group, Product, User
|
from ..models import Barcode, Group, GroupLocationMinStock, Location, Product, User
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
BarcodeCreate,
|
BarcodeCreate,
|
||||||
BarcodeNoteUpdate,
|
BarcodeNoteUpdate,
|
||||||
@@ -11,18 +11,57 @@ from ..schemas import (
|
|||||||
GroupCreate,
|
GroupCreate,
|
||||||
GroupOut,
|
GroupOut,
|
||||||
GroupUpdate,
|
GroupUpdate,
|
||||||
|
LocationMinStockIn,
|
||||||
|
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] = {}
|
||||||
@@ -65,17 +104,44 @@ 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 = [
|
||||||
|
LocationMinStockOut(
|
||||||
|
location_id=e.location_id,
|
||||||
|
location_name=UEBERALL_NAME if e.location_id is None else (
|
||||||
|
e.location.name if e.location else None
|
||||||
|
),
|
||||||
|
min_stock=e.min_stock,
|
||||||
|
stock=_loc_stock(e.location_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
|
||||||
|
|
||||||
|
|
||||||
@@ -87,6 +153,43 @@ def list_groups(
|
|||||||
return [_group_to_out(db, g) for g in groups]
|
return [_group_to_out(db, g) for g in groups]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{group_id}/location-min-stock", response_model=GroupOut)
|
||||||
|
def set_group_location_min_stock(
|
||||||
|
group_id: int,
|
||||||
|
payload: list[LocationMinStockIn],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> GroupOut:
|
||||||
|
"""Gruppen-Mindestbestände je Lagerort ersetzen (Menge 0 = Eintrag entfällt).
|
||||||
|
|
||||||
|
``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)
|
||||||
|
if group is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||||||
|
|
||||||
|
db.query(GroupLocationMinStock).filter(
|
||||||
|
GroupLocationMinStock.group_id == group_id
|
||||||
|
).delete()
|
||||||
|
# None (= Überall) ist ein eigener Schluessel in der Dublettenpruefung.
|
||||||
|
gesehen: set[str | None] = set()
|
||||||
|
for eintrag in payload:
|
||||||
|
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
|
||||||
|
continue
|
||||||
|
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")
|
||||||
|
gesehen.add(eintrag.location_id)
|
||||||
|
db.add(GroupLocationMinStock(
|
||||||
|
group_id=group_id,
|
||||||
|
location_id=eintrag.location_id,
|
||||||
|
min_stock=eintrag.min_stock,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(group)
|
||||||
|
return _group_to_out(db, group)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
||||||
def create_group(
|
def create_group(
|
||||||
payload: GroupCreate,
|
payload: GroupCreate,
|
||||||
@@ -97,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)
|
||||||
@@ -117,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)
|
||||||
@@ -125,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)
|
||||||
@@ -247,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()
|
||||||
|
|||||||
348
backend/app/routers/items.py
Normal file
348
backend/app/routers/items.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
"""Einzelstücke (Items) eines Gegenstands: anlegen, ändern, entfernen, per UID finden.
|
||||||
|
|
||||||
|
Ein Item ist ein physisches Exemplar mit eigener UID/QR und eigenen Angaben
|
||||||
|
(Lagerort, Kaufdatum, Garantie, Bezugsquelle, Notiz). Nur sinnvoll für
|
||||||
|
Gegenstands-Produkte mit ``Product.individual``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, require_admin
|
||||||
|
from ..models import (
|
||||||
|
Item,
|
||||||
|
ItemDocument,
|
||||||
|
Location,
|
||||||
|
Movement,
|
||||||
|
MovementType,
|
||||||
|
Product,
|
||||||
|
Shop,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from ..schemas import (
|
||||||
|
DocumentSuggestions,
|
||||||
|
ItemCreate,
|
||||||
|
ItemDocumentUploadOut,
|
||||||
|
ItemOut,
|
||||||
|
ItemRemove,
|
||||||
|
ItemUpdate,
|
||||||
|
)
|
||||||
|
from ..services.items import generate_uid, item_to_out
|
||||||
|
from ..services.warranty import (
|
||||||
|
extract_pdf_text,
|
||||||
|
guess_acquired_on,
|
||||||
|
guess_price_candidates,
|
||||||
|
guess_price_cents,
|
||||||
|
guess_shop,
|
||||||
|
guess_warranty_until,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["items"])
|
||||||
|
|
||||||
|
# Belege sind meist kleine PDFs; die Grenze schuetzt vor Ausreissern und passt
|
||||||
|
# zur nginx-Grenze (client_max_body_size 12m).
|
||||||
|
DOC_MAX_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _product_or_404(db: Session, product_id: int) -> Product:
|
||||||
|
product = db.get(Product, product_id)
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def _item_or_404(db: Session, item_id: int) -> Item:
|
||||||
|
item = db.get(Item, item_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einzelstück nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _check_refs(db: Session, shop_id: int | None, location_id: str | None) -> None:
|
||||||
|
if shop_id is not None and db.get(Shop, shop_id) is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||||
|
if location_id is not None and db.get(Location, location_id) is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/products/{product_id}/items", response_model=list[ItemOut])
|
||||||
|
def list_items(
|
||||||
|
product_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> list[ItemOut]:
|
||||||
|
_product_or_404(db, product_id)
|
||||||
|
rows = db.query(Item).filter(Item.product_id == product_id).order_by(Item.id).all()
|
||||||
|
return [item_to_out(i) for i in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/products/{product_id}/items", response_model=list[ItemOut],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_items(
|
||||||
|
product_id: int,
|
||||||
|
payload: ItemCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> list[ItemOut]:
|
||||||
|
"""Ein oder mehrere (count) Einzelstücke mit gemeinsamen Startwerten anlegen."""
|
||||||
|
product = _product_or_404(db, product_id)
|
||||||
|
_check_refs(db, payload.shop_id, payload.location_id)
|
||||||
|
created: list[Item] = []
|
||||||
|
for _ in range(payload.count):
|
||||||
|
item = Item(
|
||||||
|
uid=generate_uid(db),
|
||||||
|
product_id=product.id,
|
||||||
|
location_id=payload.location_id,
|
||||||
|
shop_id=payload.shop_id,
|
||||||
|
acquired_on=payload.acquired_on,
|
||||||
|
warranty_until=payload.warranty_until,
|
||||||
|
note=payload.note,
|
||||||
|
price_cents=payload.price_cents,
|
||||||
|
currency=payload.currency,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
db.flush()
|
||||||
|
created.append(item)
|
||||||
|
db.commit()
|
||||||
|
for item in created:
|
||||||
|
db.refresh(item)
|
||||||
|
return [item_to_out(i) for i in created]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items", response_model=list[ItemOut])
|
||||||
|
def list_all_items(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> list[ItemOut]:
|
||||||
|
"""Alle Einzelstücke über alle Produkte – Grundlage der Einzelstück-Liste.
|
||||||
|
|
||||||
|
Angereichert mit Produkt-, Kategorie-, Lagerort- und Shop-Angaben; die
|
||||||
|
Beziehungen werden vorgeladen, damit die Liste nicht in N+1-Abfragen zerfällt.
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
db.query(Item)
|
||||||
|
.options(
|
||||||
|
joinedload(Item.product).joinedload(Product.category),
|
||||||
|
joinedload(Item.location),
|
||||||
|
joinedload(Item.shop),
|
||||||
|
joinedload(Item.documents),
|
||||||
|
)
|
||||||
|
.order_by(Item.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [item_to_out(i) for i in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/by-uid/{uid}", response_model=ItemOut)
|
||||||
|
def item_by_uid(
|
||||||
|
uid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> ItemOut:
|
||||||
|
"""QR-Auflösung: UID → Einzelstück (mit Produktangaben)."""
|
||||||
|
item = db.query(Item).filter(Item.uid == uid.strip().upper()).first()
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Einzelstück mit dieser UID")
|
||||||
|
return item_to_out(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/{item_id}", response_model=ItemOut)
|
||||||
|
def get_item(
|
||||||
|
item_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> ItemOut:
|
||||||
|
return item_to_out(_item_or_404(db, item_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/items/{item_id}", response_model=ItemOut)
|
||||||
|
def update_item(
|
||||||
|
item_id: int,
|
||||||
|
payload: ItemUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> ItemOut:
|
||||||
|
item = _item_or_404(db, item_id)
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
_check_refs(db, data.get("shop_id"), data.get("location_id"))
|
||||||
|
for field, value in data.items():
|
||||||
|
setattr(item, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return item_to_out(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/{item_id}/remove", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def remove_item(
|
||||||
|
item_id: int,
|
||||||
|
payload: ItemRemove,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
"""Einzelstück mit Grund entfernen – als Bewegung protokolliert (Statistik je Modell)."""
|
||||||
|
item = _item_or_404(db, item_id)
|
||||||
|
db.add(
|
||||||
|
Movement(
|
||||||
|
product_id=item.product_id,
|
||||||
|
lot_id=None,
|
||||||
|
user_id=user.id,
|
||||||
|
type=MovementType.out,
|
||||||
|
quantity=1,
|
||||||
|
unit_used="Stück",
|
||||||
|
note=(payload.note or f"Einzelstück {item.uid}"),
|
||||||
|
location_id=item.location_id,
|
||||||
|
reason=payload.reason.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.delete(item)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_item(
|
||||||
|
item_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
"""Einzelstück ohne Grund löschen (Korrektur)."""
|
||||||
|
db.delete(_item_or_404(db, item_id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Belege (Rechnung/Garantieschein) je Einzelstück
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def _document_or_404(db: Session, item_id: int, doc_id: int) -> ItemDocument:
|
||||||
|
doc = (
|
||||||
|
db.query(ItemDocument)
|
||||||
|
.filter(ItemDocument.id == doc_id, ItemDocument.item_id == item_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if doc is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Beleg nicht gefunden")
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: str | None) -> str:
|
||||||
|
"""Steuerzeichen/Anführungszeichen raus – sonst Header-Injektion im Download."""
|
||||||
|
cleaned = re.sub(r'[\r\n"]', "", (name or "").strip())
|
||||||
|
return cleaned[:255] or "beleg"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_document(file: UploadFile, data: bytes) -> str:
|
||||||
|
"""Validiert Größe/Typ eines hochgeladenen Belegs und gibt den Content-Type."""
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
|
||||||
|
if len(data) > DOC_MAX_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
f"Datei zu groß (max. {DOC_MAX_BYTES // (1024 * 1024)} MB).",
|
||||||
|
)
|
||||||
|
content_type = file.content_type or "application/octet-stream"
|
||||||
|
if content_type != "application/pdf" and not content_type.startswith("image/"):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt."
|
||||||
|
)
|
||||||
|
return content_type
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_suggestions(
|
||||||
|
db: Session, data: bytes, content_type: str, item_acquired_on=None
|
||||||
|
) -> DocumentSuggestions:
|
||||||
|
"""Garantie, Preis, Kaufdatum und Shop aus einem PDF schätzen (Bilder: leer)."""
|
||||||
|
if content_type != "application/pdf":
|
||||||
|
return DocumentSuggestions()
|
||||||
|
text = extract_pdf_text(data)
|
||||||
|
shop_id, shop_name = guess_shop(text, [(s.id, s.name) for s in db.query(Shop).all()])
|
||||||
|
return DocumentSuggestions(
|
||||||
|
suggested_warranty_until=guess_warranty_until(text, acquired_on=item_acquired_on),
|
||||||
|
suggested_price_cents=guess_price_cents(text),
|
||||||
|
suggested_price_candidates=guess_price_candidates(text),
|
||||||
|
suggested_acquired_on=guess_acquired_on(text),
|
||||||
|
suggested_shop_id=shop_id,
|
||||||
|
suggested_shop_name=shop_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/items/{item_id}/documents",
|
||||||
|
response_model=ItemDocumentUploadOut,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def upload_item_document(
|
||||||
|
item_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> ItemDocumentUploadOut:
|
||||||
|
"""Beleg (PDF oder Bild) hochladen. Bei PDF werden Garantie, Preis, Kaufdatum
|
||||||
|
und Shop vorgeschlagen."""
|
||||||
|
item = _item_or_404(db, item_id)
|
||||||
|
data = await file.read()
|
||||||
|
content_type = _read_document(file, data)
|
||||||
|
|
||||||
|
doc = ItemDocument(
|
||||||
|
item_id=item.id,
|
||||||
|
filename=_safe_filename(file.filename),
|
||||||
|
content_type=content_type,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
db.add(doc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(doc)
|
||||||
|
|
||||||
|
vorschlag = _doc_suggestions(db, data, content_type, item.acquired_on)
|
||||||
|
return ItemDocumentUploadOut(
|
||||||
|
id=doc.id,
|
||||||
|
filename=doc.filename,
|
||||||
|
content_type=doc.content_type,
|
||||||
|
uploaded_at=doc.uploaded_at,
|
||||||
|
**vorschlag.model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/analyze-document", response_model=DocumentSuggestions)
|
||||||
|
async def analyze_item_document(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> DocumentSuggestions:
|
||||||
|
"""Beleg nur analysieren (nichts speichern) – zum Vorbefüllen beim Anlegen."""
|
||||||
|
data = await file.read()
|
||||||
|
content_type = _read_document(file, data)
|
||||||
|
return _doc_suggestions(db, data, content_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/{item_id}/documents/{doc_id}")
|
||||||
|
def get_item_document(
|
||||||
|
item_id: int,
|
||||||
|
doc_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> Response:
|
||||||
|
"""Beleg anzeigen/herunterladen."""
|
||||||
|
doc = _document_or_404(db, item_id, doc_id)
|
||||||
|
return Response(
|
||||||
|
content=doc.data,
|
||||||
|
media_type=doc.content_type,
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{doc.filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/items/{item_id}/documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||||
|
)
|
||||||
|
def delete_item_document(
|
||||||
|
item_id: int,
|
||||||
|
doc_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
db.delete(_document_or_404(db, item_id, doc_id))
|
||||||
|
db.commit()
|
||||||
@@ -1,14 +1,29 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..models import Location, User
|
from ..models import Location, User
|
||||||
from ..schemas import LocationCreate, LocationOut
|
from ..schemas import LocationCreate, LocationOut, LocationUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/locations", tags=["locations"])
|
router = APIRouter(prefix="/locations", tags=["locations"])
|
||||||
|
|
||||||
|
|
||||||
|
def _descendant_ids(db: Session, location_id: str) -> set[str]:
|
||||||
|
"""Alle Unterorte (rekursiv) – als Ziel beim Umhängen ausgeschlossen, sonst
|
||||||
|
entstünde ein Ring."""
|
||||||
|
result: set[str] = set()
|
||||||
|
stack = [location_id]
|
||||||
|
while stack:
|
||||||
|
cur = stack.pop()
|
||||||
|
for kid in db.query(Location).filter(Location.parent_id == cur).all():
|
||||||
|
if kid.id not in result:
|
||||||
|
result.add(kid.id)
|
||||||
|
stack.append(kid.id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[LocationOut])
|
@router.get("", response_model=list[LocationOut])
|
||||||
def list_locations(
|
def list_locations(
|
||||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||||
@@ -29,9 +44,56 @@ def create_location(
|
|||||||
return loc
|
return loc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{location_id}", response_model=LocationOut)
|
||||||
|
def update_location(
|
||||||
|
location_id: str,
|
||||||
|
payload: LocationUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> Location:
|
||||||
|
"""Umbenennen und/oder umhängen. Chargen haengen an der ID, behalten ihren
|
||||||
|
Lagerort also."""
|
||||||
|
loc = db.get(Location, location_id)
|
||||||
|
if loc is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||||
|
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if "name" in data and data["name"]:
|
||||||
|
name = data["name"].strip()
|
||||||
|
doppelt = (
|
||||||
|
db.query(Location)
|
||||||
|
.filter(func.lower(Location.name) == name.lower(), Location.id != location_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if doppelt is not None:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Lagerort gibt es bereits")
|
||||||
|
loc.name = name
|
||||||
|
|
||||||
|
# parent_id nur anfassen, wenn ausdrücklich mitgeschickt (None = oberste Ebene).
|
||||||
|
if "parent_id" in data:
|
||||||
|
neu = data["parent_id"]
|
||||||
|
if neu is not None:
|
||||||
|
if db.get(Location, neu) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_404_NOT_FOUND, "Übergeordneter Lagerort nicht gefunden"
|
||||||
|
)
|
||||||
|
if neu == location_id or neu in _descendant_ids(db, location_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Ein Lagerort kann nicht sich selbst oder einem seiner Unterorte "
|
||||||
|
"untergeordnet werden.",
|
||||||
|
)
|
||||||
|
loc.parent_id = neu
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(loc)
|
||||||
|
return loc
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_location(
|
def delete_location(
|
||||||
location_id: int,
|
location_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_admin),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -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,21 +1,129 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
import difflib
|
||||||
from fastapi.responses import Response
|
import re
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from ..crud import product_to_out
|
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
|
||||||
from ..database import get_db
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||||
|
|
||||||
|
from ..crud import product_to_out, product_tracking, products_to_out_bulk
|
||||||
|
from ..database import SessionLocal, get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..models import Barcode, BaseUnit, Category, Group, Product, ProductImage, User
|
from ..models import (
|
||||||
|
Barcode,
|
||||||
|
BaseUnit,
|
||||||
|
Category,
|
||||||
|
CategoryTracking,
|
||||||
|
Group,
|
||||||
|
Location,
|
||||||
|
Movement,
|
||||||
|
MovementType,
|
||||||
|
Product,
|
||||||
|
ProductImage,
|
||||||
|
ProductLocationMinStock,
|
||||||
|
RemovalReason,
|
||||||
|
Shop,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from ..off import lookup_barcode
|
from ..off import lookup_barcode
|
||||||
from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate
|
from ..schemas import (
|
||||||
|
BarcodeCreate,
|
||||||
|
LocationMinStockIn,
|
||||||
|
LookupResult,
|
||||||
|
MatchCandidate,
|
||||||
|
MatchLine,
|
||||||
|
MatchRequest,
|
||||||
|
ProductCreate,
|
||||||
|
ProductOut,
|
||||||
|
ProductUpdate,
|
||||||
|
RemovalHistoryItem,
|
||||||
|
RemovalStat,
|
||||||
|
RemovalSummary,
|
||||||
|
)
|
||||||
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.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
|
||||||
|
|
||||||
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(
|
||||||
@@ -30,7 +138,16 @@ def list_products(
|
|||||||
Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will
|
Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will
|
||||||
auch "Schokolade" sehen.
|
auch "Schokolade" sehen.
|
||||||
"""
|
"""
|
||||||
query = db.query(Product)
|
query = db.query(Product).options(
|
||||||
|
# Relationen vorab laden, damit products_to_out_bulk kein N+1 auslöst.
|
||||||
|
joinedload(Product.category),
|
||||||
|
joinedload(Product.group),
|
||||||
|
joinedload(Product.shop),
|
||||||
|
joinedload(Product.display_unit),
|
||||||
|
joinedload(Product.min_stock_unit),
|
||||||
|
selectinload(Product.field_values),
|
||||||
|
selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location),
|
||||||
|
)
|
||||||
if q:
|
if q:
|
||||||
like = f"%{q}%"
|
like = f"%{q}%"
|
||||||
query = query.filter(Product.name.ilike(like))
|
query = query.filter(Product.name.ilike(like))
|
||||||
@@ -39,7 +156,7 @@ def list_products(
|
|||||||
elif category_id is not None:
|
elif category_id is not None:
|
||||||
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
|
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
|
||||||
products = query.order_by(Product.name).all()
|
products = query.order_by(Product.name).all()
|
||||||
return [product_to_out(db, p) for p in products]
|
return products_to_out_bulk(db, products)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/lookup", response_model=LookupResult)
|
@router.get("/lookup", response_model=LookupResult)
|
||||||
@@ -92,6 +209,88 @@ def get_product(
|
|||||||
return product_to_out(db, product)
|
return product_to_out(db, product)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{product_id}/location-min-stock", response_model=ProductOut)
|
||||||
|
def set_product_location_min_stock(
|
||||||
|
product_id: int,
|
||||||
|
payload: list[LocationMinStockIn],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> ProductOut:
|
||||||
|
"""Mindestbestände je Lagerort komplett ersetzen (Menge 0 = Eintrag entfällt)."""
|
||||||
|
product = db.get(Product, product_id)
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||||
|
|
||||||
|
db.query(ProductLocationMinStock).filter(
|
||||||
|
ProductLocationMinStock.product_id == product_id
|
||||||
|
).delete()
|
||||||
|
gesehen: set[int] = set()
|
||||||
|
for eintrag in payload:
|
||||||
|
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
|
||||||
|
continue
|
||||||
|
if db.get(Location, eintrag.location_id) is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||||
|
gesehen.add(eintrag.location_id)
|
||||||
|
db.add(ProductLocationMinStock(
|
||||||
|
product_id=product_id,
|
||||||
|
location_id=eintrag.location_id,
|
||||||
|
min_stock=eintrag.min_stock,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product_to_out(db, product)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{product_id}/removals", response_model=RemovalSummary)
|
||||||
|
def product_removals(
|
||||||
|
product_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> RemovalSummary:
|
||||||
|
"""Entnahmen mit Grund: Summe je Grund plus die jüngsten Einträge."""
|
||||||
|
product = db.get(Product, product_id)
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||||
|
|
||||||
|
stats = [
|
||||||
|
RemovalStat(reason=RemovalReason(grund), quantity=v["quantity"], count=v["count"])
|
||||||
|
for grund, v in removal_stats(db, product_id).items()
|
||||||
|
]
|
||||||
|
stats.sort(key=lambda s: s.quantity, reverse=True)
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.query(Movement)
|
||||||
|
.filter(
|
||||||
|
Movement.product_id == product_id,
|
||||||
|
Movement.type == MovementType.out,
|
||||||
|
Movement.reason.isnot(None),
|
||||||
|
)
|
||||||
|
.order_by(Movement.created_at.desc())
|
||||||
|
.limit(50)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
loc_names = {loc.id: loc.name for loc in db.query(Location).all()}
|
||||||
|
user_ids = {m.user_id for m in rows if m.user_id}
|
||||||
|
users = (
|
||||||
|
{u.id: u.username for u in db.query(User).filter(User.id.in_(user_ids)).all()}
|
||||||
|
if user_ids
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
history = [
|
||||||
|
RemovalHistoryItem(
|
||||||
|
reason=RemovalReason(m.reason),
|
||||||
|
quantity=m.quantity,
|
||||||
|
location_id=m.location_id,
|
||||||
|
location_name=loc_names.get(m.location_id),
|
||||||
|
note=m.note,
|
||||||
|
username=users.get(m.user_id),
|
||||||
|
created_at=m.created_at,
|
||||||
|
)
|
||||||
|
for m in rows
|
||||||
|
]
|
||||||
|
return RemovalSummary(stats=stats, history=history)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{product_id}/off", response_model=LookupResult)
|
@router.get("/{product_id}/off", response_model=LookupResult)
|
||||||
def off_vergleich(
|
def off_vergleich(
|
||||||
product_id: int,
|
product_id: int,
|
||||||
@@ -123,8 +322,10 @@ def off_vergleich(
|
|||||||
"Dieser Artikel hat keinen Barcode – ohne den kann Open Food Facts nichts finden.",
|
"Dieser Artikel hat keinen Barcode – ohne den kann Open Food Facts nichts finden.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Gegenstände zuerst in der allgemeinen Produkt-DB nachschlagen, Lebensmittel in OFF.
|
||||||
|
prefer = "object" if product_tracking(db, product) == "object" else None
|
||||||
for code in codes:
|
for code in codes:
|
||||||
suggestion = lookup_barcode(code)
|
suggestion = lookup_barcode(code, prefer=prefer)
|
||||||
if suggestion:
|
if suggestion:
|
||||||
category = suggest_category(db, suggestion)
|
category = suggest_category(db, suggestion)
|
||||||
return LookupResult(
|
return LookupResult(
|
||||||
@@ -164,9 +365,102 @@ def get_product_image(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{product_id}/image", response_model=ProductOut)
|
||||||
|
async def upload_product_image(
|
||||||
|
product_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> ProductOut:
|
||||||
|
"""Eigenes Foto hochladen (Kamera oder Galerie) und beim Artikel ablegen.
|
||||||
|
|
||||||
|
``source_url = None`` markiert das Bild als selbst hochgeladen – so ersetzt
|
||||||
|
es der Bildabgleich beim Speichern nicht durch ein Bild aus einer Adresse
|
||||||
|
(solange keine Bildadresse gesetzt ist).
|
||||||
|
"""
|
||||||
|
product = db.get(Product, product_id)
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||||
|
if file.content_type not in images.ALLOWED_TYPES:
|
||||||
|
erlaubt = ", ".join(sorted(t.split("/")[-1] for t in images.ALLOWED_TYPES))
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
f"Dieses Bildformat wird nicht unterstützt. Erlaubt sind: {erlaubt}.",
|
||||||
|
)
|
||||||
|
data = await file.read()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.")
|
||||||
|
if len(data) > images.MAX_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
f"Das Bild ist zu groß ({len(data) // 1024} KB). "
|
||||||
|
f"Erlaubt sind höchstens {images.MAX_BYTES // 1024} KB.",
|
||||||
|
)
|
||||||
|
bild = db.get(ProductImage, product.id)
|
||||||
|
if bild is None:
|
||||||
|
bild = ProductImage(
|
||||||
|
product_id=product.id, content_type=file.content_type, data=data, source_url=None
|
||||||
|
)
|
||||||
|
db.add(bild)
|
||||||
|
else:
|
||||||
|
bild.content_type, bild.data, bild.source_url = file.content_type, data, None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product_to_out(db, product)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{product_id}/image", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_product_image(
|
||||||
|
product_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
"""Foto entfernen. Eine gesetzte Bildadresse bleibt bestehen."""
|
||||||
|
bild = db.get(ProductImage, product_id)
|
||||||
|
if bild is not None:
|
||||||
|
db.delete(bild)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _store_product_image_bg(product_id: int, url: str) -> None:
|
||||||
|
"""Bild nach dem Anlegen im Hintergrund holen (eigene Session), damit das
|
||||||
|
Anlegen nicht auf den (langsamen) Netzwerk-Abruf wartet."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
product = db.get(Product, product_id)
|
||||||
|
if product is not None:
|
||||||
|
images.store(db, product, url)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
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,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_admin),
|
||||||
) -> ProductOut:
|
) -> ProductOut:
|
||||||
@@ -183,6 +477,11 @@ def create_product(
|
|||||||
base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id)
|
base_unit, display_unit_id = resolve_product_unit(db, payload.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
|
||||||
|
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")
|
||||||
|
_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,
|
||||||
@@ -192,23 +491,36 @@ 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,
|
||||||
|
product_url=payload.product_url or None,
|
||||||
|
individual=bool(payload.individual),
|
||||||
|
bulk=bool(payload.bulk),
|
||||||
source="manual",
|
source="manual",
|
||||||
)
|
)
|
||||||
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)
|
||||||
if product.image_url:
|
try:
|
||||||
# Gleich beim Anlegen holen. Schlaegt es fehl, entsteht kein Fehler:
|
apply_field_values(db, product, payload.field_values)
|
||||||
# Ein fehlendes Bild darf das Anlegen eines Artikels nicht verhindern.
|
except FieldError as exc:
|
||||||
images.store(db, product, product.image_url)
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(product)
|
db.refresh(product)
|
||||||
|
if product.image_url:
|
||||||
|
# Bild erst NACH dem Anlegen im Hintergrund holen – der Netzwerk-Abruf
|
||||||
|
# soll das Anlegen nicht mehrere Sekunden blockieren. Schlägt er fehl,
|
||||||
|
# bleibt der Artikel trotzdem angelegt (nur ohne Bild).
|
||||||
|
background_tasks.add_task(_store_product_image_bg, product.id, product.image_url)
|
||||||
return product_to_out(db, product)
|
return product_to_out(db, product)
|
||||||
|
|
||||||
|
|
||||||
@@ -224,6 +536,8 @@ def update_product(
|
|||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||||
|
|
||||||
data = payload.model_dump(exclude_unset=True)
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
# Feldwerte sind keine Spalte, sondern eigene Zeilen – getrennt behandeln.
|
||||||
|
field_values = data.pop("field_values", None)
|
||||||
if "barcode" in data and data["barcode"]:
|
if "barcode" in data and data["barcode"]:
|
||||||
clash = (
|
clash = (
|
||||||
db.query(Product)
|
db.query(Product)
|
||||||
@@ -234,6 +548,8 @@ def update_product(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits"
|
status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits"
|
||||||
)
|
)
|
||||||
|
if data.get("shop_id") is not None and db.get(Shop, data["shop_id"]) is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||||
# Einheit: unit_id (falls gesetzt) bestimmt base_unit + Anzeigeeinheit.
|
# Einheit: unit_id (falls gesetzt) bestimmt base_unit + Anzeigeeinheit.
|
||||||
if "unit_id" in data:
|
if "unit_id" in data:
|
||||||
unit_id = data.pop("unit_id")
|
unit_id = data.pop("unit_id")
|
||||||
@@ -245,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:
|
||||||
@@ -253,6 +590,13 @@ 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:
|
||||||
|
try:
|
||||||
|
apply_field_values(db, product, field_values)
|
||||||
|
except FieldError as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
# Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen.
|
# Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen.
|
||||||
sync_group_code(db, product)
|
sync_group_code(db, product)
|
||||||
if "image_url" in data:
|
if "image_url" in data:
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
88
backend/app/routers/shops.py
Normal file
88
backend/app/routers/shops.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Shops / Bezugsquellen: verwaltbare Liste für „gekauft bei“ (nur Gegenstände)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, require_admin
|
||||||
|
from ..models import Product, Shop, User
|
||||||
|
from ..schemas import ShopCreate, ShopOut, ShopUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/shops", tags=["shops"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(db: Session, shop: Shop) -> ShopOut:
|
||||||
|
out = ShopOut.model_validate(shop)
|
||||||
|
out.product_count = db.query(Product).filter(Product.shop_id == shop.id).count()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ShopOut])
|
||||||
|
def list_shops(
|
||||||
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||||
|
) -> list[ShopOut]:
|
||||||
|
return [_to_out(db, s) for s in db.query(Shop).order_by(Shop.name).all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ShopOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_shop(
|
||||||
|
payload: ShopCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> ShopOut:
|
||||||
|
name = payload.name.strip()
|
||||||
|
if db.query(Shop).filter(func.lower(Shop.name) == name.lower()).first():
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Shop gibt es bereits")
|
||||||
|
shop = Shop(name=name, website=(payload.website or None))
|
||||||
|
db.add(shop)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shop)
|
||||||
|
return _to_out(db, shop)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{shop_id}", response_model=ShopOut)
|
||||||
|
def update_shop(
|
||||||
|
shop_id: int,
|
||||||
|
payload: ShopUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> ShopOut:
|
||||||
|
shop = db.get(Shop, shop_id)
|
||||||
|
if shop is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||||
|
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
if "name" in data and data["name"]:
|
||||||
|
name = data["name"].strip()
|
||||||
|
doppelt = (
|
||||||
|
db.query(Shop)
|
||||||
|
.filter(func.lower(Shop.name) == name.lower(), Shop.id != shop_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if doppelt is not None:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Shop gibt es bereits")
|
||||||
|
shop.name = name
|
||||||
|
if "website" in data:
|
||||||
|
shop.website = data["website"] or None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(shop)
|
||||||
|
return _to_out(db, shop)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{shop_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_shop(
|
||||||
|
shop_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
"""Artikel bleiben bestehen, sie verlieren nur die Bezugsquelle (SET NULL)."""
|
||||||
|
shop = db.get(Shop, shop_id)
|
||||||
|
if shop is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||||
|
# SQLite setzt Fremdschlüssel nicht ohne Weiteres um, deshalb ausdrücklich.
|
||||||
|
db.query(Product).filter(Product.shop_id == shop_id).update(
|
||||||
|
{Product.shop_id: None}
|
||||||
|
)
|
||||||
|
db.delete(shop)
|
||||||
|
db.commit()
|
||||||
@@ -1,32 +1,44 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from ..crud import resolve_product
|
from ..crud import product_tracking, resolve_product
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user
|
from ..deps import get_current_user
|
||||||
from ..models import Lot, Movement, MovementType, User
|
from ..models import CategoryTracking, Location, Lot, Movement, MovementType, Product, User
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
BatchCheckInRequest,
|
BatchCheckInRequest,
|
||||||
BatchCheckInResponse,
|
BatchCheckInResponse,
|
||||||
|
BulkLotLocation,
|
||||||
CheckInRequest,
|
CheckInRequest,
|
||||||
CheckInResponse,
|
CheckInResponse,
|
||||||
CheckOutRequest,
|
CheckOutRequest,
|
||||||
CheckOutResponse,
|
CheckOutResponse,
|
||||||
LotOut,
|
LotOut,
|
||||||
|
LotRow,
|
||||||
|
LotSplit,
|
||||||
LotUpdate,
|
LotUpdate,
|
||||||
|
RelocateRequest,
|
||||||
|
RemoveRequest,
|
||||||
|
StockActionResponse,
|
||||||
)
|
)
|
||||||
from ..services.conversion import ConversionError
|
from ..services.conversion import ConversionError, display_unit_info
|
||||||
from ..services.dates import clean_precision, normalize_best_before
|
from ..services.dates import clean_precision, normalize_best_before
|
||||||
from ..services.stock import (
|
from ..services.stock import (
|
||||||
StockError,
|
StockError,
|
||||||
check_in,
|
check_in,
|
||||||
check_out,
|
check_out,
|
||||||
check_out_lot,
|
check_out_lot,
|
||||||
|
consolidate_lot_group,
|
||||||
current_stock,
|
current_stock,
|
||||||
|
object_add,
|
||||||
|
object_relocate,
|
||||||
|
object_remove,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["stock"])
|
router = APIRouter(tags=["stock"])
|
||||||
|
|
||||||
|
OBJECT = CategoryTracking.object.value
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stock/checkin", response_model=CheckInResponse)
|
@router.post("/stock/checkin", response_model=CheckInResponse)
|
||||||
def stock_checkin(
|
def stock_checkin(
|
||||||
@@ -35,6 +47,21 @@ def stock_checkin(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> CheckInResponse:
|
) -> CheckInResponse:
|
||||||
product = resolve_product(db, payload.product_id, payload.barcode)
|
product = resolve_product(db, payload.product_id, payload.barcode)
|
||||||
|
if product_tracking(db, product) == OBJECT:
|
||||||
|
# Gegenstände: Menge am Lagerort erhöhen, kein MHD, keine Charge-Auswahl.
|
||||||
|
lot = object_add(
|
||||||
|
db,
|
||||||
|
product=product,
|
||||||
|
quantity=payload.quantity,
|
||||||
|
location_id=payload.location_id,
|
||||||
|
user=user,
|
||||||
|
note=payload.note,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(lot)
|
||||||
|
return CheckInResponse(
|
||||||
|
lot=LotOut.model_validate(lot), product_stock=current_stock(db, product.id)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
lot = check_in(
|
lot = check_in(
|
||||||
db,
|
db,
|
||||||
@@ -103,6 +130,12 @@ def stock_checkout(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
) -> CheckOutResponse:
|
) -> CheckOutResponse:
|
||||||
product = resolve_product(db, payload.product_id, payload.barcode)
|
product = resolve_product(db, payload.product_id, payload.barcode)
|
||||||
|
if product_tracking(db, product) == OBJECT:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Gegenstände werden über „Entfernen“ (mit Grund) oder „Umlagern“ gebucht, "
|
||||||
|
"nicht ausgecheckt.",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
if payload.lot_id is not None:
|
if payload.lot_id is not None:
|
||||||
lot = db.get(Lot, payload.lot_id)
|
lot = db.get(Lot, payload.lot_id)
|
||||||
@@ -138,6 +171,63 @@ def stock_checkout(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stock/relocate", response_model=StockActionResponse)
|
||||||
|
def stock_relocate(
|
||||||
|
payload: RelocateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> StockActionResponse:
|
||||||
|
"""Gegenstands-Menge von einem Lagerort zum anderen umbuchen (ohne Grund)."""
|
||||||
|
product = resolve_product(db, payload.product_id, payload.barcode)
|
||||||
|
if product_tracking(db, product) != OBJECT:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "Umlagern gibt es nur für Gegenstände."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
object_relocate(
|
||||||
|
db,
|
||||||
|
product=product,
|
||||||
|
quantity=payload.quantity,
|
||||||
|
from_location_id=payload.from_location_id,
|
||||||
|
to_location_id=payload.to_location_id,
|
||||||
|
user=user,
|
||||||
|
note=payload.note,
|
||||||
|
)
|
||||||
|
except StockError as exc:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||||
|
db.commit()
|
||||||
|
return StockActionResponse(product_stock=current_stock(db, product.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stock/remove", response_model=StockActionResponse)
|
||||||
|
def stock_remove(
|
||||||
|
payload: RemoveRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> StockActionResponse:
|
||||||
|
"""Gegenstands-Menge mit Pflicht-Grund aus dem Bestand entfernen."""
|
||||||
|
product = resolve_product(db, payload.product_id, payload.barcode)
|
||||||
|
if product_tracking(db, product) != OBJECT:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Entfernen mit Grund gibt es nur für Gegenstände.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
object_remove(
|
||||||
|
db,
|
||||||
|
product=product,
|
||||||
|
quantity=payload.quantity,
|
||||||
|
location_id=payload.location_id,
|
||||||
|
reason=payload.reason.value,
|
||||||
|
user=user,
|
||||||
|
note=payload.note,
|
||||||
|
)
|
||||||
|
except StockError as exc:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||||
|
db.commit()
|
||||||
|
return StockActionResponse(product_stock=current_stock(db, product.id))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/lots", response_model=list[LotOut])
|
@router.get("/lots", response_model=list[LotOut])
|
||||||
def list_lots(
|
def list_lots(
|
||||||
product_id: int | None = None,
|
product_id: int | None = None,
|
||||||
@@ -150,6 +240,122 @@ def list_lots(
|
|||||||
return query.order_by(Lot.best_before.is_(None), Lot.best_before).all()
|
return query.order_by(Lot.best_before.is_(None), Lot.best_before).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/lots/rows", response_model=list[LotRow])
|
||||||
|
def list_all_lots(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> list[LotRow]:
|
||||||
|
"""Chargen der Charge-Artikel (Lebensmittel + Verbrauchsgegenstand) über alle
|
||||||
|
Artikel – für die übergreifende Chargenliste. „Menge je Lagerort" und
|
||||||
|
Einzelstücke gehören nicht hierher und werden übersprungen. Der Artikel wird
|
||||||
|
eager geladen (kein N+1)."""
|
||||||
|
lots = (
|
||||||
|
db.query(Lot)
|
||||||
|
.options(joinedload(Lot.product).joinedload(Product.category))
|
||||||
|
.order_by(Lot.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
rows: list[LotRow] = []
|
||||||
|
for lot in lots:
|
||||||
|
p = lot.product
|
||||||
|
tracking = product_tracking(db, p)
|
||||||
|
# Nur Charge-Artikel: Lebensmittel (food) oder Verbrauchsgegenstand (bulk).
|
||||||
|
food_like = tracking != CategoryTracking.object.value or bool(p.bulk)
|
||||||
|
if not food_like:
|
||||||
|
continue
|
||||||
|
name, factor = display_unit_info(p)
|
||||||
|
rows.append(LotRow(
|
||||||
|
id=lot.id, product_id=p.id, product_name=p.name, product_brand=p.brand,
|
||||||
|
tracking=CategoryTracking(tracking),
|
||||||
|
quantity=lot.quantity, base_unit=p.base_unit,
|
||||||
|
package_size=p.package_size, package_label=p.package_label,
|
||||||
|
unit_name=name, unit_factor=factor,
|
||||||
|
best_before=lot.best_before, best_before_precision=lot.best_before_precision,
|
||||||
|
location_id=lot.location_id, created_at=lot.created_at,
|
||||||
|
))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lots/bulk-location", response_model=int)
|
||||||
|
def bulk_lot_location(
|
||||||
|
payload: BulkLotLocation,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> int:
|
||||||
|
"""Lagerort mehrerer Chargen auf einmal setzen (oder mit null leeren). Reine
|
||||||
|
Ortsangabe – kein Umbuchen mit Bewegung. Gibt die Anzahl geänderter Zeilen."""
|
||||||
|
if not payload.lot_ids:
|
||||||
|
return 0
|
||||||
|
if payload.location_id is not None and db.get(Location, payload.location_id) is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||||
|
n = (
|
||||||
|
db.query(Lot)
|
||||||
|
.filter(Lot.id.in_(payload.lot_ids))
|
||||||
|
.update({Lot.location_id: payload.location_id}, synchronize_session=False)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
# Am Zielort entstandene Dubletten (gleicher Artikel + MHD) zusammenfassen.
|
||||||
|
gruppen = (
|
||||||
|
db.query(Lot.product_id, Lot.best_before, Lot.best_before_precision)
|
||||||
|
.filter(Lot.id.in_(payload.lot_ids))
|
||||||
|
.distinct()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for product_id, best_before, precision in gruppen:
|
||||||
|
consolidate_lot_group(db, product_id, best_before, precision, payload.location_id)
|
||||||
|
db.commit()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/lots/{lot_id}/split", response_model=LotOut)
|
||||||
|
def split_lot(
|
||||||
|
lot_id: int,
|
||||||
|
payload: LotSplit,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> Lot:
|
||||||
|
"""Charge aufteilen: `quantity` (Basiseinheiten) abzweigen und als neue Charge
|
||||||
|
mit gleichem MHD an einen anderen Lagerort legen. Der Rest bleibt in der alten
|
||||||
|
Charge. Reine Umbuchung wie das Setzen des Lagerorts – kein Bewegungseintrag,
|
||||||
|
der Gesamtbestand ändert sich nicht. Gibt die neu entstandene Charge zurück."""
|
||||||
|
lot = db.get(Lot, lot_id)
|
||||||
|
if lot is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
|
||||||
|
# Nur echt weniger als die Gesamtmenge lässt sich abteilen – die ganze Menge
|
||||||
|
# zu verschieben ist Sache des Lagerort-Feldes, nicht des Aufteilens.
|
||||||
|
if payload.quantity >= lot.quantity - 1e-9:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"So viel enthält die Charge nicht – zum kompletten Umlagern das "
|
||||||
|
"Lagerort-Feld nutzen.",
|
||||||
|
)
|
||||||
|
if (payload.location_id or None) == (lot.location_id or None):
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Bitte einen anderen Lagerort als den aktuellen wählen.",
|
||||||
|
)
|
||||||
|
if payload.location_id is not None and db.get(Location, payload.location_id) is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||||
|
|
||||||
|
lot.quantity -= payload.quantity
|
||||||
|
neu = Lot(
|
||||||
|
product_id=lot.product_id,
|
||||||
|
quantity=payload.quantity,
|
||||||
|
best_before=lot.best_before,
|
||||||
|
best_before_precision=lot.best_before_precision,
|
||||||
|
location_id=payload.location_id,
|
||||||
|
)
|
||||||
|
db.add(neu)
|
||||||
|
db.flush()
|
||||||
|
# Liegt am Zielort schon eine Charge mit gleichem MHD, verschmelzen beide.
|
||||||
|
survivor = consolidate_lot_group(
|
||||||
|
db, lot.product_id, neu.best_before, neu.best_before_precision, payload.location_id
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(survivor)
|
||||||
|
return survivor
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/lots/{lot_id}", response_model=LotOut)
|
@router.patch("/lots/{lot_id}", response_model=LotOut)
|
||||||
def update_lot(
|
def update_lot(
|
||||||
lot_id: int,
|
lot_id: int,
|
||||||
@@ -193,9 +399,15 @@ def update_lot(
|
|||||||
note="Charge korrigiert",
|
note="Charge korrigiert",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
db.flush()
|
||||||
|
# Ist die Charge durch die Änderung (Lagerort/MHD) zur Dublette einer anderen
|
||||||
|
# geworden, werden beide zusammengefasst.
|
||||||
|
survivor = consolidate_lot_group(
|
||||||
|
db, lot.product_id, lot.best_before, lot.best_before_precision, lot.location_id
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(lot)
|
db.refresh(survivor)
|
||||||
return lot
|
return survivor
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/lots/{lot_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/lots/{lot_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -11,36 +11,70 @@ import io
|
|||||||
import json
|
import json
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile, status
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
|
from ..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,
|
||||||
|
CategoryTracking,
|
||||||
DatePrecision,
|
DatePrecision,
|
||||||
|
FieldDefinition,
|
||||||
Group,
|
Group,
|
||||||
|
GroupLocationMinStock,
|
||||||
|
Item,
|
||||||
Location,
|
Location,
|
||||||
Lot,
|
Lot,
|
||||||
Movement,
|
Movement,
|
||||||
MovementType,
|
MovementType,
|
||||||
Product,
|
Product,
|
||||||
|
ProductLocationMinStock,
|
||||||
|
Shop,
|
||||||
Unit,
|
Unit,
|
||||||
UnitKind,
|
UnitKind,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from ..services.conversion import BASE_OF_KIND, display_unit_info, find_unit
|
from ..services.conversion import BASE_OF_KIND, display_unit_info, find_unit
|
||||||
|
from ..services.fields import (
|
||||||
|
FieldError,
|
||||||
|
apply_field_values,
|
||||||
|
effective_field_definitions,
|
||||||
|
options_list,
|
||||||
|
slugify,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["transfer"])
|
router = APIRouter(tags=["transfer"])
|
||||||
|
|
||||||
CSV_FIELDS = [
|
CSV_FIELDS = [
|
||||||
"barcode", "name", "marke", "einheit", "packungsgroesse", "gebinde",
|
"barcode", "name", "marke", "einheit", "packungsgroesse", "gebinde",
|
||||||
"gruppe", "kategorie", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort",
|
"gruppe", "kategorie", "art", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _tracking_from_art(text) -> str:
|
||||||
|
"""Spalte „art" → Verwaltungsart einer (neu angelegten) Kategorie.
|
||||||
|
|
||||||
|
Leer/unbekannt ⇒ Lebensmittel (bewahrt das Verhalten für zurückgespielte
|
||||||
|
Lebensmittel-Exporte).
|
||||||
|
"""
|
||||||
|
t = (str(text) if text is not None else "").strip().lower()
|
||||||
|
if t in ("gegenstand", "gegenstände", "object", "objekt", "non-food", "nonfood"):
|
||||||
|
return CategoryTracking.object.value
|
||||||
|
return CategoryTracking.food.value
|
||||||
|
|
||||||
|
|
||||||
|
def _art_label(product: Product) -> str:
|
||||||
|
if product.category and product.category.tracking == CategoryTracking.object.value:
|
||||||
|
return "Gegenstand"
|
||||||
|
return "Lebensmittel"
|
||||||
PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"}
|
PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"}
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +119,11 @@ def export_stock_csv(
|
|||||||
product.package_label or "",
|
product.package_label or "",
|
||||||
product.group.name if product.group else "",
|
product.group.name if product.group else "",
|
||||||
_category_path(db, product.category),
|
_category_path(db, product.category),
|
||||||
product.min_stock if product.min_stock is not None else "",
|
_art_label(product),
|
||||||
|
# „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)
|
||||||
@@ -128,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": 1,
|
# 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": [
|
||||||
@@ -138,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()
|
||||||
],
|
],
|
||||||
@@ -147,6 +193,32 @@ def export_backup_json(
|
|||||||
{"name": loc.name, "parent": loc_name.get(loc.parent_id)}
|
{"name": loc.name, "parent": loc_name.get(loc.parent_id)}
|
||||||
for loc in locations
|
for loc in locations
|
||||||
],
|
],
|
||||||
|
# Kategorien mit Verwaltungsart, damit Lebensmittel/Gegenstände beim
|
||||||
|
# Wiederherstellen erhalten bleiben (auch leere Kategorien).
|
||||||
|
"categories": [
|
||||||
|
{"path": _category_path(db, c), "tracking": c.tracking}
|
||||||
|
for c in db.query(Category).order_by(Category.id).all()
|
||||||
|
],
|
||||||
|
# Bezugsquellen (nur für Gegenstände).
|
||||||
|
"shops": [
|
||||||
|
{"name": s.name, "website": s.website}
|
||||||
|
for s in db.query(Shop).order_by(Shop.id).all()
|
||||||
|
],
|
||||||
|
# Selbst definierte Felder je Kategorie.
|
||||||
|
"field_definitions": [
|
||||||
|
{
|
||||||
|
"category": _category_path(db, db.get(Category, fd.category_id)),
|
||||||
|
"label": fd.label,
|
||||||
|
"field_type": fd.field_type,
|
||||||
|
"unit": fd.unit,
|
||||||
|
"options": options_list(fd),
|
||||||
|
"required": fd.required,
|
||||||
|
"position": fd.position,
|
||||||
|
}
|
||||||
|
for fd in db.query(FieldDefinition)
|
||||||
|
.order_by(FieldDefinition.category_id, FieldDefinition.position, FieldDefinition.id)
|
||||||
|
.all()
|
||||||
|
],
|
||||||
"products": [],
|
"products": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,9 +237,19 @@ 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:
|
||||||
|
"shop": p.shop.name if p.shop else None,
|
||||||
|
"product_url": p.product_url,
|
||||||
|
"field_values": {
|
||||||
|
pfv.field_definition.label: pfv.value for pfv in p.field_values
|
||||||
|
},
|
||||||
"lots": [
|
"lots": [
|
||||||
{
|
{
|
||||||
"quantity": lot.quantity,
|
"quantity": lot.quantity,
|
||||||
@@ -190,6 +272,71 @@ def export_backup_json(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/master-data")
|
||||||
|
def export_masterdata(
|
||||||
|
db: Session = Depends(get_db), _: User = Depends(require_admin)
|
||||||
|
) -> dict:
|
||||||
|
"""Stammdaten (Kategorien, Felder, Lagerorte, Einheiten, Gebinde) als JSON –
|
||||||
|
mit IDs, für Sicherung/Umzug."""
|
||||||
|
return export_master_data(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/master-data")
|
||||||
|
def import_masterdata(
|
||||||
|
payload: dict = Body(...),
|
||||||
|
mode: str = "skip",
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
"""Stammdaten aus JSON einspielen. mode=skip (Vorhandenes lassen) oder
|
||||||
|
overwrite (per ID überschreiben). IDs werden aus der Datei übernommen."""
|
||||||
|
try:
|
||||||
|
return import_master_data(db, payload, mode=mode)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/labels")
|
||||||
|
def export_labels(
|
||||||
|
category_ids: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Zeilen für den QR-Etiketten-Export (P-touch & Co.) – je Einzelstück eine.
|
||||||
|
|
||||||
|
``category_ids`` (z.B. "1,3") grenzt auf Kategorien samt Unterkategorien ein;
|
||||||
|
leer = alle Einzelstücke. Den QR-Inhalt (…/i/<UID>) setzt die Weboberfläche
|
||||||
|
dazu, weil nur sie die öffentliche Adresse kennt.
|
||||||
|
"""
|
||||||
|
from .categories import descendant_ids
|
||||||
|
|
||||||
|
ids: set[int] | None = None
|
||||||
|
if category_ids:
|
||||||
|
ids = set()
|
||||||
|
for part in category_ids.split(","):
|
||||||
|
teil = part.strip()
|
||||||
|
if teil.isdigit():
|
||||||
|
ids |= descendant_ids(db, int(teil))
|
||||||
|
|
||||||
|
query = db.query(Item)
|
||||||
|
if ids is not None:
|
||||||
|
query = (
|
||||||
|
query.join(Product, Item.product_id == Product.id)
|
||||||
|
.filter(Product.category_id.in_(ids))
|
||||||
|
)
|
||||||
|
rows: list[dict] = []
|
||||||
|
for item in query.order_by(Item.id).all():
|
||||||
|
product = item.product
|
||||||
|
rows.append({
|
||||||
|
"uid": item.uid,
|
||||||
|
"product": product.name if product else "",
|
||||||
|
"brand": (product.brand if product else "") or "",
|
||||||
|
"category": _category_path(db, product.category) if product else "",
|
||||||
|
"location": item.location.name if item.location else "",
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Import (nur additiv)
|
# Import (nur additiv)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -307,8 +454,16 @@ def _category_path(db: Session, category: Category | None) -> str:
|
|||||||
return " > ".join(reversed(teile))
|
return " > ".join(reversed(teile))
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_category(db: Session, path: str | None) -> Category | None:
|
def _get_or_create_category(
|
||||||
"""Legt den ganzen Pfad an, falls Teile davon fehlen."""
|
db: Session, path: str | None, tracking: str = CategoryTracking.food.value
|
||||||
|
) -> Category | None:
|
||||||
|
"""Legt den ganzen Pfad an, falls Teile davon fehlen.
|
||||||
|
|
||||||
|
Importierte Kategorien sind standardmäßig „food": Backups stammen aus der
|
||||||
|
Lebensmittel-Ausgabe, und so verhalten sich wiederhergestellte Artikel wie
|
||||||
|
zuvor. Der Modus aus einem neueren Backup (Liste ``categories``) überschreibt
|
||||||
|
das anschließend.
|
||||||
|
"""
|
||||||
path = (path or "").strip()
|
path = (path or "").strip()
|
||||||
if not path:
|
if not path:
|
||||||
return None
|
return None
|
||||||
@@ -320,13 +475,78 @@ def _get_or_create_category(db: Session, path: str | None) -> Category | None:
|
|||||||
)
|
)
|
||||||
node = query.first()
|
node = query.first()
|
||||||
if node is None:
|
if node is None:
|
||||||
node = Category(name=name, parent_id=parent.id if parent else None)
|
node = Category(
|
||||||
|
name=name,
|
||||||
|
parent_id=parent.id if parent else None,
|
||||||
|
tracking=tracking,
|
||||||
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
db.flush()
|
db.flush()
|
||||||
parent = node
|
parent = node
|
||||||
return parent
|
return parent
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_shop(db: Session, name: str | None, website: str | None = None) -> Shop | None:
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
shop = db.query(Shop).filter(Shop.name == name).first()
|
||||||
|
if shop is None:
|
||||||
|
shop = Shop(name=name, website=(website or None))
|
||||||
|
db.add(shop)
|
||||||
|
db.flush()
|
||||||
|
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:
|
||||||
@@ -371,7 +591,9 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
|
|||||||
if unit is None:
|
if unit is None:
|
||||||
raise ValueError(f"Unbekannte Einheit: {row.get('einheit')}")
|
raise ValueError(f"Unbekannte Einheit: {row.get('einheit')}")
|
||||||
group = _get_or_create_group(db, row.get("gruppe"))
|
group = _get_or_create_group(db, row.get("gruppe"))
|
||||||
category = _get_or_create_category(db, row.get("kategorie"))
|
category = _get_or_create_category(
|
||||||
|
db, row.get("kategorie"), tracking=_tracking_from_art(row.get("art"))
|
||||||
|
)
|
||||||
product = Product(
|
product = Product(
|
||||||
barcode=barcode,
|
barcode=barcode,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -388,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)
|
||||||
@@ -510,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()
|
||||||
@@ -524,6 +760,63 @@ 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
|
||||||
|
# legen ihre Kategorien weiter über die Produktpfade an (Standard: food).
|
||||||
|
for entry in data.get("categories", []):
|
||||||
|
cat = _get_or_create_category(
|
||||||
|
db,
|
||||||
|
entry.get("path"),
|
||||||
|
tracking=entry.get("tracking") or CategoryTracking.food.value,
|
||||||
|
)
|
||||||
|
if cat is not None and entry.get("tracking"):
|
||||||
|
cat.tracking = entry["tracking"]
|
||||||
|
for entry in data.get("shops", []):
|
||||||
|
_get_or_create_shop(db, entry.get("name"), entry.get("website"))
|
||||||
|
db.flush()
|
||||||
|
for entry in data.get("field_definitions", []):
|
||||||
|
cat = _get_or_create_category(db, entry.get("category"))
|
||||||
|
label = (entry.get("label") or "").strip()
|
||||||
|
if cat is None or not label:
|
||||||
|
continue
|
||||||
|
if db.query(FieldDefinition).filter_by(category_id=cat.id, label=label).first():
|
||||||
|
continue
|
||||||
|
ftype = entry.get("field_type") or "text"
|
||||||
|
optionen = entry.get("options") or []
|
||||||
|
db.add(
|
||||||
|
FieldDefinition(
|
||||||
|
category_id=cat.id,
|
||||||
|
label=label,
|
||||||
|
key=slugify(label),
|
||||||
|
field_type=ftype,
|
||||||
|
unit=(entry.get("unit") or None),
|
||||||
|
options=json.dumps(optionen) if ftype == "select" and optionen else None,
|
||||||
|
required=bool(entry.get("required")),
|
||||||
|
position=int(entry.get("position") or 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
for entry in data.get("products", []):
|
for entry in data.get("products", []):
|
||||||
try:
|
try:
|
||||||
row = {
|
row = {
|
||||||
@@ -543,6 +836,36 @@ 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.
|
||||||
|
shop_name = (entry.get("shop") or "").strip()
|
||||||
|
if shop_name and product.shop_id is None:
|
||||||
|
shop = _get_or_create_shop(db, shop_name)
|
||||||
|
product.shop_id = shop.id if shop else None
|
||||||
|
if entry.get("product_url") and not product.product_url:
|
||||||
|
product.product_url = entry["product_url"]
|
||||||
|
feldwerte = entry.get("field_values") or {}
|
||||||
|
if feldwerte and product.category_id:
|
||||||
|
nach_label = {
|
||||||
|
fd.label: fd
|
||||||
|
for fd, _ in effective_field_definitions(db, product.category_id)
|
||||||
|
}
|
||||||
|
for label, value in feldwerte.items():
|
||||||
|
fd = nach_label.get(label)
|
||||||
|
if fd is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
apply_field_values(db, product, {fd.id: value})
|
||||||
|
except FieldError:
|
||||||
|
pass
|
||||||
if mode == "replace_listed" and product.id not in cleared:
|
if mode == "replace_listed" and product.id not in cleared:
|
||||||
_clear_lots(db, product, user)
|
_clear_lots(db, product, user)
|
||||||
cleared.add(product.id)
|
cleared.add(product.id)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..models import Group, Product, Unit, User
|
from ..models import Group, Product, Unit, User
|
||||||
from ..schemas import UnitCreate, UnitOut
|
from ..schemas import UnitCreate, UnitOut, UnitUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/units", tags=["units"])
|
router = APIRouter(prefix="/units", tags=["units"])
|
||||||
|
|
||||||
@@ -33,6 +33,34 @@ def create_unit(
|
|||||||
return unit
|
return unit
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{unit_id}", response_model=UnitOut)
|
||||||
|
def update_unit(
|
||||||
|
unit_id: int,
|
||||||
|
payload: UnitUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> Unit:
|
||||||
|
"""Umbenennen – auch bei eingebauten Einheiten, wie bei den Gebinden.
|
||||||
|
Produkte und Gruppen verweisen ueber die ID und bleiben unberuehrt."""
|
||||||
|
unit = db.get(Unit, unit_id)
|
||||||
|
if unit is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einheit nicht gefunden")
|
||||||
|
|
||||||
|
name = payload.name.strip()
|
||||||
|
doppelt = (
|
||||||
|
db.query(Unit)
|
||||||
|
.filter(func.lower(Unit.name) == name.lower(), Unit.id != unit_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if doppelt is not None:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, "Einheit existiert bereits")
|
||||||
|
|
||||||
|
unit.name = name
|
||||||
|
db.commit()
|
||||||
|
db.refresh(unit)
|
||||||
|
return unit
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{unit_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{unit_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_unit(
|
def delete_unit(
|
||||||
unit_id: int,
|
unit_id: int,
|
||||||
|
|||||||
@@ -1,44 +1,314 @@
|
|||||||
|
import math
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterable
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
from typing import Callable, TypeVar
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user
|
from ..deps import get_current_user
|
||||||
from ..models import Group, Lot, Movement, Product, User
|
from ..models import (
|
||||||
from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem
|
Group,
|
||||||
from ..services.conversion import BASE_OF_KIND, display_unit_info
|
GroupLocationMinStock,
|
||||||
from ..services.stock import current_stock
|
Item,
|
||||||
|
Location,
|
||||||
|
Lot,
|
||||||
|
Movement,
|
||||||
|
PackageType,
|
||||||
|
Product,
|
||||||
|
ProductLocationMinStock,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from ..schemas import (
|
||||||
|
BaseUnit,
|
||||||
|
ExpiringItem,
|
||||||
|
GroupShoppingItem,
|
||||||
|
LocationContentEntry,
|
||||||
|
LocationContents,
|
||||||
|
LocationNeedGroup,
|
||||||
|
LocationNeedProduct,
|
||||||
|
LocationNeeds,
|
||||||
|
MovementOut,
|
||||||
|
ShoppingItem,
|
||||||
|
ShoppingListAll,
|
||||||
|
ShoppingNeed,
|
||||||
|
)
|
||||||
|
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 (
|
||||||
|
current_stock,
|
||||||
|
descendant_location_ids,
|
||||||
|
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
|
||||||
|
|
||||||
@@ -47,40 +317,206 @@ 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.
|
def _netted_topups(
|
||||||
|
minima: dict[K, float],
|
||||||
|
nachfahren: Callable[[K], set[K]],
|
||||||
|
stock_of: Callable[[K], float],
|
||||||
|
) -> dict[K, float]:
|
||||||
|
"""Bedarfe entlang ihrer Hierarchie verrechnen.
|
||||||
|
|
||||||
|
Was fuer einen Nachfahren gekauft wird, liegt auch bei dessen Vorfahren und
|
||||||
|
deckt deren Bedarf mit. ``topup(x)`` ist die ZUSAETZLICH noetige Menge –
|
||||||
|
ueber die Kaeufe fuer die Nachfahren hinaus. So kostet „Lemgo braucht 5,
|
||||||
|
Kueche braucht 2" bei je 1 fehlend nur 1 (in die Kueche), nicht 2.
|
||||||
|
|
||||||
|
``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.
|
||||||
"""
|
"""
|
||||||
items: list[GroupShoppingItem] = []
|
schluessel = list(minima)
|
||||||
groups = (
|
desc = {k: [d for d in schluessel if d != k and d in nachfahren(k)] for k in schluessel}
|
||||||
db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all()
|
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])
|
||||||
|
def shopping_list_by_location(
|
||||||
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||||
|
) -> list[LocationNeeds]:
|
||||||
|
"""Bedarfe je Lagerort: Artikel und Gruppen, denen AN DIESEM ORT etwas fehlt.
|
||||||
|
|
||||||
|
„Ü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)
|
||||||
|
group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list)
|
||||||
|
|
||||||
|
for product, mins, bestand, needs in _artikel_bedarfe(db, ort_desc).values():
|
||||||
|
faktor, label = article_unit(product)
|
||||||
|
faktor = faktor or 1.0
|
||||||
|
ist_gebinde = bool(product.package_size and product.package_size > 0)
|
||||||
|
for loc, need in needs.items():
|
||||||
|
if loc is None or need <= 1e-9:
|
||||||
|
continue
|
||||||
|
prod_needs[loc].append(LocationNeedProduct(
|
||||||
|
product_id=product.id, name=product.name, unit_label=label,
|
||||||
|
# Nach aussen weiterhin in Artikeleinheiten – gespeichert und
|
||||||
|
# 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,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
gruppen, ctxs, minima, bestand_g, needs_g = _gruppen_bedarfe(db, ort_desc)
|
||||||
|
for (gid, loc), need in needs_g.items():
|
||||||
|
if loc is None or need <= 1e-9:
|
||||||
|
continue
|
||||||
|
ctx = ctxs[gid]
|
||||||
|
schluessel = (gid, loc)
|
||||||
|
group_needs[loc].append(LocationNeedGroup(
|
||||||
|
group_id=gid, name=gruppen[gid].name, unit_name=ctx.label,
|
||||||
|
stock=round(bestand_g[schluessel] / ctx.divisor, 3),
|
||||||
|
min_stock=round(minima[schluessel] / ctx.divisor, 3),
|
||||||
|
deficit=round(need / ctx.divisor, 3),
|
||||||
|
subgroup_count=len(gruppen_graph.nachfahren_ids(gruppen[gid])),
|
||||||
|
need=_build_need(
|
||||||
|
deficit_base=need,
|
||||||
|
stock_base=bestand_g[schluessel],
|
||||||
|
min_base=minima[schluessel],
|
||||||
|
factor=ctx.divisor,
|
||||||
|
singular=ctx.label,
|
||||||
|
is_package=ctx.is_package,
|
||||||
|
base_unit=ctx.base_unit,
|
||||||
|
plural=plural,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
loc_ids = set(prod_needs) | set(group_needs)
|
||||||
|
namen = {
|
||||||
|
loc.id: loc.name
|
||||||
|
for loc in db.query(Location).filter(Location.id.in_(loc_ids)).all()
|
||||||
|
}
|
||||||
|
result: list[LocationNeeds] = []
|
||||||
|
for loc_id in sorted(loc_ids, key=lambda i: namen.get(i, "")):
|
||||||
|
result.append(LocationNeeds(
|
||||||
|
location_id=loc_id,
|
||||||
|
location_name=namen.get(loc_id, "?"),
|
||||||
|
products=sorted(prod_needs.get(loc_id, []), key=lambda x: x.deficit, reverse=True),
|
||||||
|
groups=sorted(group_needs.get(loc_id, []), key=lambda x: x.deficit, reverse=True),
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shopping-list/all", response_model=ShoppingListAll)
|
||||||
|
def shopping_list_all(
|
||||||
|
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
) -> ShoppingListAll:
|
||||||
|
"""Komplette Einkaufsliste in einem Aufruf: Produkte, Gruppen und Bedarfe je
|
||||||
|
Lagerort – genau die drei Quellen, die auch die Oberfläche zusammenführt.
|
||||||
|
Erspart drei getrennte Abfragen."""
|
||||||
|
return ShoppingListAll(
|
||||||
|
products=shopping_list(db, user),
|
||||||
|
groups=group_shopping_list(db, user),
|
||||||
|
by_location=shopping_list_by_location(db, user),
|
||||||
)
|
)
|
||||||
for group in groups:
|
|
||||||
unit = group.min_stock_unit
|
|
||||||
if unit is not None:
|
@router.get("/location/{code}", response_model=LocationContents)
|
||||||
base = BASE_OF_KIND[unit.kind]
|
def location_contents(
|
||||||
products = [p for p in group.products if p.base_unit == base]
|
code: str,
|
||||||
stock = float(sum(current_stock(db, p.id) for p in products)) / unit.factor
|
db: Session = Depends(get_db),
|
||||||
unit_name = unit.name
|
_: User = Depends(get_current_user),
|
||||||
else:
|
) -> LocationContents:
|
||||||
products = list(group.products)
|
"""Alle Artikel, die an einem Lagerort liegen – inklusive der Unterorte.
|
||||||
stock = float(sum(current_stock(db, p.id) for p in products))
|
|
||||||
unit_name = ""
|
Ziel des Lagerort-QR (`/l/<code>`) beim Nachschlagen: einmal scannen und
|
||||||
if stock < group.min_stock:
|
sehen, was im Regal/Fach (und allem darunter) steht. Zählt sowohl
|
||||||
items.append(
|
Lot-Bestände (Lebensmittel/Objekte) als auch Einzelstücke.
|
||||||
GroupShoppingItem(
|
"""
|
||||||
group_id=group.id,
|
loc = db.get(Location, code)
|
||||||
name=group.name,
|
if loc is None:
|
||||||
stock=stock,
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||||
min_stock=group.min_stock,
|
|
||||||
deficit=group.min_stock - stock,
|
ids = {code} | descendant_location_ids(db, code)
|
||||||
unit_name=unit_name,
|
product_ids: set[int] = {
|
||||||
product_count=len(products),
|
pid for (pid,) in db.query(Lot.product_id).filter(Lot.location_id.in_(ids)).distinct()
|
||||||
)
|
}
|
||||||
)
|
product_ids |= {
|
||||||
items.sort(key=lambda i: i.deficit, reverse=True)
|
pid for (pid,) in db.query(Item.product_id).filter(Item.location_id.in_(ids)).distinct()
|
||||||
return items
|
}
|
||||||
|
|
||||||
|
entries: list[LocationContentEntry] = []
|
||||||
|
for pid in product_ids:
|
||||||
|
product = db.get(Product, pid)
|
||||||
|
if product is None:
|
||||||
|
continue
|
||||||
|
faktor, label = article_unit(product)
|
||||||
|
stock = location_subtree_stock_base(db, product, code) / (faktor or 1.0)
|
||||||
|
if stock <= 0:
|
||||||
|
continue
|
||||||
|
entries.append(LocationContentEntry(
|
||||||
|
product_id=product.id, name=product.name, brand=product.brand,
|
||||||
|
stock=round(stock, 3), unit_label=label, individual=product.individual,
|
||||||
|
))
|
||||||
|
entries.sort(key=lambda e: e.name.lower())
|
||||||
|
return LocationContents(location_id=code, location_name=loc.name, products=entries)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/expiring", response_model=list[ExpiringItem])
|
@router.get("/expiring", response_model=list[ExpiringItem])
|
||||||
|
|||||||
@@ -2,9 +2,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
from .models import BaseUnit, DatePrecision, Role, UnitKind
|
from .models import (
|
||||||
|
BaseUnit,
|
||||||
|
CategoryTracking,
|
||||||
|
DatePrecision,
|
||||||
|
FieldType,
|
||||||
|
RemovalReason,
|
||||||
|
Role,
|
||||||
|
UnitKind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---- Units ----
|
# ---- Units ----
|
||||||
@@ -23,6 +31,13 @@ class UnitCreate(BaseModel):
|
|||||||
factor: float = Field(gt=0)
|
factor: float = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class UnitUpdate(BaseModel):
|
||||||
|
"""Nur der Name. Art und Faktor bleiben fest – sie stecken bereits in
|
||||||
|
umgerechneten Bestaenden, eine Aenderung wuerde die still verfaelschen."""
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
# ---- API-Tokens (externe Zugriffe, z.B. Home Assistant) ----
|
# ---- API-Tokens (externe Zugriffe, z.B. Home Assistant) ----
|
||||||
class ApiTokenOut(BaseModel):
|
class ApiTokenOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -94,66 +109,179 @@ class UserUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# ---- Groups ----
|
# ---- Groups ----
|
||||||
|
class LocationMinStockIn(BaseModel):
|
||||||
|
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Basiseinheiten).
|
||||||
|
|
||||||
|
``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)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationMinStockOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
location_id: str | None = None
|
||||||
|
location_name: str | None = None # bei location_id = null: „Überall"
|
||||||
|
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.
|
||||||
product_barcodes: list[ProductBarcodeOut] = []
|
product_barcodes: list[ProductBarcodeOut] = []
|
||||||
|
# Mindestbestand je Lagerort (zusaetzlich zum Gesamt-Mindestbestand oben).
|
||||||
|
location_min_stocks: list[LocationMinStockOut] = []
|
||||||
|
|
||||||
|
|
||||||
class GroupCreate(BaseModel):
|
class GroupCreate(BaseModel):
|
||||||
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 ----
|
||||||
class CategoryOut(BaseModel):
|
class CategoryOut(BaseModel):
|
||||||
"""Reine Ordnungshilfe – kein Bestand, kein Mindestbestand, keine EAN-Codes."""
|
"""Ordnungshilfe plus Verwaltungsart (Lebensmittel/Gegenstand)."""
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
parent_id: int | None = None
|
parent_id: int | None = None
|
||||||
is_builtin: bool = False
|
is_builtin: bool = False
|
||||||
|
# "food" = Chargen+MHD, "object" = Menge je Lagerort.
|
||||||
|
tracking: CategoryTracking = CategoryTracking.food
|
||||||
product_count: int = 0
|
product_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class CategoryCreate(BaseModel):
|
class CategoryCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
parent_id: int | None = None
|
parent_id: int | None = None
|
||||||
|
# None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand.
|
||||||
|
tracking: CategoryTracking | None = None
|
||||||
|
|
||||||
|
|
||||||
class CategoryUpdate(BaseModel):
|
class CategoryUpdate(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)
|
||||||
parent_id: int | None = None
|
parent_id: int | None = None
|
||||||
|
tracking: CategoryTracking | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Shops (Bezugsquellen, nur für Gegenstände) ----
|
||||||
|
class ShopOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
website: str | None = None
|
||||||
|
product_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ShopCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=120)
|
||||||
|
website: str | None = Field(default=None, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class ShopUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
website: str | None = Field(default=None, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Selbst definierte Felder je Kategorie ----
|
||||||
|
class FieldDefinitionBase(BaseModel):
|
||||||
|
label: str = Field(min_length=1, max_length=120)
|
||||||
|
field_type: FieldType = FieldType.text
|
||||||
|
unit: str | None = Field(default=None, max_length=32)
|
||||||
|
options: list[str] | None = None # nur für field_type == "select"
|
||||||
|
required: bool = False
|
||||||
|
position: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class FieldDefinitionCreate(FieldDefinitionBase):
|
||||||
|
category_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class FieldDefinitionUpdate(BaseModel):
|
||||||
|
label: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
field_type: FieldType | None = None
|
||||||
|
unit: str | None = Field(default=None, max_length=32)
|
||||||
|
options: list[str] | None = None
|
||||||
|
required: bool | None = None
|
||||||
|
position: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FieldDefinitionOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
category_id: int
|
||||||
|
label: str
|
||||||
|
key: str
|
||||||
|
field_type: FieldType
|
||||||
|
unit: str | None = None
|
||||||
|
options: list[str] = []
|
||||||
|
required: bool = False
|
||||||
|
position: int = 0
|
||||||
|
is_builtin: bool = False
|
||||||
|
# Bei der vererbten Liste (GET /categories/{id}/fields): stammt das Feld von
|
||||||
|
# einer Oberkategorie? Dann in der Verwaltung dort bearbeiten.
|
||||||
|
inherited: bool = False
|
||||||
|
|
||||||
|
|
||||||
# ---- Locations ----
|
# ---- Locations ----
|
||||||
class LocationOut(BaseModel):
|
class LocationOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: int
|
id: str
|
||||||
name: str
|
name: str
|
||||||
parent_id: int | None = None
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class LocationCreate(BaseModel):
|
class LocationCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
parent_id: int | None = None
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocationUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ---- Gebinde (Packung, Glas, …) ----
|
# ---- Gebinde (Packung, Glas, …) ----
|
||||||
@@ -187,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
|
||||||
@@ -196,6 +330,15 @@ class ProductBase(BaseModel):
|
|||||||
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
|
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
|
||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
min_stock_in_packages: bool = False
|
min_stock_in_packages: bool = False
|
||||||
|
# Nur für Gegenstände: Bezugsquelle und Onlineshop-Link.
|
||||||
|
shop_id: int | None = None
|
||||||
|
product_url: str | None = Field(default=None, max_length=1024)
|
||||||
|
# Gegenstände als Einzelstücke (Items mit UID/QR) statt als Menge führen.
|
||||||
|
individual: bool = False
|
||||||
|
# Gegenstand als Verbrauchsgegenstand wie ein Lebensmittel führen (Chargen).
|
||||||
|
bulk: bool = False
|
||||||
|
# Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}.
|
||||||
|
field_values: dict[int, str | None] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProductCreate(ProductBase):
|
class ProductCreate(ProductBase):
|
||||||
@@ -211,12 +354,23 @@ 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
|
||||||
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
|
||||||
min_stock_in_packages: bool | None = None
|
min_stock_in_packages: bool | None = None
|
||||||
|
shop_id: int | None = None
|
||||||
|
product_url: str | None = Field(default=None, max_length=1024)
|
||||||
|
individual: bool | None = None
|
||||||
|
bulk: bool | None = None
|
||||||
|
field_values: dict[int, str | None] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProductOut(BaseModel):
|
class ProductOut(BaseModel):
|
||||||
@@ -230,18 +384,36 @@ 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
|
||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
min_stock_in_packages: bool = False
|
min_stock_in_packages: bool = False
|
||||||
source: str
|
source: str
|
||||||
|
shop_id: int | None = None
|
||||||
|
product_url: str | None = None
|
||||||
|
individual: bool = False
|
||||||
|
bulk: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
updated_at: datetime | None = None
|
||||||
# angereichert:
|
# angereichert:
|
||||||
stock: float = 0.0
|
stock: float = 0.0
|
||||||
expired_count: int = 0
|
expired_count: int = 0
|
||||||
|
# Epoch (Sekunden) der letzten Bildänderung, sonst None (= kein Bild). Der
|
||||||
|
# Client cacht Bilder danach und lädt nur bei geänderter Version neu.
|
||||||
|
image_version: int | None = None
|
||||||
kind: str = ""
|
kind: str = ""
|
||||||
unit_name: str = ""
|
unit_name: str = ""
|
||||||
unit_factor: float = 1.0
|
unit_factor: float = 1.0
|
||||||
@@ -250,6 +422,20 @@ class ProductOut(BaseModel):
|
|||||||
min_stock_unit_label: str = ""
|
min_stock_unit_label: str = ""
|
||||||
# Zusätzliche EAN-Codes (neben dem Haupt-Barcode):
|
# Zusätzliche EAN-Codes (neben dem Haupt-Barcode):
|
||||||
barcodes: list[BarcodeOut] = []
|
barcodes: list[BarcodeOut] = []
|
||||||
|
# Verwaltungsart aus der Kategorie (food/object), Bezugsquelle und Feldwerte:
|
||||||
|
tracking: CategoryTracking = CategoryTracking.food
|
||||||
|
shop_name: str | None = None
|
||||||
|
field_values: dict[int, str | None] = {}
|
||||||
|
# Mindestbestand je Lagerort (in Artikeleinheiten), zusaetzlich zum globalen.
|
||||||
|
location_min_stocks: list[LocationMinStockOut] = []
|
||||||
|
|
||||||
|
@field_validator("field_values", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _field_values_from_orm(cls, v):
|
||||||
|
"""Beim Lesen aus der DB kommt eine Liste ProductFieldValue – zu Map machen."""
|
||||||
|
if isinstance(v, list):
|
||||||
|
return {pfv.field_definition_id: pfv.value for pfv in v}
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class LookupResult(BaseModel):
|
class LookupResult(BaseModel):
|
||||||
@@ -283,7 +469,7 @@ class CheckInRequest(BaseModel):
|
|||||||
best_before: date | None = None
|
best_before: date | None = None
|
||||||
# "month" legt das MHD auf den Monatsletzten (siehe services/dates.py).
|
# "month" legt das MHD auf den Monatsletzten (siehe services/dates.py).
|
||||||
best_before_precision: DatePrecision = DatePrecision.day
|
best_before_precision: DatePrecision = DatePrecision.day
|
||||||
location_id: int | None = None
|
location_id: str | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -304,7 +490,7 @@ class LotOut(BaseModel):
|
|||||||
quantity: float
|
quantity: float
|
||||||
best_before: date | None
|
best_before: date | None
|
||||||
best_before_precision: DatePrecision = DatePrecision.day
|
best_before_precision: DatePrecision = DatePrecision.day
|
||||||
location_id: int | None
|
location_id: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -313,7 +499,62 @@ class LotUpdate(BaseModel):
|
|||||||
quantity: float | None = Field(default=None, gt=0)
|
quantity: float | None = Field(default=None, gt=0)
|
||||||
best_before: date | None = None
|
best_before: date | None = None
|
||||||
best_before_precision: DatePrecision | None = None
|
best_before_precision: DatePrecision | None = None
|
||||||
location_id: int | None = None
|
location_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LotRow(BaseModel):
|
||||||
|
"""Eine Charge mit Artikel-Infos – für die übergreifende Chargenliste."""
|
||||||
|
id: int
|
||||||
|
product_id: int
|
||||||
|
product_name: str
|
||||||
|
product_brand: str | None = None
|
||||||
|
tracking: CategoryTracking = CategoryTracking.food # food / object
|
||||||
|
quantity: float # in Basiseinheiten
|
||||||
|
base_unit: BaseUnit
|
||||||
|
package_size: float | None = None
|
||||||
|
package_label: str | None = None
|
||||||
|
unit_name: str = ""
|
||||||
|
unit_factor: float = 1.0
|
||||||
|
best_before: date | None = None
|
||||||
|
best_before_precision: DatePrecision = DatePrecision.day
|
||||||
|
location_id: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BulkLotLocation(BaseModel):
|
||||||
|
"""Lagerort für mehrere Chargen auf einmal setzen (oder leeren)."""
|
||||||
|
lot_ids: list[int]
|
||||||
|
location_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LotSplit(BaseModel):
|
||||||
|
"""Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und
|
||||||
|
als neue Charge mit gleichem MHD an `location_id` gelegt; der Rest bleibt."""
|
||||||
|
quantity: float = Field(gt=0)
|
||||||
|
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):
|
||||||
@@ -326,7 +567,7 @@ class CheckInLine(BaseModel):
|
|||||||
quantity: float = Field(gt=0)
|
quantity: float = Field(gt=0)
|
||||||
best_before: date | None = None
|
best_before: date | None = None
|
||||||
best_before_precision: DatePrecision = DatePrecision.day
|
best_before_precision: DatePrecision = DatePrecision.day
|
||||||
location_id: int | None = None
|
location_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class BatchCheckInRequest(BaseModel):
|
class BatchCheckInRequest(BaseModel):
|
||||||
@@ -347,12 +588,153 @@ class CheckOutResponse(BaseModel):
|
|||||||
product_stock: float
|
product_stock: float
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Gegenstände: Umlagern und Entfernen mit Grund ----
|
||||||
|
class RelocateRequest(BaseModel):
|
||||||
|
"""Menge eines Gegenstands von einem Lagerort zu einem anderen umbuchen (ohne Grund)."""
|
||||||
|
product_id: int | None = None
|
||||||
|
barcode: str | None = None
|
||||||
|
quantity: float = Field(gt=0)
|
||||||
|
from_location_id: str | None = None
|
||||||
|
to_location_id: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemoveRequest(BaseModel):
|
||||||
|
"""Menge eines Gegenstands aus dem Bestand entfernen – Grund ist Pflicht."""
|
||||||
|
product_id: int | None = None
|
||||||
|
barcode: str | None = None
|
||||||
|
quantity: float = Field(gt=0)
|
||||||
|
location_id: str | None = None
|
||||||
|
reason: RemovalReason
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StockActionResponse(BaseModel):
|
||||||
|
product_stock: float
|
||||||
|
|
||||||
|
|
||||||
|
class RemovalStat(BaseModel):
|
||||||
|
"""Summe der Entnahmen je Grund (für die kleine Statistik am Artikel)."""
|
||||||
|
reason: RemovalReason
|
||||||
|
quantity: float
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class RemovalHistoryItem(BaseModel):
|
||||||
|
reason: RemovalReason
|
||||||
|
quantity: float
|
||||||
|
location_id: str | None = None
|
||||||
|
location_name: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RemovalSummary(BaseModel):
|
||||||
|
stats: list[RemovalStat] = []
|
||||||
|
history: list[RemovalHistoryItem] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Einzelstücke (Items mit UID/QR) ----
|
||||||
|
class ItemCreate(BaseModel):
|
||||||
|
"""Ein oder mehrere Einzelstücke mit gemeinsamen Startwerten anlegen."""
|
||||||
|
count: int = Field(default=1, ge=1, le=200)
|
||||||
|
location_id: str | None = None
|
||||||
|
shop_id: int | None = None
|
||||||
|
acquired_on: date | None = None
|
||||||
|
warranty_until: date | None = None
|
||||||
|
note: str | None = Field(default=None, max_length=255)
|
||||||
|
price_cents: int | None = Field(default=None, ge=0)
|
||||||
|
currency: str | None = Field(default=None, max_length=3)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemUpdate(BaseModel):
|
||||||
|
location_id: str | None = None
|
||||||
|
shop_id: int | None = None
|
||||||
|
acquired_on: date | None = None
|
||||||
|
warranty_until: date | None = None
|
||||||
|
note: str | None = Field(default=None, max_length=255)
|
||||||
|
price_cents: int | None = Field(default=None, ge=0)
|
||||||
|
currency: str | None = Field(default=None, max_length=3)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemRemove(BaseModel):
|
||||||
|
reason: RemovalReason
|
||||||
|
note: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDocumentOut(BaseModel):
|
||||||
|
"""Beleg-Metadaten (ohne Binärdaten)."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
filename: str
|
||||||
|
content_type: str
|
||||||
|
uploaded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentSuggestions(BaseModel):
|
||||||
|
"""Aus einem Beleg-PDF geschätzte Werte (ohne den Beleg zu speichern)."""
|
||||||
|
suggested_warranty_until: date | None = None
|
||||||
|
suggested_price_cents: int | None = None
|
||||||
|
# Alle plausiblen Preise (bester zuerst) – zur Auswahl, falls es mehrere gibt.
|
||||||
|
suggested_price_candidates: list[int] = []
|
||||||
|
suggested_acquired_on: date | None = None
|
||||||
|
# Erkannter Shop: id = bereits hinterlegt; sonst name = Vorschlag zum Anlegen.
|
||||||
|
suggested_shop_id: int | None = None
|
||||||
|
suggested_shop_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDocumentUploadOut(ItemDocumentOut, DocumentSuggestions):
|
||||||
|
"""Antwort nach dem Upload – Beleg-Metadaten plus die geschätzten Werte."""
|
||||||
|
|
||||||
|
|
||||||
|
class ItemOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
uid: str
|
||||||
|
product_id: int
|
||||||
|
location_id: str | None = None
|
||||||
|
location_name: str | None = None
|
||||||
|
shop_id: int | None = None
|
||||||
|
shop_name: str | None = None
|
||||||
|
acquired_on: date | None = None
|
||||||
|
warranty_until: date | None = None
|
||||||
|
note: str | None = None
|
||||||
|
price_cents: int | None = None
|
||||||
|
currency: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
# Für QR-Auflösung/Anzeige mitgeliefert:
|
||||||
|
product_name: str | None = None
|
||||||
|
product_brand: str | None = None
|
||||||
|
category_id: int | None = None
|
||||||
|
category_name: str | None = None
|
||||||
|
documents: list[ItemDocumentOut] = []
|
||||||
|
|
||||||
|
|
||||||
# ---- 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
|
||||||
@@ -382,6 +764,65 @@ 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):
|
||||||
|
"""Ein Produkt-Bedarf an einem Lagerort (Mengen in Artikeleinheiten)."""
|
||||||
|
product_id: int
|
||||||
|
name: str
|
||||||
|
unit_label: str = ""
|
||||||
|
stock: float
|
||||||
|
min_stock: float
|
||||||
|
deficit: float
|
||||||
|
need: ShoppingNeed | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocationNeedGroup(BaseModel):
|
||||||
|
group_id: int
|
||||||
|
name: str
|
||||||
|
unit_name: str = ""
|
||||||
|
stock: float
|
||||||
|
min_stock: float
|
||||||
|
deficit: float
|
||||||
|
# Wie viele Untergruppen mitgezaehlt werden (0 = keine).
|
||||||
|
subgroup_count: int = 0
|
||||||
|
need: ShoppingNeed | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocationNeeds(BaseModel):
|
||||||
|
"""Alle Bedarfe (Produkte + Gruppen) eines Lagerorts."""
|
||||||
|
location_id: str
|
||||||
|
location_name: str
|
||||||
|
products: list[LocationNeedProduct] = []
|
||||||
|
groups: list[LocationNeedGroup] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingListAll(BaseModel):
|
||||||
|
"""Komplette Einkaufsliste in einem Aufruf – dieselben drei Quellen, die auch
|
||||||
|
die Oberfläche zusammenführt."""
|
||||||
|
products: list[ShoppingItem] = []
|
||||||
|
groups: list[GroupShoppingItem] = []
|
||||||
|
by_location: list[LocationNeeds] = []
|
||||||
|
|
||||||
|
|
||||||
|
class LocationContentEntry(BaseModel):
|
||||||
|
"""Ein Artikel mit seinem Bestand an einem Lagerort (in Artikeleinheiten)."""
|
||||||
|
product_id: int
|
||||||
|
name: str
|
||||||
|
brand: str | None = None
|
||||||
|
stock: float
|
||||||
|
unit_label: str = ""
|
||||||
|
individual: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class LocationContents(BaseModel):
|
||||||
|
"""Alle Artikel, die an einem Lagerort liegen – inklusive der Unterorte."""
|
||||||
|
location_id: str
|
||||||
|
location_name: str
|
||||||
|
products: list[LocationContentEntry] = []
|
||||||
|
|
||||||
|
|
||||||
class MovementOut(BaseModel):
|
class MovementOut(BaseModel):
|
||||||
@@ -413,8 +854,27 @@ class DashboardLayoutIn(BaseModel):
|
|||||||
layout: list[dict]
|
layout: list[dict]
|
||||||
|
|
||||||
|
|
||||||
class DashboardLayoutOut(BaseModel):
|
class DashboardCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=80)
|
||||||
|
layout: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardUpdate(BaseModel):
|
||||||
|
"""Umbenennen und/oder umbauen – nur mitgeschickte Felder zaehlen."""
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||||
|
layout: list[dict] | None = None
|
||||||
|
position: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
position: int
|
||||||
layout: list[dict]
|
layout: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardListOut(BaseModel):
|
||||||
|
dashboards: list[DashboardOut]
|
||||||
# Woher die Anordnung stammt: eigene, Admin-Vorgabe oder eingebaut.
|
# Woher die Anordnung stammt: eigene, Admin-Vorgabe oder eingebaut.
|
||||||
source: str
|
source: str
|
||||||
# Ist die Vorgabe verbindlich, darf der Benutzer nicht umbauen.
|
# Ist die Vorgabe verbindlich, darf der Benutzer nicht umbauen.
|
||||||
@@ -422,6 +882,13 @@ class DashboardLayoutOut(BaseModel):
|
|||||||
has_default: bool
|
has_default: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardLayoutOut(BaseModel):
|
||||||
|
layout: list[dict]
|
||||||
|
source: str
|
||||||
|
enforced: bool
|
||||||
|
has_default: bool
|
||||||
|
|
||||||
|
|
||||||
class DashboardStats(BaseModel):
|
class DashboardStats(BaseModel):
|
||||||
products_in_stock: int # Artikel mit Bestand > 0
|
products_in_stock: int # Artikel mit Bestand > 0
|
||||||
article_units: float # Summe der Artikeleinheiten
|
article_units: float # Summe der Artikeleinheiten
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Startup-Seeds: erster Admin-Benutzer und die eingebauten Einheiten."""
|
"""Startup-Seeds: erster Admin-Benutzer und die eingebauten Einheiten."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .models import Category, PackageType, Role, Unit, UnitKind, User
|
from .models import Category, CategoryTracking, FieldDefinition, PackageType, Role, Unit, UnitKind, User
|
||||||
from .security import hash_password
|
from .security import hash_password
|
||||||
|
from .services.fields import slugify
|
||||||
|
|
||||||
# (Name, Art, Faktor zur kanonischen Basiseinheit)
|
# (Name, Art, Faktor zur kanonischen Basiseinheit)
|
||||||
BUILTIN_UNITS: list[tuple[str, UnitKind, float]] = [
|
BUILTIN_UNITS: list[tuple[str, UnitKind, float]] = [
|
||||||
@@ -87,12 +90,98 @@ def ensure_builtin_categories(db: Session) -> None:
|
|||||||
"""
|
"""
|
||||||
if db.query(Category).first() is not None:
|
if db.query(Category).first() is not None:
|
||||||
return
|
return
|
||||||
|
food = CategoryTracking.food.value
|
||||||
for name, children in BUILTIN_CATEGORIES:
|
for name, children in BUILTIN_CATEGORIES:
|
||||||
parent = Category(name=name, is_builtin=True)
|
parent = Category(name=name, is_builtin=True, tracking=food)
|
||||||
db.add(parent)
|
db.add(parent)
|
||||||
db.flush()
|
db.flush()
|
||||||
for child in children:
|
for child in children:
|
||||||
db.add(Category(name=child, parent_id=parent.id, is_builtin=True))
|
db.add(Category(name=child, parent_id=parent.id, is_builtin=True, tracking=food))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# Beispiel-Kategorien für Gegenstände (Non-Food) mit ein paar sinnvollen Feldern.
|
||||||
|
# Frei änderbar; wer sie löscht, bekommt sie nicht zurück (siehe Guard unten).
|
||||||
|
OBJECT_EXAMPLES: list[dict] = [
|
||||||
|
{
|
||||||
|
"name": "Kleidung",
|
||||||
|
"fields": [
|
||||||
|
{"label": "Größe", "type": "select",
|
||||||
|
"options": ["XS", "S", "M", "L", "XL", "XXL"]},
|
||||||
|
{"label": "Farbe", "type": "text"},
|
||||||
|
],
|
||||||
|
"children": [
|
||||||
|
{"name": "Schuhe",
|
||||||
|
"fields": [{"label": "Schuhgröße", "type": "number", "unit": "EU"}]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
# Kaufdatum/Garantie sind je Einzelstück (Item), nicht am Modell – deshalb
|
||||||
|
# hier bewusst keine solchen Felder. Modell-Felder = gemeinsame Merkmale.
|
||||||
|
"name": "Elektronik",
|
||||||
|
"fields": [],
|
||||||
|
"children": [
|
||||||
|
{"name": "Powerbank",
|
||||||
|
"fields": [{"label": "Kapazität", "type": "number", "unit": "mAh"}]},
|
||||||
|
{"name": "Kabel", "fields": [
|
||||||
|
{"label": "Länge", "type": "number", "unit": "cm"},
|
||||||
|
{"label": "Anschluss", "type": "text"},
|
||||||
|
]},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"name": "Werkzeug", "fields": []},
|
||||||
|
{"name": "Bücher & Medien", "fields": []},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _add_fields(db: Session, category_id: int, felder: list[dict]) -> None:
|
||||||
|
for pos, f in enumerate(felder):
|
||||||
|
optionen = f.get("options")
|
||||||
|
db.add(
|
||||||
|
FieldDefinition(
|
||||||
|
category_id=category_id,
|
||||||
|
label=f["label"],
|
||||||
|
key=slugify(f["label"]),
|
||||||
|
field_type=f.get("type", "text"),
|
||||||
|
unit=f.get("unit"),
|
||||||
|
options=json.dumps(optionen) if optionen else None,
|
||||||
|
required=bool(f.get("required")),
|
||||||
|
position=pos,
|
||||||
|
is_builtin=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_example_object_categories(db: Session) -> None:
|
||||||
|
"""Legt Beispiel-Gegenstandskategorien mit Feldern an – nur, wenn es noch keine gibt.
|
||||||
|
|
||||||
|
So sieht man das Gegenstands-Feature gleich in Aktion; wer die Beispiele
|
||||||
|
bewusst entfernt, bekommt sie beim nächsten Start nicht zurück.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
db.query(Category)
|
||||||
|
.filter(Category.tracking == CategoryTracking.object.value)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
return
|
||||||
|
for top in OBJECT_EXAMPLES:
|
||||||
|
parent = Category(
|
||||||
|
name=top["name"], tracking=CategoryTracking.object.value, is_builtin=True
|
||||||
|
)
|
||||||
|
db.add(parent)
|
||||||
|
db.flush()
|
||||||
|
_add_fields(db, parent.id, top.get("fields", []))
|
||||||
|
for child in top.get("children", []):
|
||||||
|
node = Category(
|
||||||
|
name=child["name"],
|
||||||
|
parent_id=parent.id,
|
||||||
|
tracking=CategoryTracking.object.value,
|
||||||
|
is_builtin=True,
|
||||||
|
)
|
||||||
|
db.add(node)
|
||||||
|
db.flush()
|
||||||
|
_add_fields(db, node.id, child.get("fields", []))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
139
backend/app/services/fields.py
Normal file
139
backend/app/services/fields.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""Selbst definierte Felder je Kategorie: Vererbung, Validierung, Werte.
|
||||||
|
|
||||||
|
Felder hängen an einer Kategorie und gelten – über den Kategorie-Baum – auch für
|
||||||
|
deren Unterkategorien. Der Wert je Artikel steht in ProductFieldValue (immer als
|
||||||
|
Text; typgerecht geprüft wird hier).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Category, FieldDefinition, FieldType, ProductFieldValue
|
||||||
|
|
||||||
|
|
||||||
|
class FieldError(ValueError):
|
||||||
|
"""Ungültige Feld-Definition oder ein Wert, der nicht zum Feldtyp passt."""
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(label: str) -> str:
|
||||||
|
"""Maschinenlesbaren Schlüssel aus einem Label ableiten (z.B. „Kapazität“ → „kapazitaet“)."""
|
||||||
|
text = unicodedata.normalize("NFKD", label)
|
||||||
|
text = text.encode("ascii", "ignore").decode("ascii").lower()
|
||||||
|
text = re.sub(r"[^a-z0-9]+", "_", text).strip("_")
|
||||||
|
return text or "feld"
|
||||||
|
|
||||||
|
|
||||||
|
def options_list(fd: FieldDefinition) -> list[str]:
|
||||||
|
"""Auswahlmöglichkeiten eines select-Felds als Liste (leer, wenn keine)."""
|
||||||
|
if not fd.options:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(fd.options)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return []
|
||||||
|
return [str(o) for o in data] if isinstance(data, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _ancestry(db: Session, category_id: int) -> list[Category]:
|
||||||
|
"""Kategorien von der Wurzel bis zur angegebenen (einschließlich)."""
|
||||||
|
chain: list[Category] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
cid: int | None = category_id
|
||||||
|
while cid is not None and cid not in seen:
|
||||||
|
seen.add(cid)
|
||||||
|
cat = db.get(Category, cid)
|
||||||
|
if cat is None:
|
||||||
|
break
|
||||||
|
chain.append(cat)
|
||||||
|
cid = cat.parent_id
|
||||||
|
chain.reverse() # Wurzel zuerst, damit deren Felder oben stehen
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def effective_field_definitions(
|
||||||
|
db: Session, category_id: int | None
|
||||||
|
) -> list[tuple[FieldDefinition, bool]]:
|
||||||
|
"""Alle für eine Kategorie geltenden Felder inkl. der geerbten.
|
||||||
|
|
||||||
|
Rückgabe: Liste aus (Felddefinition, geerbt?) – geerbt=True, wenn das Feld
|
||||||
|
von einer Oberkategorie stammt. Reihenfolge: Oberkategorien zuerst.
|
||||||
|
"""
|
||||||
|
if category_id is None:
|
||||||
|
return []
|
||||||
|
result: list[tuple[FieldDefinition, bool]] = []
|
||||||
|
for cat in _ancestry(db, category_id):
|
||||||
|
defs = (
|
||||||
|
db.query(FieldDefinition)
|
||||||
|
.filter(FieldDefinition.category_id == cat.id)
|
||||||
|
.order_by(FieldDefinition.position, FieldDefinition.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for d in defs:
|
||||||
|
result.append((d, cat.id != category_id))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_value(fd: FieldDefinition, raw) -> str | None:
|
||||||
|
"""Einen Rohwert typgerecht prüfen und als Text zurückgeben (oder None = leer)."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
text = str(raw).strip()
|
||||||
|
if text == "":
|
||||||
|
return None
|
||||||
|
|
||||||
|
if fd.field_type == FieldType.number.value:
|
||||||
|
try:
|
||||||
|
float(text.replace(",", "."))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise FieldError(f"„{fd.label}“ erwartet eine Zahl.") from exc
|
||||||
|
return text
|
||||||
|
if fd.field_type == FieldType.date.value:
|
||||||
|
try:
|
||||||
|
date.fromisoformat(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise FieldError(f"„{fd.label}“ erwartet ein Datum (JJJJ-MM-TT).") from exc
|
||||||
|
return text
|
||||||
|
if fd.field_type == FieldType.boolean.value:
|
||||||
|
return "true" if text.lower() in ("1", "true", "ja", "yes", "on") else "false"
|
||||||
|
if fd.field_type == FieldType.select.value:
|
||||||
|
opts = options_list(fd)
|
||||||
|
if opts and text not in opts:
|
||||||
|
raise FieldError(f"„{text}“ ist keine gültige Auswahl für „{fd.label}“.")
|
||||||
|
return text
|
||||||
|
return text # text / textarea
|
||||||
|
|
||||||
|
|
||||||
|
def apply_field_values(
|
||||||
|
db: Session, product, values: dict[int, str | None] | None
|
||||||
|
) -> None:
|
||||||
|
"""Feldwerte eines Artikels setzen/ändern/löschen (nur die übergebenen Felder)."""
|
||||||
|
if not values:
|
||||||
|
return
|
||||||
|
existing = {
|
||||||
|
pfv.field_definition_id: pfv
|
||||||
|
for pfv in db.query(ProductFieldValue)
|
||||||
|
.filter(ProductFieldValue.product_id == product.id)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
for fid, raw in values.items():
|
||||||
|
fd = db.get(FieldDefinition, fid)
|
||||||
|
if fd is None:
|
||||||
|
raise FieldError(f"Feld {fid} existiert nicht.")
|
||||||
|
val = coerce_value(fd, raw)
|
||||||
|
if fid in existing:
|
||||||
|
if val is None:
|
||||||
|
db.delete(existing[fid])
|
||||||
|
else:
|
||||||
|
existing[fid].value = val
|
||||||
|
elif val is not None:
|
||||||
|
db.add(
|
||||||
|
ProductFieldValue(
|
||||||
|
product_id=product.id, field_definition_id=fid, value=val
|
||||||
|
)
|
||||||
|
)
|
||||||
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
|
||||||
@@ -18,9 +18,11 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..models import Product, ProductImage
|
from ..models import Product, ProductImage
|
||||||
|
|
||||||
# Grosszuegig genug fuer ein Produktfoto, eng genug, dass niemand die Datenbank
|
# Grosszuegig genug fuer ein Produktfoto (auch ein Handy-Foto), eng genug, dass
|
||||||
# mit einer versehentlich verlinkten Datei volllaeuft.
|
# niemand die Datenbank mit einer versehentlich verlinkten Datei volllaeuft.
|
||||||
MAX_BYTES = 2 * 1024 * 1024
|
# Bleibt unter dem nginx-Limit (client_max_body_size, siehe web/nginx.conf),
|
||||||
|
# damit Uebergroesse eine klare Meldung statt eines nackten 413 ergibt.
|
||||||
|
MAX_BYTES = 8 * 1024 * 1024
|
||||||
|
|
||||||
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||||
|
|
||||||
|
|||||||
34
backend/app/services/items.py
Normal file
34
backend/app/services/items.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Einzelstücke (Items): UID-Erzeugung und Anreicherung für die Ausgabe."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Item
|
||||||
|
from ..schemas import ItemOut
|
||||||
|
|
||||||
|
# Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt.
|
||||||
|
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_uid(db: Session, length: int = 8) -> str:
|
||||||
|
"""Kurze, eindeutige UID erzeugen (bei Kollision neu würfeln)."""
|
||||||
|
while True:
|
||||||
|
code = "".join(secrets.choice(_ALPHABET) for _ in range(length))
|
||||||
|
if db.query(Item).filter(Item.uid == code).first() is None:
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def item_to_out(item: Item) -> ItemOut:
|
||||||
|
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen für die Anzeige."""
|
||||||
|
out = ItemOut.model_validate(item)
|
||||||
|
out.location_name = item.location.name if item.location else None
|
||||||
|
out.shop_name = item.shop.name if item.shop else None
|
||||||
|
out.product_name = item.product.name if item.product else None
|
||||||
|
out.product_brand = item.product.brand if item.product else None
|
||||||
|
kategorie = item.product.category if item.product else None
|
||||||
|
out.category_id = kategorie.id if kategorie else None
|
||||||
|
out.category_name = kategorie.name if kategorie else None
|
||||||
|
return out
|
||||||
182
backend/app/services/master_data.py
Normal file
182
backend/app/services/master_data.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""Export/Import der Stammdaten als JSON – mit ID-Erhalt für Sicherung/Umzug.
|
||||||
|
|
||||||
|
Umfasst Kategorien (inkl. eigener Felder), Lagerorte, Einheiten und Gebinde. Die
|
||||||
|
IDs werden bewusst mitgeführt: nur so passen z.B. gedruckte Lagerort-QR-Codes
|
||||||
|
(`/l/<id>`) nach einem Wiederherstellen noch, und Verweise (parent_id,
|
||||||
|
category_id) bleiben gültig.
|
||||||
|
|
||||||
|
Beim Import entscheidet der Modus, was bei bereits vergebener ID passiert:
|
||||||
|
- ``skip`` – vorhandene Einträge unangetastet lassen, nur neue anlegen.
|
||||||
|
- ``overwrite`` – vorhandene per ID überschreiben, fehlende anlegen.
|
||||||
|
|
||||||
|
Einzelne Zeilen, die an einer Eindeutigkeit scheitern (z.B. gleicher Name unter
|
||||||
|
anderer ID), werden per Savepoint übersprungen statt den ganzen Import
|
||||||
|
abzubrechen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models import Category, FieldDefinition, Location, PackageType, Unit, UnitKind
|
||||||
|
from .fields import options_list
|
||||||
|
|
||||||
|
FORMAT_KEY = "vorrania_master_data"
|
||||||
|
FORMAT_VERSION = 1
|
||||||
|
# Nur Tabellen mit fortlaufender Integer-id brauchen die Sequenz-Korrektur.
|
||||||
|
# Lagerorte tragen einen zufälligen Code als id – dort gibt es keine Sequenz.
|
||||||
|
_TABLES = ("units", "package_types", "categories", "field_definitions")
|
||||||
|
|
||||||
|
|
||||||
|
def export_master_data(db: Session) -> dict:
|
||||||
|
return {
|
||||||
|
FORMAT_KEY: FORMAT_VERSION,
|
||||||
|
"categories": [
|
||||||
|
{"id": c.id, "name": c.name, "parent_id": c.parent_id, "tracking": c.tracking}
|
||||||
|
for c in db.query(Category).order_by(Category.id).all()
|
||||||
|
],
|
||||||
|
"field_definitions": [
|
||||||
|
{
|
||||||
|
"id": f.id, "category_id": f.category_id, "label": f.label, "key": f.key,
|
||||||
|
"field_type": f.field_type, "unit": f.unit, "options": options_list(f),
|
||||||
|
"required": f.required, "position": f.position, "is_builtin": f.is_builtin,
|
||||||
|
}
|
||||||
|
for f in db.query(FieldDefinition).order_by(FieldDefinition.id).all()
|
||||||
|
],
|
||||||
|
"locations": [
|
||||||
|
{"id": l.id, "name": l.name, "parent_id": l.parent_id}
|
||||||
|
for l in db.query(Location).order_by(Location.name).all()
|
||||||
|
],
|
||||||
|
"units": [
|
||||||
|
{"id": u.id, "name": u.name, "kind": u.kind.value, "factor": u.factor,
|
||||||
|
"is_builtin": u.is_builtin}
|
||||||
|
for u in db.query(Unit).order_by(Unit.id).all()
|
||||||
|
],
|
||||||
|
"package_types": [
|
||||||
|
{"id": p.id, "singular": p.singular, "plural": p.plural, "is_builtin": p.is_builtin}
|
||||||
|
for p in db.query(PackageType).order_by(PackageType.id).all()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _Report:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.created: dict[str, int] = {}
|
||||||
|
self.updated: dict[str, int] = {}
|
||||||
|
self.skipped: dict[str, int] = {}
|
||||||
|
|
||||||
|
def add(self, bucket: dict[str, int], key: str) -> None:
|
||||||
|
bucket[key] = bucket.get(key, 0) + 1
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {"created": self.created, "updated": self.updated, "skipped": self.skipped}
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_sequence(db: Session, table: str) -> None:
|
||||||
|
"""Postgres: die id-Sequenz auf MAX(id) heben, sonst kollidieren spätere
|
||||||
|
Auto-IDs mit importierten. SQLite braucht das nicht."""
|
||||||
|
if db.get_bind().dialect.name != "postgresql":
|
||||||
|
return
|
||||||
|
db.execute(text(
|
||||||
|
f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), "
|
||||||
|
f"GREATEST((SELECT COALESCE(MAX(id), 1) FROM {table}), 1))"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def import_master_data(db: Session, data: dict, mode: str = "skip") -> dict:
|
||||||
|
if not isinstance(data, dict) or FORMAT_KEY not in data:
|
||||||
|
raise ValueError("Das ist keine Vorrania-Stammdaten-Datei.")
|
||||||
|
overwrite = mode == "overwrite"
|
||||||
|
rep = _Report()
|
||||||
|
|
||||||
|
def upsert(model, row, key, build, *, is_tree=False, conflict=None) -> None:
|
||||||
|
rid = row.get("id")
|
||||||
|
existing = db.get(model, rid) if rid is not None else None
|
||||||
|
if existing is not None and not overwrite:
|
||||||
|
rep.add(rep.skipped, key)
|
||||||
|
return
|
||||||
|
# Vorab-Check statt IntegrityError abzufangen: würde die Zeile eine
|
||||||
|
# Eindeutigkeit unter ANDERER id verletzen (z.B. gleicher Name), lieber
|
||||||
|
# überspringen als den Import abbrechen.
|
||||||
|
if conflict is not None and conflict(row):
|
||||||
|
rep.add(rep.skipped, key)
|
||||||
|
return
|
||||||
|
if existing is not None:
|
||||||
|
for k, v in build(row).items():
|
||||||
|
setattr(existing, k, v)
|
||||||
|
db.flush()
|
||||||
|
rep.add(rep.updated, key)
|
||||||
|
return
|
||||||
|
kwargs = build(row)
|
||||||
|
if rid is not None:
|
||||||
|
kwargs["id"] = rid
|
||||||
|
if is_tree:
|
||||||
|
kwargs["parent_id"] = None # Eltern erst im 2. Durchgang
|
||||||
|
db.add(model(**kwargs))
|
||||||
|
db.flush()
|
||||||
|
rep.add(rep.created, key)
|
||||||
|
|
||||||
|
def set_parents(model, rows) -> None:
|
||||||
|
for row in rows or []:
|
||||||
|
rid = row.get("id")
|
||||||
|
obj = db.get(model, rid) if rid is not None else None
|
||||||
|
if obj is None:
|
||||||
|
continue
|
||||||
|
pid = row.get("parent_id")
|
||||||
|
ziel = db.get(model, pid) if pid is not None else None
|
||||||
|
obj.parent_id = ziel.id if ziel is not None else None
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
def name_clash(model, field, r):
|
||||||
|
val = r.get(field)
|
||||||
|
return db.query(model.id).filter(
|
||||||
|
getattr(model, field) == val, model.id != r.get("id")
|
||||||
|
).first() is not None
|
||||||
|
|
||||||
|
for row in data.get("units") or []:
|
||||||
|
upsert(Unit, row, "units", lambda r: {
|
||||||
|
"name": r["name"], "kind": UnitKind(r["kind"]),
|
||||||
|
"factor": float(r.get("factor", 1.0)),
|
||||||
|
"is_builtin": bool(r.get("is_builtin", False)),
|
||||||
|
}, conflict=lambda r: name_clash(Unit, "name", r))
|
||||||
|
for row in data.get("package_types") or []:
|
||||||
|
upsert(PackageType, row, "package_types", lambda r: {
|
||||||
|
"singular": r["singular"], "plural": r.get("plural") or r["singular"],
|
||||||
|
"is_builtin": bool(r.get("is_builtin", False)),
|
||||||
|
}, conflict=lambda r: name_clash(PackageType, "singular", r))
|
||||||
|
|
||||||
|
cats = data.get("categories") or []
|
||||||
|
for row in cats:
|
||||||
|
upsert(Category, row, "categories", lambda r: {
|
||||||
|
"name": r["name"], "tracking": r.get("tracking") or "food",
|
||||||
|
}, is_tree=True)
|
||||||
|
set_parents(Category, cats)
|
||||||
|
|
||||||
|
for row in data.get("field_definitions") or []:
|
||||||
|
if db.get(Category, row.get("category_id")) is None:
|
||||||
|
rep.add(rep.skipped, "field_definitions")
|
||||||
|
continue
|
||||||
|
upsert(FieldDefinition, row, "field_definitions", lambda r: {
|
||||||
|
"category_id": r["category_id"], "label": r["label"],
|
||||||
|
"key": r.get("key") or r["label"],
|
||||||
|
"field_type": r.get("field_type", "text"),
|
||||||
|
"unit": r.get("unit"),
|
||||||
|
"options": json.dumps(r.get("options") or []) if r.get("field_type") == "select" else None,
|
||||||
|
"required": bool(r.get("required", False)),
|
||||||
|
"position": int(r.get("position", 0)),
|
||||||
|
"is_builtin": bool(r.get("is_builtin", False)),
|
||||||
|
})
|
||||||
|
|
||||||
|
locs = data.get("locations") or []
|
||||||
|
for row in locs:
|
||||||
|
upsert(Location, row, "locations", lambda r: {"name": r["name"]}, is_tree=True)
|
||||||
|
set_parents(Location, locs)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
for table in _TABLES:
|
||||||
|
_fix_sequence(db, table)
|
||||||
|
db.commit()
|
||||||
|
return rep.as_dict()
|
||||||
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,12 +2,22 @@
|
|||||||
|
|
||||||
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
|
from sqlalchemy import asc, func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..models import DatePrecision, Lot, Movement, MovementType, Product, User
|
from ..models import (
|
||||||
|
DatePrecision,
|
||||||
|
Item,
|
||||||
|
Location,
|
||||||
|
Lot,
|
||||||
|
Movement,
|
||||||
|
MovementType,
|
||||||
|
Product,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from .conversion import to_base
|
from .conversion import to_base
|
||||||
from .dates import clean_precision, normalize_best_before
|
from .dates import clean_precision, normalize_best_before
|
||||||
|
|
||||||
@@ -16,21 +26,330 @@ class StockError(ValueError):
|
|||||||
"""Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand)."""
|
"""Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand)."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gegenstände (Non-Food): Menge je Lagerort statt Chargen mit MHD.
|
||||||
|
#
|
||||||
|
# Technisch wird dieselbe Lot-Tabelle genutzt – je (Produkt, Lagerort) genau
|
||||||
|
# eine Zeile mit ``best_before = NULL``. So laufen Bestands-Summe, Bewegungslog
|
||||||
|
# und Export unverändert weiter; nur MHD/FEFO entfällt. Die Lebensmittel-Logik
|
||||||
|
# oben (check_in/check_out) bleibt davon unberührt.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _object_lot(db: Session, product_id: int, location_id: str | None) -> Lot | None:
|
||||||
|
"""Die (einzige) Bestandszeile eines Gegenstands an einem Lagerort."""
|
||||||
|
query = db.query(Lot).filter(
|
||||||
|
Lot.product_id == product_id, Lot.best_before.is_(None)
|
||||||
|
)
|
||||||
|
if location_id is None:
|
||||||
|
query = query.filter(Lot.location_id.is_(None))
|
||||||
|
else:
|
||||||
|
query = query.filter(Lot.location_id == location_id)
|
||||||
|
return query.first()
|
||||||
|
|
||||||
|
|
||||||
|
def object_add(
|
||||||
|
db: Session,
|
||||||
|
product: Product,
|
||||||
|
quantity: float,
|
||||||
|
location_id: str | None,
|
||||||
|
user: User | None,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> Lot:
|
||||||
|
"""Erhöht die Menge eines Gegenstands an einem Lagerort."""
|
||||||
|
lot = _object_lot(db, product.id, location_id)
|
||||||
|
if lot is None:
|
||||||
|
lot = Lot(
|
||||||
|
product_id=product.id,
|
||||||
|
quantity=0.0,
|
||||||
|
best_before=None,
|
||||||
|
best_before_precision=DatePrecision.day.value,
|
||||||
|
location_id=location_id,
|
||||||
|
)
|
||||||
|
db.add(lot)
|
||||||
|
db.flush()
|
||||||
|
lot.quantity += quantity
|
||||||
|
db.add(
|
||||||
|
Movement(
|
||||||
|
product_id=product.id,
|
||||||
|
lot_id=lot.id,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
type=MovementType.in_,
|
||||||
|
quantity=quantity,
|
||||||
|
unit_used=product.base_unit.value,
|
||||||
|
note=note,
|
||||||
|
location_id=location_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return lot
|
||||||
|
|
||||||
|
|
||||||
|
def object_remove(
|
||||||
|
db: Session,
|
||||||
|
product: Product,
|
||||||
|
quantity: float,
|
||||||
|
location_id: str | None,
|
||||||
|
reason: str,
|
||||||
|
user: User | None,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Entfernt eine Menge mit Grund (verloren/kaputt/…) aus dem Bestand."""
|
||||||
|
lot = _object_lot(db, product.id, location_id)
|
||||||
|
have = lot.quantity if lot else 0.0
|
||||||
|
if quantity > have + 1e-9:
|
||||||
|
raise StockError(
|
||||||
|
f"Am Lagerort sind nur {have:g} {product.base_unit.value} vorhanden "
|
||||||
|
f"(benötigt {quantity:g})."
|
||||||
|
)
|
||||||
|
lot.quantity -= quantity
|
||||||
|
db.add(
|
||||||
|
Movement(
|
||||||
|
product_id=product.id,
|
||||||
|
lot_id=lot.id,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
type=MovementType.out,
|
||||||
|
quantity=quantity,
|
||||||
|
unit_used=product.base_unit.value,
|
||||||
|
note=note,
|
||||||
|
location_id=location_id,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if lot.quantity <= 1e-9:
|
||||||
|
db.delete(lot)
|
||||||
|
|
||||||
|
|
||||||
|
def object_relocate(
|
||||||
|
db: Session,
|
||||||
|
product: Product,
|
||||||
|
quantity: float,
|
||||||
|
from_location_id: str | None,
|
||||||
|
to_location_id: str | None,
|
||||||
|
user: User | None,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Bucht eine Menge von einem Lagerort zum anderen um (ohne Grund)."""
|
||||||
|
if from_location_id == to_location_id:
|
||||||
|
raise StockError("Quell- und Ziel-Lagerort sind identisch.")
|
||||||
|
src = _object_lot(db, product.id, from_location_id)
|
||||||
|
have = src.quantity if src else 0.0
|
||||||
|
if quantity > have + 1e-9:
|
||||||
|
raise StockError(
|
||||||
|
f"Am Quell-Lagerort sind nur {have:g} {product.base_unit.value} "
|
||||||
|
f"vorhanden (benötigt {quantity:g})."
|
||||||
|
)
|
||||||
|
beleg = note or "Umlagerung"
|
||||||
|
src.quantity -= quantity
|
||||||
|
# Als neutrale Korrektur (adjust) protokollieren, damit Umlagerungen die
|
||||||
|
# Ein-/Auslager-Statistiken nicht verfälschen.
|
||||||
|
db.add(
|
||||||
|
Movement(
|
||||||
|
product_id=product.id,
|
||||||
|
lot_id=src.id,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
type=MovementType.adjust,
|
||||||
|
quantity=-quantity,
|
||||||
|
unit_used=product.base_unit.value,
|
||||||
|
note=beleg,
|
||||||
|
location_id=from_location_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if src.quantity <= 1e-9:
|
||||||
|
db.delete(src)
|
||||||
|
|
||||||
|
dest = _object_lot(db, product.id, to_location_id)
|
||||||
|
if dest is None:
|
||||||
|
dest = Lot(
|
||||||
|
product_id=product.id,
|
||||||
|
quantity=0.0,
|
||||||
|
best_before=None,
|
||||||
|
best_before_precision=DatePrecision.day.value,
|
||||||
|
location_id=to_location_id,
|
||||||
|
)
|
||||||
|
db.add(dest)
|
||||||
|
db.flush()
|
||||||
|
dest.quantity += quantity
|
||||||
|
db.add(
|
||||||
|
Movement(
|
||||||
|
product_id=product.id,
|
||||||
|
lot_id=dest.id,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
type=MovementType.adjust,
|
||||||
|
quantity=quantity,
|
||||||
|
unit_used=product.base_unit.value,
|
||||||
|
note=beleg,
|
||||||
|
location_id=to_location_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def removal_stats(db: Session, product_id: int) -> dict[str, dict]:
|
||||||
|
"""Entnahmen je Grund summieren: {reason: {quantity, count}}."""
|
||||||
|
rows = (
|
||||||
|
db.query(Movement)
|
||||||
|
.filter(
|
||||||
|
Movement.product_id == product_id,
|
||||||
|
Movement.type == MovementType.out,
|
||||||
|
Movement.reason.isnot(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
stats: dict[str, dict] = {}
|
||||||
|
for m in rows:
|
||||||
|
eintrag = stats.setdefault(m.reason, {"quantity": 0.0, "count": 0})
|
||||||
|
eintrag["quantity"] += m.quantity
|
||||||
|
eintrag["count"] += 1
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
def current_stock(db: Session, product_id: int) -> float:
|
def current_stock(db: Session, product_id: int) -> float:
|
||||||
"""Summe der Lot-Mengen eines Produkts (in Basiseinheiten)."""
|
"""Bestand eines Produkts (in Basiseinheiten).
|
||||||
|
|
||||||
|
Einzelstück-Produkte zählen die Anzahl ihrer Items, alle anderen summieren
|
||||||
|
die Lot-Mengen.
|
||||||
|
"""
|
||||||
|
product = db.get(Product, product_id)
|
||||||
|
if product is not None and product.individual:
|
||||||
|
return float(db.query(Item).filter(Item.product_id == product_id).count())
|
||||||
total = (
|
total = (
|
||||||
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
|
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
|
||||||
)
|
)
|
||||||
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:
|
||||||
|
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||||||
|
|
||||||
|
Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des
|
||||||
|
Ortes summiert.
|
||||||
|
"""
|
||||||
|
if product.individual:
|
||||||
|
return float(
|
||||||
|
db.query(Item)
|
||||||
|
.filter(Item.product_id == product.id, Item.location_id == location_id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
total = (
|
||||||
|
db.query(Lot.quantity)
|
||||||
|
.filter(Lot.product_id == product.id, Lot.location_id == location_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return float(sum(q for (q,) in total))
|
||||||
|
|
||||||
|
|
||||||
|
def descendant_location_ids(db: Session, location_id: str) -> set[str]:
|
||||||
|
"""Alle Unter-Lagerorte (rekursiv) eines Lagerorts."""
|
||||||
|
result: set[str] = set()
|
||||||
|
stack = [location_id]
|
||||||
|
while stack:
|
||||||
|
cur = stack.pop()
|
||||||
|
for (lid,) in db.query(Location.id).filter(Location.parent_id == cur).all():
|
||||||
|
if lid not in result:
|
||||||
|
result.add(lid)
|
||||||
|
stack.append(lid)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def location_subtree_stock_base(db: Session, product: Product, location_id: str) -> float:
|
||||||
|
"""Bestand an einem Lagerort INKL. aller Unter-Lagerorte (Basiseinheiten).
|
||||||
|
|
||||||
|
So gilt ein Mindestbestand auf „Hedingen" als gedeckt, wenn der Vorrat
|
||||||
|
irgendwo darunter liegt (z.B. „Hedingen → Keller").
|
||||||
|
"""
|
||||||
|
ids = {location_id} | descendant_location_ids(db, location_id)
|
||||||
|
return float(sum(location_stock_base(db, product, lid) for lid in ids))
|
||||||
|
|
||||||
|
|
||||||
def check_in(
|
def check_in(
|
||||||
db: Session,
|
db: Session,
|
||||||
product: Product,
|
product: Product,
|
||||||
quantity: float,
|
quantity: float,
|
||||||
unit: str,
|
unit: str,
|
||||||
best_before: date | None,
|
best_before: date | None,
|
||||||
location_id: int | None,
|
location_id: str | None,
|
||||||
user: User | None,
|
user: User | None,
|
||||||
note: str | None = None,
|
note: str | None = None,
|
||||||
best_before_precision: str | None = DatePrecision.day.value,
|
best_before_precision: str | None = DatePrecision.day.value,
|
||||||
@@ -65,7 +384,90 @@ def check_in(
|
|||||||
note=note,
|
note=note,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return lot
|
db.flush()
|
||||||
|
# Liegt am selben Ort schon eine Charge mit gleichem MHD, wird eingelagert =
|
||||||
|
# dazugebucht statt eine zweite Charge anzulegen. Die Bewegung bleibt als
|
||||||
|
# eigener Eintrag im Verlauf erhalten (nur auf die bestehende Charge bezogen).
|
||||||
|
return consolidate_lot_group(
|
||||||
|
db, product.id, lot.best_before, lot.best_before_precision, location_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def consolidate_lot_group(
|
||||||
|
db: Session,
|
||||||
|
product_id: int,
|
||||||
|
best_before: date | None,
|
||||||
|
best_before_precision: str,
|
||||||
|
location_id: str | None,
|
||||||
|
) -> Lot | None:
|
||||||
|
"""Chargen mit gleichem Artikel, MHD, Genauigkeit und Lagerort zu EINER
|
||||||
|
zusammenfassen: Mengen addieren, Bewegungen auf die verbleibende (älteste)
|
||||||
|
Charge umhängen, die übrigen löschen. Gibt die verbleibende Charge zurück.
|
||||||
|
|
||||||
|
Wird nach jedem Umlagern/Aufteilen aufgerufen – zwei gleich gelagerte Chargen
|
||||||
|
mit demselben MHD sind für den Nutzer dieselbe Charge und sollen als eine
|
||||||
|
erscheinen. Der Bestand bleibt unverändert, die Historie (Bewegungen) auch.
|
||||||
|
"""
|
||||||
|
query = db.query(Lot).filter(
|
||||||
|
Lot.product_id == product_id,
|
||||||
|
Lot.best_before_precision == best_before_precision,
|
||||||
|
)
|
||||||
|
query = (
|
||||||
|
query.filter(Lot.best_before.is_(None))
|
||||||
|
if best_before is None
|
||||||
|
else query.filter(Lot.best_before == best_before)
|
||||||
|
)
|
||||||
|
query = (
|
||||||
|
query.filter(Lot.location_id.is_(None))
|
||||||
|
if location_id is None
|
||||||
|
else query.filter(Lot.location_id == location_id)
|
||||||
|
)
|
||||||
|
lots = query.order_by(asc(Lot.created_at), asc(Lot.id)).all()
|
||||||
|
if len(lots) <= 1:
|
||||||
|
return lots[0] if lots else None
|
||||||
|
|
||||||
|
survivor = lots[0]
|
||||||
|
for extra in lots[1:]:
|
||||||
|
survivor.quantity += extra.quantity
|
||||||
|
db.query(Movement).filter(Movement.lot_id == extra.id).update(
|
||||||
|
{Movement.lot_id: survivor.id}, synchronize_session=False
|
||||||
|
)
|
||||||
|
db.delete(extra)
|
||||||
|
db.flush()
|
||||||
|
return survivor
|
||||||
|
|
||||||
|
|
||||||
|
def consolidate_duplicate_lots(db: Session) -> int:
|
||||||
|
"""Alle bereits vorhandenen Dubletten einmalig zusammenfassen (beim Start).
|
||||||
|
Idempotent: Nach dem ersten Lauf gibt es keine Gruppen mit mehr als einer
|
||||||
|
Charge mehr. Gibt die Anzahl entfernter Chargen zurück."""
|
||||||
|
gruppen = (
|
||||||
|
db.query(
|
||||||
|
Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id
|
||||||
|
)
|
||||||
|
.group_by(
|
||||||
|
Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id
|
||||||
|
)
|
||||||
|
.having(func.count(Lot.id) > 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
entfernt = 0
|
||||||
|
for product_id, best_before, precision, location_id in gruppen:
|
||||||
|
vorher = (
|
||||||
|
db.query(func.count(Lot.id))
|
||||||
|
.filter(
|
||||||
|
Lot.product_id == product_id,
|
||||||
|
Lot.best_before_precision == precision,
|
||||||
|
Lot.best_before.is_(None) if best_before is None else Lot.best_before == best_before,
|
||||||
|
Lot.location_id.is_(None) if location_id is None else Lot.location_id == location_id,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
consolidate_lot_group(db, product_id, best_before, precision, location_id)
|
||||||
|
entfernt += max(0, (vorher or 0) - 1)
|
||||||
|
if entfernt:
|
||||||
|
db.commit()
|
||||||
|
return entfernt
|
||||||
|
|
||||||
|
|
||||||
def check_out_lot(
|
def check_out_lot(
|
||||||
|
|||||||
361
backend/app/services/warranty.py
Normal file
361
backend/app/services/warranty.py
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
"""Aus einem Beleg-PDF ein mögliches Garantieende schätzen – lokal, ohne KI.
|
||||||
|
|
||||||
|
Zwei Wege, in dieser Reihenfolge:
|
||||||
|
1. Garantiezeitraum ("24 Monate", "2 Jahre") + Kaufdatum (aus dem Beleg oder vom
|
||||||
|
Einzelstück) → Enddatum ausrechnen.
|
||||||
|
2. Ein Datum in der Nähe eines Garantie-Stichworts.
|
||||||
|
|
||||||
|
Bewusst konservativ: lieber nichts vorschlagen als etwas Falsches – das Ergebnis
|
||||||
|
ist nur ein Vorschlag, den der Nutzer bestätigt. Reine Scan-PDFs ohne Textebene
|
||||||
|
liefern keinen Text und damit keinen Vorschlag (dafür bräuchte es OCR).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
_KEYWORD_RE = re.compile(r"garantie|gew[äa]hrleistung|warranty", re.IGNORECASE)
|
||||||
|
_PERIOD_RE = re.compile(r"(\d{1,3})\s*(jahr|monat|year|month)", re.IGNORECASE)
|
||||||
|
_PURCHASE_KW = re.compile(
|
||||||
|
r"rechnungsdatum|rechnung|kaufdatum|bestelldatum|bestellt|belegdatum|"
|
||||||
|
r"datum|invoice|order date|purchase|receipt",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Monatsnamen (Deutsch + Englisch, mit gängigen Abkürzungen), damit z.B.
|
||||||
|
# Amazon-Rechnungen mit „29 April 2026" erkannt werden – nicht nur 29.04.2026.
|
||||||
|
_MONTHS = {
|
||||||
|
"januar": 1, "jan": 1, "january": 1,
|
||||||
|
"februar": 2, "feb": 2, "february": 2,
|
||||||
|
"märz": 3, "maerz": 3, "mrz": 3, "mär": 3, "mar": 3, "march": 3,
|
||||||
|
"april": 4, "apr": 4,
|
||||||
|
"mai": 5, "may": 5,
|
||||||
|
"juni": 6, "jun": 6, "june": 6,
|
||||||
|
"juli": 7, "jul": 7, "july": 7,
|
||||||
|
"august": 8, "aug": 8,
|
||||||
|
"september": 9, "sept": 9, "sep": 9,
|
||||||
|
"oktober": 10, "okt": 10, "october": 10, "oct": 10,
|
||||||
|
"november": 11, "nov": 11,
|
||||||
|
"dezember": 12, "dez": 12, "december": 12, "dec": 12,
|
||||||
|
}
|
||||||
|
# Längere Namen zuerst, damit „januar" vor „jan" greift.
|
||||||
|
_MONTH_ALT = "|".join(sorted((re.escape(k) for k in _MONTHS), key=len, reverse=True))
|
||||||
|
|
||||||
|
# ISO 2024-03-31, numerisch 31.03.2024 / 31/03/24, oder mit Monatsname
|
||||||
|
# ("29 April 2026" bzw. "April 29, 2026").
|
||||||
|
_DATE_RE = re.compile(
|
||||||
|
r"(?P<iy>\d{4})-(?P<im>\d{1,2})-(?P<idd>\d{1,2})"
|
||||||
|
r"|(?P<d>\d{1,2})[.\/](?P<m>\d{1,2})[.\/](?P<y>\d{2,4})"
|
||||||
|
r"|(?P<nd>\d{1,2})\.?\s+(?P<nmon>" + _MONTH_ALT + r")\.?\s+(?P<ny>\d{4})"
|
||||||
|
r"|(?P<emon>" + _MONTH_ALT + r")\s+(?P<ed>\d{1,2})(?:th|st|nd|rd)?,?\s+(?P<ey>\d{4})",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
_WINDOW = 60 # Zeichen um ein Stichwort, in denen Zahl/Datum als zugehörig gelten
|
||||||
|
|
||||||
|
|
||||||
|
def extract_pdf_text(data: bytes) -> str:
|
||||||
|
"""Text aus einem PDF ziehen. Schlägt es fehl (kaputt, reiner Scan), ""."""
|
||||||
|
try:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
|
||||||
|
reader = PdfReader(io.BytesIO(data))
|
||||||
|
return "\n".join((page.extract_text() or "") for page in reader.pages)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _match_to_date(m: re.Match) -> date | None:
|
||||||
|
g = m.groupdict()
|
||||||
|
try:
|
||||||
|
if g.get("iy"): # ISO yyyy-mm-dd
|
||||||
|
y, mo, d = int(g["iy"]), int(g["im"]), int(g["idd"])
|
||||||
|
elif g.get("d"): # dd.mm.yyyy / dd/mm/yy
|
||||||
|
d, mo, y = int(g["d"]), int(g["m"]), int(g["y"])
|
||||||
|
if y < 100:
|
||||||
|
y += 2000
|
||||||
|
elif g.get("nd"): # 29 April 2026
|
||||||
|
d, mo, y = int(g["nd"]), _MONTHS[g["nmon"].lower()], int(g["ny"])
|
||||||
|
elif g.get("emon"): # April 29, 2026
|
||||||
|
d, mo, y = int(g["ed"]), _MONTHS[g["emon"].lower()], int(g["ey"])
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return date(y, mo, d)
|
||||||
|
except (TypeError, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _all_dates(text: str) -> list[tuple[int, date]]:
|
||||||
|
out: list[tuple[int, date]] = []
|
||||||
|
for m in _DATE_RE.finditer(text):
|
||||||
|
d = _match_to_date(m)
|
||||||
|
if d is not None:
|
||||||
|
out.append((m.start(), d))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _find_period(text: str) -> tuple[int, str] | None:
|
||||||
|
"""(Anzahl, Einheit) in der Nähe eines Garantie-Stichworts – beide Reihenfolgen."""
|
||||||
|
for kw in _KEYWORD_RE.finditer(text):
|
||||||
|
fenster = text[max(0, kw.start() - _WINDOW): kw.end() + _WINDOW]
|
||||||
|
p = _PERIOD_RE.search(fenster)
|
||||||
|
if p:
|
||||||
|
return int(p.group(1)), p.group(2).lower()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Eindeutige Kaufdatum-Stichworte (Vorrang) und Fälligkeits-/Zahlungsziel-Wörter,
|
||||||
|
# die KEIN Kaufdatum markieren (z.B. "Fälligkeitsdatum" enthält "datum").
|
||||||
|
_STRONG_DATE_KW = re.compile(
|
||||||
|
r"rechnungsdatum|bestelldatum|kaufdatum|belegdatum|bestellt am|kaufdatum|"
|
||||||
|
r"invoice date|order date|purchase date|date of purchase",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_DUE_KW = re.compile(
|
||||||
|
r"fällig|faellig|zahlbar|zahlungsziel|valuta|due date|payable", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _closest_date(text: str, dates: list[tuple[int, date]], kw_re, window: int) -> date | None:
|
||||||
|
best: date | None = None
|
||||||
|
best_dist = 10**9
|
||||||
|
for kw in kw_re.finditer(text):
|
||||||
|
for pos, d in dates:
|
||||||
|
dist = abs(pos - kw.start())
|
||||||
|
if dist <= window and dist < best_dist:
|
||||||
|
best, best_dist = d, dist
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _near_due(text: str, pos: int) -> bool:
|
||||||
|
return bool(_DUE_KW.search(text[max(0, pos - 30): pos + 30]))
|
||||||
|
|
||||||
|
|
||||||
|
def _find_purchase_date(text: str) -> date | None:
|
||||||
|
dates = _all_dates(text)
|
||||||
|
if not dates:
|
||||||
|
return None
|
||||||
|
# 1) Eindeutiges Kaufdatum-Stichwort (Rechnungs-/Bestell-/Kaufdatum) gewinnt.
|
||||||
|
stark = _closest_date(text, dates, _STRONG_DATE_KW, window=60)
|
||||||
|
if stark is not None:
|
||||||
|
return stark
|
||||||
|
# 2) Generisches "datum/rechnung", aber NICHT neben einem Fälligkeits-Wort.
|
||||||
|
best: date | None = None
|
||||||
|
best_dist = 10**9
|
||||||
|
for kw in _PURCHASE_KW.finditer(text):
|
||||||
|
for pos, d in dates:
|
||||||
|
dist = abs(pos - kw.start())
|
||||||
|
if dist <= _WINDOW and dist < best_dist and not _near_due(text, pos):
|
||||||
|
best, best_dist = d, dist
|
||||||
|
if best is not None:
|
||||||
|
return best
|
||||||
|
# 3) Kein Stichwort getroffen: frühestes Datum (liegt vor dem Garantieende).
|
||||||
|
return min(d for _, d in dates)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_date_near_keyword(text: str) -> date | None:
|
||||||
|
dates = _all_dates(text)
|
||||||
|
best: date | None = None
|
||||||
|
best_dist = 10**9
|
||||||
|
for kw in _KEYWORD_RE.finditer(text):
|
||||||
|
for pos, d in dates:
|
||||||
|
dist = abs(pos - kw.start())
|
||||||
|
if dist <= _WINDOW and dist < best_dist:
|
||||||
|
best, best_dist = d, dist
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _find_latest_date_near_keyword(text: str) -> date | None:
|
||||||
|
"""Spätestes Datum nahe einem Garantie-Stichwort – das Ende eines ausdrücklich
|
||||||
|
genannten Zeitraums (z.B. „… - 06.10.2027")."""
|
||||||
|
dates = _all_dates(text)
|
||||||
|
latest: date | None = None
|
||||||
|
for kw in _KEYWORD_RE.finditer(text):
|
||||||
|
for pos, d in dates:
|
||||||
|
if abs(pos - kw.start()) <= _WINDOW and (latest is None or d > latest):
|
||||||
|
latest = d
|
||||||
|
return latest
|
||||||
|
|
||||||
|
|
||||||
|
def _add_period(base: date, n: int, unit: str) -> date:
|
||||||
|
if unit.startswith(("jahr", "year")):
|
||||||
|
return base + relativedelta(years=n)
|
||||||
|
return base + relativedelta(months=n)
|
||||||
|
|
||||||
|
|
||||||
|
def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | None:
|
||||||
|
"""Bestes rät fürs Garantieende oder ``None``.
|
||||||
|
|
||||||
|
``acquired_on`` ist das am Einzelstück hinterlegte Kaufdatum – es hat Vorrang
|
||||||
|
vor einem im Beleg gefundenen Datum.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
base = acquired_on or _find_purchase_date(text)
|
||||||
|
# Ein ausdrücklich genanntes Enddatum im Garantie-Umfeld hat Vorrang vor dem
|
||||||
|
# aus Zeitraum + Kaufdatum gerechneten (z.B. Beleg nennt "… - 06.10.2027").
|
||||||
|
explizit = _find_latest_date_near_keyword(text)
|
||||||
|
if explizit is not None and (base is None or explizit > base):
|
||||||
|
return explizit
|
||||||
|
period = _find_period(text)
|
||||||
|
if period and base:
|
||||||
|
return _add_period(base, period[0], period[1])
|
||||||
|
return _find_date_near_keyword(text)
|
||||||
|
|
||||||
|
|
||||||
|
def guess_acquired_on(text: str) -> date | None:
|
||||||
|
"""Kaufdatum aus dem Beleg (Datum nahe Rechnungs-/Kaufdatum-Stichwort)."""
|
||||||
|
return _find_purchase_date(text) if text else None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Shop / Bezugsquelle aus dem Beleg
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
_LEGAL_RE = re.compile(
|
||||||
|
r"\b(GmbH|AG|SA|Sàrl|S\.?à r\.?l\.?|Ltd|Inc|SE|KG|OHG|e\.?K\.?|AS|BV|S\.p\.A\.)\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vendor_candidate(text: str) -> str | None:
|
||||||
|
"""Bester Rate-Name des Händlers – meist im Kopf des Belegs. Best effort."""
|
||||||
|
lines = [z.strip() for z in text.splitlines() if z.strip()]
|
||||||
|
for z in lines[:20]:
|
||||||
|
if _LEGAL_RE.search(z):
|
||||||
|
return z[:60]
|
||||||
|
for z in lines[:8]:
|
||||||
|
buchstaben = sum(c.isalpha() for c in z)
|
||||||
|
if 3 <= len(z) <= 40 and buchstaben >= 3 and not re.search(
|
||||||
|
r"rechnung|invoice|quittung|beleg|kassenbon|datum|receipt|order|bestell",
|
||||||
|
z, re.IGNORECASE,
|
||||||
|
):
|
||||||
|
return z[:60]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def guess_shop(text: str, shops: list[tuple[int, str]]) -> tuple[int | None, str | None]:
|
||||||
|
"""(shop_id, name).
|
||||||
|
|
||||||
|
Kommt der Name eines bereits hinterlegten Shops im Beleg vor, wird dieser
|
||||||
|
vorgeschlagen (längster Treffer gewinnt). Sonst ein Kandidatenname zum
|
||||||
|
Anlegen – oder (None, None), wenn nichts Brauchbares gefunden wird.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return (None, None)
|
||||||
|
low = text.lower()
|
||||||
|
treffer: tuple[int, str] | None = None
|
||||||
|
best_len = 0
|
||||||
|
for sid, name in shops:
|
||||||
|
n = name.strip().lower()
|
||||||
|
if len(n) >= 3 and n in low and len(n) > best_len:
|
||||||
|
treffer = (sid, name)
|
||||||
|
best_len = len(n)
|
||||||
|
if treffer is not None:
|
||||||
|
return treffer
|
||||||
|
return (None, _vendor_candidate(text))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Kaufpreis aus dem Beleg schätzen
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
_TOTAL_KW = re.compile(
|
||||||
|
r"gesamtbetrag|gesamtsumme|rechnungsbetrag|endbetrag|gesamt|summe|total|"
|
||||||
|
r"zu zahlen|amount due|grand total|total due",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_CURRENCY_RE = re.compile(r"chf|eur|sfr|fr\.|rp\.|€|\$", re.IGNORECASE)
|
||||||
|
# Beträge mit genau 2 Nachkommastellen, mit/ohne Tausendertrenner
|
||||||
|
# (1'299.00 / 1.299,00 / 49,90). Die Lookarounds verhindern Treffer mitten in
|
||||||
|
# längeren Zahlen und in Datumsangaben (z.B. "31.12.2027" liefert kein "31.12").
|
||||||
|
_AMOUNT_RE = re.compile(
|
||||||
|
r"(?<![\d.,])(?:\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2})(?![.,]?\d)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _amount_to_cents(raw: str) -> int | None:
|
||||||
|
""""1'299.00" / "1.299,00" / "49,90" -> Rappen/Cent."""
|
||||||
|
s = raw.replace(" ", "").replace("'", "")
|
||||||
|
m = re.search(r"[.,](\d{2})$", s) # letzter Trenner ist der Dezimalpunkt
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
dezimal = m.group(1)
|
||||||
|
ganz = re.sub(r"[.,]", "", s[: m.start()]) # Tausendertrenner entfernen
|
||||||
|
if not ganz.isdigit():
|
||||||
|
return None
|
||||||
|
return int(ganz) * 100 + int(dezimal)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_currency_near(text: str, start: int, end: int, window: int = 6) -> bool:
|
||||||
|
return bool(_CURRENCY_RE.search(text[max(0, start - window): end + window]))
|
||||||
|
|
||||||
|
|
||||||
|
def _near_total(text: str, pos: int) -> bool:
|
||||||
|
"""Steht der Betrag kurz hinter einem Summen-Stichwort?"""
|
||||||
|
for kw in _TOTAL_KW.finditer(text):
|
||||||
|
if kw.start() <= pos <= kw.end() + 40:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _price_hits(text: str) -> list[tuple[int, int, bool]]:
|
||||||
|
"""(Position, Betrag in Cent, Währung in der Nähe?) für alle Beträge.
|
||||||
|
|
||||||
|
Prozentangaben (MwSt-Satz „8.10 %") werden übersprungen – das sind keine
|
||||||
|
Preise.
|
||||||
|
"""
|
||||||
|
hits: list[tuple[int, int, bool]] = []
|
||||||
|
for m in _AMOUNT_RE.finditer(text):
|
||||||
|
if text[m.end(): m.end() + 3].lstrip().startswith("%"):
|
||||||
|
continue
|
||||||
|
c = _amount_to_cents(m.group())
|
||||||
|
if c:
|
||||||
|
hits.append((m.start(), c, _has_currency_near(text, m.start(), m.end())))
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def guess_price_cents(text: str) -> int | None:
|
||||||
|
"""Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``.
|
||||||
|
|
||||||
|
Bewusst zurückhaltend, damit nicht irgendeine Zahl gewinnt:
|
||||||
|
1. Von den Beträgen nahe einem Summen-Stichwort (Gesamtbetrag/Total/…) der
|
||||||
|
GRÖSSTE – der Endbetrag inkl. steht rechts und ist meist der höchste.
|
||||||
|
2. Sonst der größte Betrag MIT Währungszeichen (CHF/EUR/€/…) in der Nähe.
|
||||||
|
3. Ohne Hinweis (weder Summe noch Währung) lieber kein Vorschlag.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
hits = _price_hits(text)
|
||||||
|
if not hits:
|
||||||
|
return None
|
||||||
|
total_nah = [
|
||||||
|
c for pos, c, _cur in hits if _near_total(text, pos)
|
||||||
|
]
|
||||||
|
if total_nah:
|
||||||
|
return max(total_nah)
|
||||||
|
mit_waehrung = [c for _pos, c, cur in hits if cur]
|
||||||
|
return max(mit_waehrung) if mit_waehrung else None
|
||||||
|
|
||||||
|
|
||||||
|
def guess_price_candidates(text: str) -> list[int]:
|
||||||
|
"""Alle geldartigen Beträge (Rappen/Cent) zur Auswahl, bester Tipp zuerst.
|
||||||
|
|
||||||
|
Bei einer Rechnung mit mehreren Artikeln soll man auch den Zeilenpreis des
|
||||||
|
passenden Produkts wählen können – deshalb kommen hier alle Beträge rein
|
||||||
|
(Zeilen- und Summenbeträge). Mengen, Artikelnummern und Prozentsätze fallen
|
||||||
|
schon in :func:`_price_hits` weg. Der automatische Tipp (Gesamtsumme) steht
|
||||||
|
vorne.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
werte = {c for _pos, c, _cur in _price_hits(text)}
|
||||||
|
werte.discard(0)
|
||||||
|
if not werte:
|
||||||
|
return []
|
||||||
|
kandidaten = sorted(werte, reverse=True)
|
||||||
|
best = guess_price_cents(text)
|
||||||
|
if best is not None and best in kandidaten:
|
||||||
|
kandidaten.remove(best)
|
||||||
|
kandidaten.insert(0, best)
|
||||||
|
return kandidaten[:12]
|
||||||
@@ -9,4 +9,5 @@ bcrypt==4.2.1
|
|||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
|
pypdf==5.1.0
|
||||||
pytest==8.3.4
|
pytest==8.3.4
|
||||||
|
|||||||
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
|
||||||
@@ -2,9 +2,10 @@ from datetime import date, timedelta # noqa: F401 (date fuer die Zustands-Test
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.models import BaseUnit, Category, Product, Role, User
|
from app.models import BaseUnit, Category, Item, Product, Role, User
|
||||||
from app.routers import dashboard
|
from app.routers import dashboard
|
||||||
from app.services.conversion import article_unit, article_units
|
from app.services.conversion import article_unit, article_units
|
||||||
|
from app.services.items import generate_uid
|
||||||
from app.services.stock import check_in, check_out
|
from app.services.stock import check_in, check_out
|
||||||
|
|
||||||
|
|
||||||
@@ -89,6 +90,31 @@ def test_by_category_bündelt_und_sortiert(db, user):
|
|||||||
assert anteile[1].article_units == 2
|
assert anteile[1].article_units == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_einzelstuecke_zaehlen_in_auswertungen(db, user):
|
||||||
|
"""Regression: Einzelstueck-Produkte liegen als Items, nicht als Lots. Sie
|
||||||
|
muessen trotzdem in Kategorie-, Ablauf- und Bestandsauswertung erscheinen –
|
||||||
|
sonst zeigte das Dashboard nur die Mengen-Gegenstaende (Bug: nur Powerbank)."""
|
||||||
|
kamera_kat = Category(name="Kamera", tracking="object")
|
||||||
|
db.add(kamera_kat)
|
||||||
|
db.commit()
|
||||||
|
kamera = Product(name="Alpha 7 IV", category_id=kamera_kat.id, individual=True)
|
||||||
|
db.add(kamera)
|
||||||
|
db.commit()
|
||||||
|
for _ in range(2):
|
||||||
|
db.add(Item(uid=generate_uid(db), product_id=kamera.id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
anteil = next(a for a in dashboard.by_category(db=db, _=user) if a.name == "Kamera")
|
||||||
|
assert anteil.article_units == 2
|
||||||
|
assert anteil.no_date == 2 # Einzelstuecke haben kein MHD
|
||||||
|
|
||||||
|
assert dashboard.expiry_split(db=db, _=user).no_date == 2
|
||||||
|
|
||||||
|
st = dashboard.stats(db=db, _=user)
|
||||||
|
assert st.article_units == 2
|
||||||
|
assert st.products_in_stock == 1
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------- Verlauf
|
# ----------------------------------------------------------------- Verlauf
|
||||||
def test_schrittweite_haengt_am_zeitraum():
|
def test_schrittweite_haengt_am_zeitraum():
|
||||||
"""Kurze Zeitraeume werden fein abgetastet, lange grob."""
|
"""Kurze Zeitraeume werden fein abgetastet, lange grob."""
|
||||||
@@ -188,54 +214,120 @@ def test_activity_zaehlt_ein_und_auslagerungen(db, rice, user):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- Anordnung
|
# ---------------------------------------------------------------- Anordnung
|
||||||
|
def _erstes(antwort):
|
||||||
|
return antwort.dashboards[0]
|
||||||
|
|
||||||
|
|
||||||
def test_layout_faellt_auf_die_eingebaute_zurueck(db, user):
|
def test_layout_faellt_auf_die_eingebaute_zurueck(db, user):
|
||||||
antwort = dashboard.get_layout(db=db, user=user)
|
antwort = dashboard.list_dashboards(db=db, user=user)
|
||||||
assert antwort.source == "builtin"
|
assert antwort.source == "builtin"
|
||||||
assert antwort.enforced is False
|
assert antwort.enforced is False
|
||||||
assert antwort.has_default is False
|
assert antwort.has_default is False
|
||||||
assert any(k["i"] == "expiry-all" for k in antwort.layout)
|
assert len(antwort.dashboards) == 1
|
||||||
|
assert any(k["type"] == "expiry-all" for k in _erstes(antwort).layout)
|
||||||
|
|
||||||
|
|
||||||
def test_eigene_anordnung_wird_gespeichert(db, user):
|
def test_eigene_anordnung_wird_gespeichert(db, user):
|
||||||
from app.schemas import DashboardLayoutIn
|
from app.schemas import DashboardCreate, DashboardUpdate
|
||||||
|
|
||||||
eigen = [{"i": "shopping", "x": 0, "y": 0, "w": 6, "h": 3}]
|
neu = dashboard.create_dashboard(
|
||||||
dashboard.put_layout(DashboardLayoutIn(layout=eigen), db=db, user=user)
|
DashboardCreate(name="Vorräte", layout=[]), db=db, user=user
|
||||||
|
)
|
||||||
|
eigen = [{"i": "k1", "type": "shopping", "x": 0, "y": 0, "w": 6, "h": 3, "props": {}}]
|
||||||
|
dashboard.update_dashboard(neu.id, DashboardUpdate(layout=eigen), db=db, user=user)
|
||||||
|
|
||||||
antwort = dashboard.get_layout(db=db, user=user)
|
antwort = dashboard.list_dashboards(db=db, user=user)
|
||||||
assert antwort.source == "user"
|
assert antwort.source == "user"
|
||||||
assert antwort.layout == eigen
|
assert _erstes(antwort).name == "Vorräte"
|
||||||
|
assert _erstes(antwort).layout == eigen
|
||||||
|
|
||||||
|
|
||||||
|
def test_dieselbe_kartenart_mehrfach(db, user):
|
||||||
|
"""Der eigentliche Zweck der Instanz-Kennung: zwei Karten derselben Art,
|
||||||
|
jede auf einen anderen Artikel eingestellt."""
|
||||||
|
from app.schemas import DashboardCreate
|
||||||
|
|
||||||
|
layout = [
|
||||||
|
{"i": "a", "type": "product-timeline", "x": 0, "y": 0, "w": 6, "h": 6,
|
||||||
|
"props": {"product_id": 1}},
|
||||||
|
{"i": "b", "type": "product-timeline", "x": 6, "y": 0, "w": 6, "h": 6,
|
||||||
|
"props": {"product_id": 2}},
|
||||||
|
]
|
||||||
|
neu = dashboard.create_dashboard(
|
||||||
|
DashboardCreate(name="Beobachtung", layout=layout), db=db, user=user
|
||||||
|
)
|
||||||
|
assert len(neu.layout) == 2
|
||||||
|
assert {k["props"]["product_id"] for k in neu.layout} == {1, 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_alte_anordnung_bekommt_die_art_aus_der_kennung(db):
|
||||||
|
"""Gespeicherte Anordnungen von frueher haben kein "type" - dort war die
|
||||||
|
Kennung die Art. Ohne das Nachziehen waere jede alte Karte unbekannt."""
|
||||||
|
alt = [{"i": "shopping", "x": 0, "y": 0, "w": 6, "h": 3}]
|
||||||
|
normalisiert = dashboard._normalize(alt)
|
||||||
|
assert normalisiert[0]["type"] == "shopping"
|
||||||
|
assert normalisiert[0]["props"] == {}
|
||||||
|
|
||||||
|
|
||||||
def test_erzwungene_vorgabe_verhindert_eigene_anordnung(db, user):
|
def test_erzwungene_vorgabe_verhindert_eigene_anordnung(db, user):
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.schemas import DashboardLayoutIn
|
from app.schemas import DashboardCreate
|
||||||
|
|
||||||
vorgabe = [{"i": "status", "x": 0, "y": 0, "w": 12, "h": 1}]
|
dashboard.create_dashboard(DashboardCreate(name="Vorgabe", layout=[]), db=db, user=user)
|
||||||
dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user)
|
dashboard.put_default_dashboards(db=db, admin=user)
|
||||||
dashboard.set_enforced(True, db=db, admin=user)
|
dashboard.set_enforced(True, db=db, admin=user)
|
||||||
|
|
||||||
antwort = dashboard.get_layout(db=db, user=user)
|
antwort = dashboard.list_dashboards(db=db, user=user)
|
||||||
assert antwort.source == "default"
|
assert antwort.source == "default"
|
||||||
assert antwort.enforced is True
|
assert antwort.enforced is True
|
||||||
assert antwort.layout == vorgabe
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as fehler:
|
with pytest.raises(HTTPException) as fehler:
|
||||||
dashboard.put_layout(DashboardLayoutIn(layout=[]), db=db, user=user)
|
dashboard.create_dashboard(DashboardCreate(name="X", layout=[]), db=db, user=user)
|
||||||
assert fehler.value.status_code == 403
|
assert fehler.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_zuruecksetzen_stellt_die_vorgabe_wieder_her(db, user):
|
def test_zuruecksetzen_stellt_die_vorgabe_wieder_her(db, user):
|
||||||
from app.schemas import DashboardLayoutIn
|
from app.schemas import DashboardCreate
|
||||||
|
|
||||||
vorgabe = [{"i": "status", "x": 0, "y": 0, "w": 12, "h": 1}]
|
dashboard.create_dashboard(DashboardCreate(name="Vorgabe", layout=[]), db=db, user=user)
|
||||||
dashboard.put_default_layout(DashboardLayoutIn(layout=vorgabe), db=db, admin=user)
|
dashboard.put_default_dashboards(db=db, admin=user)
|
||||||
dashboard.put_layout(DashboardLayoutIn(layout=[{"i": "shopping", "x": 0, "y": 0, "w": 4, "h": 3}]),
|
dashboard.create_dashboard(DashboardCreate(name="Eigenes", layout=[]), db=db, user=user)
|
||||||
db=db, user=user)
|
assert dashboard.list_dashboards(db=db, user=user).source == "user"
|
||||||
assert dashboard.get_layout(db=db, user=user).source == "user"
|
|
||||||
|
|
||||||
dashboard.reset_layout(db=db, user=user)
|
dashboard.reset_dashboards(db=db, user=user)
|
||||||
antwort = dashboard.get_layout(db=db, user=user)
|
antwort = dashboard.list_dashboards(db=db, user=user)
|
||||||
assert antwort.source == "default"
|
assert antwort.source == "default"
|
||||||
assert antwort.layout == vorgabe
|
assert _erstes(antwort).name == "Vorgabe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_erstes_eigenes_uebernimmt_die_vorgabe(db, user):
|
||||||
|
"""Sonst verschwaende das Anlegen eines zweiten Dashboards die Vorgabe:
|
||||||
|
Sobald eigene Zeilen existieren, zaehlen nur noch die."""
|
||||||
|
from app.schemas import DashboardCreate
|
||||||
|
|
||||||
|
dashboard.create_dashboard(DashboardCreate(name="Vorgabe", layout=[]), db=db, user=user)
|
||||||
|
dashboard.put_default_dashboards(db=db, admin=user)
|
||||||
|
dashboard.reset_dashboards(db=db, user=user)
|
||||||
|
assert dashboard.list_dashboards(db=db, user=user).source == "default"
|
||||||
|
|
||||||
|
dashboard.create_dashboard(DashboardCreate(name="Getränke", layout=[]), db=db, user=user)
|
||||||
|
antwort = dashboard.list_dashboards(db=db, user=user)
|
||||||
|
assert [d.name for d in antwort.dashboards] == ["Vorgabe", "Getränke"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremdes_dashboard_ist_unsichtbar(db, user):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.models import Role, User
|
||||||
|
from app.schemas import DashboardCreate, DashboardUpdate
|
||||||
|
|
||||||
|
fremd = User(username="andere", password_hash="x", role=Role.user)
|
||||||
|
db.add(fremd)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(fremd)
|
||||||
|
|
||||||
|
meins = dashboard.create_dashboard(DashboardCreate(name="Meins", layout=[]), db=db, user=user)
|
||||||
|
with pytest.raises(HTTPException) as fehler:
|
||||||
|
dashboard.update_dashboard(meins.id, DashboardUpdate(name="Geklaut"), db=db, user=fremd)
|
||||||
|
assert fehler.value.status_code == 404
|
||||||
|
|||||||
393
backend/tests/test_gegenstaende.py
Normal file
393
backend/tests/test_gegenstaende.py
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
"""Gegenstände (Non-Food): Menge je Lagerort, Umlagern, Entfernen mit Grund,
|
||||||
|
Kategorie-Modus und selbst definierte Felder.
|
||||||
|
|
||||||
|
Die Lebensmittel-Logik (Chargen/MHD/FEFO) bleibt davon unberührt – dafür sorgt
|
||||||
|
test_fefo.py weiterhin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.crud import product_to_out, product_tracking, products_to_out_bulk
|
||||||
|
from app.models import (
|
||||||
|
BaseUnit,
|
||||||
|
Category,
|
||||||
|
CategoryTracking,
|
||||||
|
FieldDefinition,
|
||||||
|
Item,
|
||||||
|
Location,
|
||||||
|
Lot,
|
||||||
|
Movement,
|
||||||
|
Product,
|
||||||
|
ProductFieldValue,
|
||||||
|
RemovalReason,
|
||||||
|
)
|
||||||
|
from app.services.items import generate_uid
|
||||||
|
from app.routers.categories import create_category
|
||||||
|
from app.schemas import CategoryCreate
|
||||||
|
from app.services.fields import (
|
||||||
|
FieldError,
|
||||||
|
apply_field_values,
|
||||||
|
coerce_value,
|
||||||
|
effective_field_definitions,
|
||||||
|
)
|
||||||
|
from app.services.stock import (
|
||||||
|
StockError,
|
||||||
|
current_stock,
|
||||||
|
object_add,
|
||||||
|
object_relocate,
|
||||||
|
object_remove,
|
||||||
|
removal_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _object_product(db, name="Unterhose"):
|
||||||
|
cat = Category(name="Kleidung", tracking=CategoryTracking.object.value)
|
||||||
|
db.add(cat)
|
||||||
|
db.flush()
|
||||||
|
product = Product(name=name, category_id=cat.id)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product, cat
|
||||||
|
|
||||||
|
|
||||||
|
def _loc(db, name):
|
||||||
|
loc = Location(name=name)
|
||||||
|
db.add(loc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(loc)
|
||||||
|
return loc
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Modus je Kategorie ----
|
||||||
|
|
||||||
|
def test_ohne_kategorie_bleibt_food(db):
|
||||||
|
product = Product(name="Altbestand")
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
assert product_tracking(db, product) == "food"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gegenstandskategorie_macht_object(db):
|
||||||
|
product, _ = _object_product(db)
|
||||||
|
assert product_tracking(db, product) == "object"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verbrauchsgegenstand_ohne_kategorie_ist_object(db):
|
||||||
|
# Nach dem Umstellen Lebensmittel -> Verbrauchsgegenstand hat der Artikel oft
|
||||||
|
# keine (Gegenstands-)Kategorie mehr. Das Flag muss ihn trotzdem als
|
||||||
|
# Gegenstand führen, sonst rutscht er zurück in die Lebensmittelliste.
|
||||||
|
product = Product(name="Sonnencreme", bulk=True)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
assert product_tracking(db, product) == "object"
|
||||||
|
|
||||||
|
|
||||||
|
def test_einzelstueck_ohne_kategorie_ist_object(db):
|
||||||
|
product = Product(name="Powerbank", individual=True)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
assert product_tracking(db, product) == "object"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Gebündelte Listen-Ausgabe ----
|
||||||
|
|
||||||
|
def test_bulk_liste_zaehlt_bestand_wie_einzeln(db):
|
||||||
|
# products_to_out_bulk muss denselben Bestand liefern wie product_to_out –
|
||||||
|
# Lot-Summe für Mengen, Item-Anzahl für Einzelstücke.
|
||||||
|
reis = Product(name="Reis", base_unit=BaseUnit.gram, package_size=500)
|
||||||
|
db.add(reis)
|
||||||
|
db.flush()
|
||||||
|
a = _loc(db, "Regal")
|
||||||
|
object_add(db, reis, 1000, a.id, None) # 1000 g als Charge
|
||||||
|
powerbank = _individual_product(db, name="Powerbank")
|
||||||
|
for _ in range(2):
|
||||||
|
db.add(Item(uid=generate_uid(db), product_id=powerbank.id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
einzeln = {product_to_out(db, p).id: product_to_out(db, p).stock for p in [reis, powerbank]}
|
||||||
|
gebuendelt = {o.id: o.stock for o in products_to_out_bulk(db, [reis, powerbank])}
|
||||||
|
assert gebuendelt == einzeln
|
||||||
|
assert gebuendelt[reis.id] == 1000
|
||||||
|
assert gebuendelt[powerbank.id] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_chargenliste_nur_charge_artikel(db):
|
||||||
|
# Die Chargenliste zeigt nur Lebensmittel + Verbrauchsgegenstände – nicht die
|
||||||
|
# Lots von „Menge je Lagerort".
|
||||||
|
from app.routers.stock import list_all_lots
|
||||||
|
a = _loc(db, "Regal")
|
||||||
|
food = Product(name="Reis", base_unit=BaseUnit.gram)
|
||||||
|
db.add(food)
|
||||||
|
db.flush()
|
||||||
|
object_add(db, food, 500, a.id, None)
|
||||||
|
unterhose, _ = _object_product(db, name="Unterhose") # Menge je Lagerort
|
||||||
|
object_add(db, unterhose, 5, a.id, None)
|
||||||
|
creme = _bulk_product(db, name="Sonnencreme") # Verbrauchsgegenstand
|
||||||
|
object_add(db, creme, 200, a.id, None)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
namen = {r.product_name for r in list_all_lots(db=db, _=None)}
|
||||||
|
assert "Reis" in namen
|
||||||
|
assert "Sonnencreme" in namen
|
||||||
|
assert "Unterhose" not in namen
|
||||||
|
|
||||||
|
|
||||||
|
def test_neue_oberkategorie_ist_gegenstand(db):
|
||||||
|
out = create_category(CategoryCreate(name="Werkzeug"), db, None)
|
||||||
|
assert out.tracking == CategoryTracking.object
|
||||||
|
|
||||||
|
|
||||||
|
def test_unterkategorie_erbt_modus_vom_elternteil(db):
|
||||||
|
ober = create_category(
|
||||||
|
CategoryCreate(name="Lebensmittel", tracking=CategoryTracking.food), db, None
|
||||||
|
)
|
||||||
|
unter = create_category(CategoryCreate(name="Käse", parent_id=ober.id), db, None)
|
||||||
|
assert unter.tracking == CategoryTracking.food
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Menge je Lagerort ----
|
||||||
|
|
||||||
|
def test_add_summiert_je_ort(db):
|
||||||
|
product, _ = _object_product(db)
|
||||||
|
a, b = _loc(db, "Schrank A"), _loc(db, "Schrank B")
|
||||||
|
object_add(db, product, 5, a.id, None)
|
||||||
|
object_add(db, product, 3, b.id, None)
|
||||||
|
db.commit()
|
||||||
|
assert current_stock(db, product.id) == 8
|
||||||
|
verteilung = {
|
||||||
|
lot.location_id: lot.quantity
|
||||||
|
for lot in db.query(Lot).filter(Lot.product_id == product.id).all()
|
||||||
|
}
|
||||||
|
assert verteilung == {a.id: 5, b.id: 3}
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_am_gleichen_ort_erhoeht_dieselbe_zeile(db):
|
||||||
|
product, _ = _object_product(db)
|
||||||
|
a = _loc(db, "Schrank A")
|
||||||
|
object_add(db, product, 2, a.id, None)
|
||||||
|
object_add(db, product, 3, a.id, None)
|
||||||
|
db.commit()
|
||||||
|
lots = db.query(Lot).filter(Lot.product_id == product.id).all()
|
||||||
|
assert len(lots) == 1 and lots[0].quantity == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Umlagern (ohne Grund) ----
|
||||||
|
|
||||||
|
def test_umlagern_verschiebt_menge(db):
|
||||||
|
product, _ = _object_product(db)
|
||||||
|
a, b = _loc(db, "A"), _loc(db, "B")
|
||||||
|
object_add(db, product, 5, a.id, None)
|
||||||
|
db.commit()
|
||||||
|
object_relocate(db, product, 2, a.id, b.id, None)
|
||||||
|
db.commit()
|
||||||
|
verteilung = {
|
||||||
|
lot.location_id: lot.quantity
|
||||||
|
for lot in db.query(Lot).filter(Lot.product_id == product.id).all()
|
||||||
|
}
|
||||||
|
assert verteilung == {a.id: 3, b.id: 2}
|
||||||
|
assert current_stock(db, product.id) == 5
|
||||||
|
# Umlagern erzeugt keine Entnahme mit Grund.
|
||||||
|
assert db.query(Movement).filter(Movement.reason.isnot(None)).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_umlagern_zu_wenig_bestand(db):
|
||||||
|
product, _ = _object_product(db)
|
||||||
|
a, b = _loc(db, "A"), _loc(db, "B")
|
||||||
|
object_add(db, product, 1, a.id, None)
|
||||||
|
db.commit()
|
||||||
|
with pytest.raises(StockError):
|
||||||
|
object_relocate(db, product, 5, a.id, b.id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_umlagern_gleicher_ort_fehler(db):
|
||||||
|
product, _ = _object_product(db)
|
||||||
|
a = _loc(db, "A")
|
||||||
|
object_add(db, product, 3, a.id, None)
|
||||||
|
db.commit()
|
||||||
|
with pytest.raises(StockError):
|
||||||
|
object_relocate(db, product, 1, a.id, a.id, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Entfernen mit Grund + Statistik ----
|
||||||
|
|
||||||
|
def test_entfernen_mit_grund_und_statistik(db):
|
||||||
|
product, _ = _object_product(db)
|
||||||
|
a = _loc(db, "A")
|
||||||
|
object_add(db, product, 5, a.id, None)
|
||||||
|
db.commit()
|
||||||
|
object_remove(db, product, 2, a.id, RemovalReason.broken.value, None)
|
||||||
|
object_remove(db, product, 1, a.id, RemovalReason.broken.value, None)
|
||||||
|
object_remove(db, product, 1, a.id, RemovalReason.lost.value, None)
|
||||||
|
db.commit()
|
||||||
|
assert current_stock(db, product.id) == 1
|
||||||
|
stats = removal_stats(db, product.id)
|
||||||
|
assert stats["broken"] == {"quantity": 3, "count": 2}
|
||||||
|
assert stats["lost"] == {"quantity": 1, "count": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_entfernen_zu_viel_bestand(db):
|
||||||
|
product, _ = _object_product(db)
|
||||||
|
a = _loc(db, "A")
|
||||||
|
object_add(db, product, 1, a.id, None)
|
||||||
|
db.commit()
|
||||||
|
with pytest.raises(StockError):
|
||||||
|
object_remove(db, product, 5, a.id, RemovalReason.lost.value, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Selbst definierte Felder ----
|
||||||
|
|
||||||
|
def test_felder_werden_vererbt_oberkategorie_zuerst(db):
|
||||||
|
ober = Category(name="Elektronik", tracking="object")
|
||||||
|
db.add(ober)
|
||||||
|
db.flush()
|
||||||
|
unter = Category(name="Powerbank", parent_id=ober.id, tracking="object")
|
||||||
|
db.add(unter)
|
||||||
|
db.commit()
|
||||||
|
db.add(FieldDefinition(category_id=ober.id, label="Kaufdatum", key="kaufdatum", field_type="date"))
|
||||||
|
db.add(
|
||||||
|
FieldDefinition(
|
||||||
|
category_id=unter.id, label="Kapazität", key="kapazitaet",
|
||||||
|
field_type="number", unit="mAh",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
eff = effective_field_definitions(db, unter.id)
|
||||||
|
assert [fd.label for fd, _ in eff] == ["Kaufdatum", "Kapazität"]
|
||||||
|
geerbt = {fd.label: inh for fd, inh in eff}
|
||||||
|
assert geerbt["Kaufdatum"] is True and geerbt["Kapazität"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_zahlfeld_prueft_typ(db):
|
||||||
|
fd = FieldDefinition(category_id=1, label="Kapazität", key="kap", field_type="number")
|
||||||
|
assert coerce_value(fd, "20000") == "20000"
|
||||||
|
assert coerce_value(fd, "3,5") == "3,5"
|
||||||
|
with pytest.raises(FieldError):
|
||||||
|
coerce_value(fd, "abc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_auswahlfeld_prueft_optionen(db):
|
||||||
|
fd = FieldDefinition(
|
||||||
|
category_id=1, label="Größe", key="groesse", field_type="select",
|
||||||
|
options=json.dumps(["S", "M", "L"]),
|
||||||
|
)
|
||||||
|
assert coerce_value(fd, "M") == "M"
|
||||||
|
with pytest.raises(FieldError):
|
||||||
|
coerce_value(fd, "XXL")
|
||||||
|
|
||||||
|
|
||||||
|
def test_feldwert_setzen_und_leeren(db):
|
||||||
|
cat = Category(name="Elektronik", tracking="object")
|
||||||
|
db.add(cat)
|
||||||
|
db.flush()
|
||||||
|
fd = FieldDefinition(category_id=cat.id, label="Notiz", key="notiz", field_type="text")
|
||||||
|
db.add(fd)
|
||||||
|
db.commit()
|
||||||
|
product = Product(name="Gerät", category_id=cat.id)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
apply_field_values(db, product, {fd.id: "wichtig"})
|
||||||
|
db.commit()
|
||||||
|
assert (
|
||||||
|
db.query(ProductFieldValue).filter_by(product_id=product.id).one().value
|
||||||
|
== "wichtig"
|
||||||
|
)
|
||||||
|
# Leerer Wert löscht den Eintrag wieder.
|
||||||
|
apply_field_values(db, product, {fd.id: ""})
|
||||||
|
db.commit()
|
||||||
|
assert db.query(ProductFieldValue).filter_by(product_id=product.id).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Einzelstücke (Items mit UID/QR) ----
|
||||||
|
|
||||||
|
def _individual_product(db, name="Powerbank"):
|
||||||
|
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
|
||||||
|
db.add(cat)
|
||||||
|
db.flush()
|
||||||
|
product = Product(name=name, category_id=cat.id, individual=True)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_ist_eindeutig(db):
|
||||||
|
codes = {generate_uid(db) for _ in range(50)}
|
||||||
|
assert len(codes) == 50
|
||||||
|
# Keine verwechselbaren Zeichen (0/O/1/I/L) in der UID.
|
||||||
|
assert all(ch not in "01OIL" for code in codes for ch in code)
|
||||||
|
|
||||||
|
|
||||||
|
def test_einzelstueck_bestand_zaehlt_items(db):
|
||||||
|
product = _individual_product(db)
|
||||||
|
for _ in range(3):
|
||||||
|
db.add(Item(uid=generate_uid(db), product_id=product.id))
|
||||||
|
db.commit()
|
||||||
|
assert current_stock(db, product.id) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_mengen_produkt_zaehlt_weiter_lots(db):
|
||||||
|
# Ein Nicht-Einzelstück-Produkt zählt weiterhin über Lots, nicht über Items.
|
||||||
|
product, _ = _object_product(db, name="Unterhose")
|
||||||
|
a = _loc(db, "Schrank A")
|
||||||
|
object_add(db, product, 5, a.id, None)
|
||||||
|
db.commit()
|
||||||
|
assert current_stock(db, product.id) == 5
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Verbrauchsgegenstand (bulk): wie ein Lebensmittel geführt ----
|
||||||
|
|
||||||
|
def _bulk_product(db, name="Sonnencreme", base_unit=BaseUnit.milliliter, min_stock=500):
|
||||||
|
"""Gegenstand, der als Charge (Menge + Einheit) geführt wird – wie ein
|
||||||
|
Lebensmittel, aber in der Gegenstände-Welt und ohne eindeutigen Code."""
|
||||||
|
cat = Category(name="Pflege", tracking=CategoryTracking.object.value)
|
||||||
|
db.add(cat)
|
||||||
|
db.flush()
|
||||||
|
product = Product(
|
||||||
|
name=name, category_id=cat.id, bulk=True,
|
||||||
|
base_unit=base_unit, min_stock=min_stock,
|
||||||
|
)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def test_verbrauchsgegenstand_zaehlt_ueber_lots(db):
|
||||||
|
# bulk-Gegenstand (individual=False) zählt über Chargen wie ein Lebensmittel.
|
||||||
|
product = _bulk_product(db)
|
||||||
|
assert product.bulk is True and product.individual is False
|
||||||
|
a = _loc(db, "Bad")
|
||||||
|
object_add(db, product, 200, a.id, None)
|
||||||
|
object_add(db, product, 200, a.id, None)
|
||||||
|
db.commit()
|
||||||
|
assert current_stock(db, product.id) == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_verbrauchsgegenstand_unter_mindestbestand(db):
|
||||||
|
product = _bulk_product(db, min_stock=500) # 500 ml
|
||||||
|
a = _loc(db, "Bad")
|
||||||
|
object_add(db, product, 200, a.id, None)
|
||||||
|
db.commit()
|
||||||
|
# Unter dem Mindestbestand -> gehört auf die Einkaufsliste (wie ein Lebensmittel).
|
||||||
|
assert current_stock(db, product.id) < product.min_stock
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Zuletzt geändert ----
|
||||||
|
|
||||||
|
def test_updated_at_wird_bei_aenderung_nachgezogen(db):
|
||||||
|
import time
|
||||||
|
product = Product(name="Alt")
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
erst = product.updated_at
|
||||||
|
assert erst is not None
|
||||||
|
time.sleep(0.01)
|
||||||
|
product.name = "Neu"
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
assert product.updated_at > erst
|
||||||
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
|
||||||
86
backend/tests/test_items.py
Normal file
86
backend/tests/test_items.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Einzelstücke: Kaufpreis und Belege (Metadaten in der Ausgabe, Dateinamen-Härtung)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
Category,
|
||||||
|
CategoryTracking,
|
||||||
|
Item,
|
||||||
|
ItemDocument,
|
||||||
|
Location,
|
||||||
|
Product,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.routers.items import _safe_filename, create_items, get_item, list_all_items
|
||||||
|
from app.schemas import ItemCreate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin(db):
|
||||||
|
person = User(username="admin", password_hash="x", role=Role.admin)
|
||||||
|
db.add(person)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
def _product(db):
|
||||||
|
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
|
||||||
|
db.add(cat)
|
||||||
|
db.flush()
|
||||||
|
product = Product(name="Kamera", category_id=cat.id, individual=True)
|
||||||
|
db.add(product)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(product)
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_kaufpreis_round_trip(db, admin):
|
||||||
|
product = _product(db)
|
||||||
|
out = create_items(
|
||||||
|
product.id, ItemCreate(count=1, price_cents=49900, currency="CHF"), db=db, _=admin
|
||||||
|
)
|
||||||
|
assert out[0].price_cents == 49900
|
||||||
|
assert out[0].currency == "CHF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_out_listet_belege(db, admin):
|
||||||
|
product = _product(db)
|
||||||
|
item_id = create_items(product.id, ItemCreate(count=1), db=db, _=admin)[0].id
|
||||||
|
db.add(ItemDocument(item_id=item_id, filename="rechnung.pdf",
|
||||||
|
content_type="application/pdf", data=b"%PDF-1.4 x"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
fresh = get_item(item_id, db=db, _=admin)
|
||||||
|
assert len(fresh.documents) == 1
|
||||||
|
assert fresh.documents[0].filename == "rechnung.pdf"
|
||||||
|
assert fresh.documents[0].content_type == "application/pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_all_items_angereichert(db, admin):
|
||||||
|
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
|
||||||
|
db.add(cat)
|
||||||
|
db.flush()
|
||||||
|
product = Product(name="Kamera", brand="Sony", category_id=cat.id, individual=True)
|
||||||
|
db.add(product)
|
||||||
|
db.flush()
|
||||||
|
ort = Location(name="Keller")
|
||||||
|
db.add(ort)
|
||||||
|
db.commit()
|
||||||
|
create_items(product.id, ItemCreate(count=2, location_id=ort.id), db=db, _=admin)
|
||||||
|
|
||||||
|
alle = list_all_items(db=db, _=admin)
|
||||||
|
assert len(alle) == 2
|
||||||
|
e = alle[0]
|
||||||
|
assert e.product_name == "Kamera"
|
||||||
|
assert e.product_brand == "Sony"
|
||||||
|
assert e.category_name == "Elektronik"
|
||||||
|
assert e.location_id == ort.id
|
||||||
|
assert e.location_name == "Keller"
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_filename_entfernt_header_zeichen():
|
||||||
|
assert _safe_filename('a"b\r\nc.pdf') == "abc.pdf"
|
||||||
|
assert _safe_filename(" ") == "beleg"
|
||||||
|
assert _safe_filename(None) == "beleg"
|
||||||
64
backend/tests/test_location_contents.py
Normal file
64
backend/tests/test_location_contents.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Lagerort-Inhalt: was liegt an einem Ort – inklusive der Unterorte?"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.models import BaseUnit, Item, Location, Lot, Product, Role, User
|
||||||
|
from app.routers.views import location_contents
|
||||||
|
|
||||||
|
|
||||||
|
@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_zeigt_artikel_inkl_unterorten(db, user):
|
||||||
|
haus = Location(name="Hedingen")
|
||||||
|
db.add(haus)
|
||||||
|
db.flush()
|
||||||
|
keller = Location(name="Keller", parent_id=haus.id)
|
||||||
|
db.add(keller)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# package_size=1 -> Artikeleinheit == Basiseinheit, macht die Menge klar.
|
||||||
|
kaffee = Product(name="Kaffee", base_unit=BaseUnit.gram, package_size=1)
|
||||||
|
bohrer = Product(name="Bohrer", base_unit=BaseUnit.piece, package_size=1, individual=True)
|
||||||
|
db.add_all([kaffee, bohrer])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Lot im Unterort (Keller), Einzelstück direkt in Hedingen.
|
||||||
|
db.add(Lot(product_id=kaffee.id, quantity=3, location_id=keller.id))
|
||||||
|
db.add(Item(uid="AAAA111111", product_id=bohrer.id, location_id=haus.id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
inhalt = location_contents(code=haus.id, db=db, _=user)
|
||||||
|
assert inhalt.location_name == "Hedingen"
|
||||||
|
nach_name = {e.name: e for e in inhalt.products}
|
||||||
|
assert set(nach_name) == {"Kaffee", "Bohrer"} # Bestand im Unterort zaehlt mit
|
||||||
|
assert nach_name["Kaffee"].stock == 3
|
||||||
|
assert nach_name["Bohrer"].stock == 1
|
||||||
|
assert nach_name["Bohrer"].individual is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_nur_der_gescannte_teilbaum(db, user):
|
||||||
|
a = Location(name="Ort A")
|
||||||
|
b = Location(name="Ort B")
|
||||||
|
db.add_all([a, b])
|
||||||
|
db.flush()
|
||||||
|
p = Product(name="Reis", base_unit=BaseUnit.gram, package_size=1)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
db.add(Lot(product_id=p.id, quantity=2, location_id=b.id))
|
||||||
|
db.commit()
|
||||||
|
# In A liegt nichts – der Bestand von B darf nicht auftauchen.
|
||||||
|
assert location_contents(code=a.id, db=db, _=user).products == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannter_ort_404(db, user):
|
||||||
|
with pytest.raises(HTTPException) as ex:
|
||||||
|
location_contents(code="ZZZZZZZZZZ", db=db, _=user)
|
||||||
|
assert ex.value.status_code == 404
|
||||||
146
backend/tests/test_location_needs.py
Normal file
146
backend/tests/test_location_needs.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Bedarfe (Mindestbestände) je Lagerort und die Einkaufsliste je Ort."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
BaseUnit,
|
||||||
|
Location,
|
||||||
|
Lot,
|
||||||
|
Product,
|
||||||
|
ProductLocationMinStock,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.routers.views import shopping_list_by_location
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def user(db):
|
||||||
|
person = User(username="tester", password_hash="x", role=Role.admin)
|
||||||
|
db.add(person)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
def test_bedarf_je_lagerort(db, user):
|
||||||
|
# package_size=1 -> Artikeleinheit == Basiseinheit, macht die Rechnung klar.
|
||||||
|
p = Product(name="Kaffee", base_unit=BaseUnit.gram, package_size=1)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
zuhause = Location(name="Zuhause")
|
||||||
|
ferien = Location(name="Ferienhaus")
|
||||||
|
db.add_all([zuhause, ferien])
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# 1 Stück zuhause, nichts im Ferienhaus.
|
||||||
|
db.add(Lot(product_id=p.id, quantity=1, location_id=zuhause.id))
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=zuhause.id, min_stock=3))
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=ferien.id, min_stock=2))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
needs = shopping_list_by_location(db=db, _=user)
|
||||||
|
nach_ort = {n.location_name: n for n in needs}
|
||||||
|
|
||||||
|
assert set(nach_ort) == {"Zuhause", "Ferienhaus"}
|
||||||
|
assert nach_ort["Zuhause"].products[0].deficit == 2 # 3 - 1
|
||||||
|
assert nach_ort["Ferienhaus"].products[0].deficit == 2 # 2 - 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_gedeckter_ort_erscheint_nicht(db, user):
|
||||||
|
p = Product(name="Reis", base_unit=BaseUnit.gram, package_size=1)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
zuhause = Location(name="Zuhause")
|
||||||
|
db.add(zuhause)
|
||||||
|
db.flush()
|
||||||
|
db.add(Lot(product_id=p.id, quantity=5, location_id=zuhause.id))
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=zuhause.id, min_stock=3))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Bestand (5) >= Mindestbestand (3) -> kein Bedarf, kein Eintrag.
|
||||||
|
assert shopping_list_by_location(db=db, _=user) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_bestand_in_unterlagerort_zaehlt_zum_oberort(db, user):
|
||||||
|
p = Product(name="Konserve", base_unit=BaseUnit.gram, package_size=1)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
haus = Location(name="Hedingen")
|
||||||
|
db.add(haus)
|
||||||
|
db.flush()
|
||||||
|
keller = Location(name="Keller", parent_id=haus.id)
|
||||||
|
db.add(keller)
|
||||||
|
db.flush()
|
||||||
|
# Vorrat liegt im Unter-Lagerort; Mindestbestand ist auf dem Ober-Lagerort.
|
||||||
|
db.add(Lot(product_id=p.id, quantity=5, location_id=keller.id))
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=haus.id, min_stock=3))
|
||||||
|
db.commit()
|
||||||
|
# 5 (im Keller) deckt die 3 auf Hedingen -> kein Bedarf.
|
||||||
|
assert shopping_list_by_location(db=db, _=user) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unterlagerort_reicht_nicht_zeigt_restbedarf(db, user):
|
||||||
|
p = Product(name="Konserve", base_unit=BaseUnit.gram, package_size=1)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
haus = Location(name="Hedingen")
|
||||||
|
db.add(haus)
|
||||||
|
db.flush()
|
||||||
|
keller = Location(name="Keller", parent_id=haus.id)
|
||||||
|
db.add(keller)
|
||||||
|
db.flush()
|
||||||
|
db.add(Lot(product_id=p.id, quantity=2, location_id=keller.id))
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=haus.id, min_stock=5))
|
||||||
|
db.commit()
|
||||||
|
needs = shopping_list_by_location(db=db, _=user)
|
||||||
|
assert needs[0].location_name == "Hedingen"
|
||||||
|
assert needs[0].products[0].deficit == 3 # 5 - 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_verschachtelte_orte_werden_verrechnet(db, user):
|
||||||
|
"""Ober- UND Unterort haben einen Mindestbestand. Was in den Unterort gekauft
|
||||||
|
wird, liegt im Subtree des Oberorts und deckt ihn mit – dann taucht der Oberort
|
||||||
|
nicht mehr auf (keine Doppelzählung)."""
|
||||||
|
p = Product(name="Mehl", base_unit=BaseUnit.gram, package_size=1)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
lemgo = Location(name="Lemgo")
|
||||||
|
db.add(lemgo)
|
||||||
|
db.flush()
|
||||||
|
kueche = Location(name="Kueche", parent_id=lemgo.id)
|
||||||
|
db.add(kueche)
|
||||||
|
db.flush()
|
||||||
|
# 3 direkt in Lemgo, 1 in der Kueche -> Lemgo-Subtree = 4, Kueche = 1.
|
||||||
|
db.add(Lot(product_id=p.id, quantity=3, location_id=lemgo.id))
|
||||||
|
db.add(Lot(product_id=p.id, quantity=1, location_id=kueche.id))
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=lemgo.id, min_stock=5))
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=kueche.id, min_stock=2))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)}
|
||||||
|
# 1 in die Kueche gekauft (2-1) hebt Lemgo auf 5 -> Lemgo verschwindet.
|
||||||
|
assert set(nach_ort) == {"Kueche"}
|
||||||
|
assert nach_ort["Kueche"].products[0].deficit == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_verschachtelte_orte_restbedarf_am_oberort(db, user):
|
||||||
|
"""Deckt der Unterort-Kauf den Oberort nicht ganz, bleibt der Restbedarf am
|
||||||
|
Oberort – aber die Unterort-Menge wird nicht doppelt gezaehlt."""
|
||||||
|
p = Product(name="Mehl", base_unit=BaseUnit.gram, package_size=1)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
lemgo = Location(name="Lemgo")
|
||||||
|
db.add(lemgo)
|
||||||
|
db.flush()
|
||||||
|
kueche = Location(name="Kueche", parent_id=lemgo.id)
|
||||||
|
db.add(kueche)
|
||||||
|
db.flush()
|
||||||
|
db.add(Lot(product_id=p.id, quantity=1, location_id=kueche.id)) # nur 1 in der Kueche
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=lemgo.id, min_stock=5))
|
||||||
|
db.add(ProductLocationMinStock(product_id=p.id, location_id=kueche.id, min_stock=2))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)}
|
||||||
|
assert nach_ort["Kueche"].products[0].deficit == 1 # 2 - 1
|
||||||
|
assert nach_ort["Lemgo"].products[0].deficit == 3 # 5 - (1 da + 1 aus Kueche-Kauf), nicht 4
|
||||||
82
backend/tests/test_locations.py
Normal file
82
backend/tests/test_locations.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Lagerorte: Umbenennen und Umhängen (mit Schutz vor Ringen)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.models import Location, Role, User
|
||||||
|
from app.routers.locations import update_location
|
||||||
|
from app.schemas import LocationUpdate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin(db):
|
||||||
|
person = User(username="admin", password_hash="x", role=Role.admin)
|
||||||
|
db.add(person)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
def _orte(db):
|
||||||
|
"""Keller → Regal → Fach."""
|
||||||
|
keller = Location(name="Keller")
|
||||||
|
db.add(keller)
|
||||||
|
db.flush()
|
||||||
|
regal = Location(name="Regal", parent_id=keller.id)
|
||||||
|
db.add(regal)
|
||||||
|
db.flush()
|
||||||
|
fach = Location(name="Fach", parent_id=regal.id)
|
||||||
|
db.add(fach)
|
||||||
|
db.commit()
|
||||||
|
return keller, regal, fach
|
||||||
|
|
||||||
|
|
||||||
|
def test_neuer_ort_bekommt_10_zeichen_code_als_id(db):
|
||||||
|
ort = Location(name="Speisekammer")
|
||||||
|
db.add(ort)
|
||||||
|
db.commit()
|
||||||
|
# Statt fortlaufender Zahl trägt der Lagerort einen zufälligen Code – das
|
||||||
|
# macht Sicherung/Import zwischen zwei Instanzen konfliktfrei.
|
||||||
|
assert isinstance(ort.id, str)
|
||||||
|
assert len(ort.id) == 10
|
||||||
|
assert set(ort.id) <= set("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
|
||||||
|
|
||||||
|
|
||||||
|
def test_umbenennen_ohne_parent_bleibt_hierarchie(db, admin):
|
||||||
|
keller, regal, _ = _orte(db)
|
||||||
|
out = update_location(regal.id, LocationUpdate(name="Regal links"), db=db, _=admin)
|
||||||
|
assert out.name == "Regal links"
|
||||||
|
assert out.parent_id == keller.id # Umhängen war nicht gemeint
|
||||||
|
|
||||||
|
|
||||||
|
def test_umhaengen_setzt_neuen_parent(db, admin):
|
||||||
|
keller, _, fach = _orte(db)
|
||||||
|
out = update_location(fach.id, LocationUpdate(parent_id=keller.id), db=db, _=admin)
|
||||||
|
assert out.parent_id == keller.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_umhaengen_auf_oberste_ebene(db, admin):
|
||||||
|
_, regal, _ = _orte(db)
|
||||||
|
out = update_location(regal.id, LocationUpdate(parent_id=None), db=db, _=admin)
|
||||||
|
assert out.parent_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_umhaengen_auf_sich_selbst_wird_abgelehnt(db, admin):
|
||||||
|
keller, _, _ = _orte(db)
|
||||||
|
with pytest.raises(HTTPException) as ex:
|
||||||
|
update_location(keller.id, LocationUpdate(parent_id=keller.id), db=db, _=admin)
|
||||||
|
assert ex.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_umhaengen_in_eigenen_unterort_wird_abgelehnt(db, admin):
|
||||||
|
keller, _, fach = _orte(db)
|
||||||
|
with pytest.raises(HTTPException) as ex:
|
||||||
|
update_location(keller.id, LocationUpdate(parent_id=fach.id), db=db, _=admin)
|
||||||
|
assert ex.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_umhaengen_auf_unbekannten_ort_ist_404(db, admin):
|
||||||
|
_, _, fach = _orte(db)
|
||||||
|
with pytest.raises(HTTPException) as ex:
|
||||||
|
update_location(fach.id, LocationUpdate(parent_id="ZZZZZZZZZZ"), db=db, _=admin)
|
||||||
|
assert ex.value.status_code == 404
|
||||||
91
backend/tests/test_master_data.py
Normal file
91
backend/tests/test_master_data.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Stammdaten-Export/Import (JSON) mit ID-Erhalt und Konfliktmodi."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import Category, FieldDefinition, Location, PackageType, Unit
|
||||||
|
from app.services.master_data import (
|
||||||
|
FORMAT_KEY,
|
||||||
|
export_master_data,
|
||||||
|
import_master_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_db():
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
return sessionmaker(bind=engine, autoflush=False, autocommit=False)()
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_erhaelt_ids_und_baum(db):
|
||||||
|
ober = Category(name="Elektronik", tracking="object")
|
||||||
|
db.add(ober)
|
||||||
|
db.flush()
|
||||||
|
unter = Category(name="Kabel", parent_id=ober.id, tracking="object")
|
||||||
|
db.add(unter)
|
||||||
|
db.flush()
|
||||||
|
db.add(FieldDefinition(category_id=ober.id, label="Kapazität", key="kapazitaet",
|
||||||
|
field_type="number", unit="mAh"))
|
||||||
|
haus = Location(name="Hedingen")
|
||||||
|
db.add(haus)
|
||||||
|
db.flush()
|
||||||
|
keller = Location(name="Keller", parent_id=haus.id)
|
||||||
|
db.add(keller)
|
||||||
|
beutel = PackageType(singular="Beutel", plural="Beutel")
|
||||||
|
db.add(beutel)
|
||||||
|
db.commit()
|
||||||
|
beutel_id = beutel.id
|
||||||
|
|
||||||
|
data = export_master_data(db)
|
||||||
|
|
||||||
|
ziel = _fresh_db()
|
||||||
|
import_master_data(ziel, data, mode="skip")
|
||||||
|
|
||||||
|
assert ziel.get(Category, ober.id).name == "Elektronik"
|
||||||
|
assert ziel.get(Category, unter.id).parent_id == ober.id # Baum erhalten
|
||||||
|
assert ziel.get(Location, keller.id).parent_id == haus.id
|
||||||
|
assert ziel.query(FieldDefinition).filter_by(category_id=ober.id).first().label == "Kapazität"
|
||||||
|
assert ziel.get(PackageType, beutel_id).singular == "Beutel" # id erhalten
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_laesst_vorhandenes_unangetastet(db):
|
||||||
|
a = Location(name="Alt")
|
||||||
|
db.add(a)
|
||||||
|
db.commit()
|
||||||
|
data = {FORMAT_KEY: 1, "locations": [{"id": a.id, "name": "Neu"}]}
|
||||||
|
rep = import_master_data(db, data, mode="skip")
|
||||||
|
assert db.get(Location, a.id).name == "Alt"
|
||||||
|
assert rep["skipped"].get("locations") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_overwrite_aktualisiert_per_id(db):
|
||||||
|
a = Location(name="Alt")
|
||||||
|
db.add(a)
|
||||||
|
db.commit()
|
||||||
|
data = {FORMAT_KEY: 1, "locations": [{"id": a.id, "name": "Neu"}]}
|
||||||
|
rep = import_master_data(db, data, mode="overwrite")
|
||||||
|
assert db.get(Location, a.id).name == "Neu"
|
||||||
|
assert rep["updated"].get("locations") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_falsche_datei_wirft():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
import_master_data(_fresh_db(), {"foo": 1})
|
||||||
|
|
||||||
|
|
||||||
|
def test_namenskonflikt_wird_uebersprungen_nicht_abgebrochen(db):
|
||||||
|
# Vorhandene Einheit unter id X; Import bringt gleichen Namen unter anderer id.
|
||||||
|
vorhanden = Unit(name="Spezialeinheit", kind="count", factor=1)
|
||||||
|
db.add(vorhanden)
|
||||||
|
db.commit()
|
||||||
|
data = {FORMAT_KEY: 1, "units": [{"id": vorhanden.id + 999, "name": "Spezialeinheit",
|
||||||
|
"kind": "count", "factor": 1}]}
|
||||||
|
rep = import_master_data(db, data, mode="skip")
|
||||||
|
# Gleicher Name unter neuer id -> Eindeutigkeit verletzt -> uebersprungen.
|
||||||
|
assert rep["skipped"].get("units") == 1
|
||||||
|
assert db.query(Unit).filter_by(name="Spezialeinheit").count() == 1
|
||||||
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
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ def _artikel(db, url=None):
|
|||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_version_spiegelt_bildzeitstempel(db):
|
||||||
|
from app.crud import product_to_out
|
||||||
|
|
||||||
|
p = _artikel(db)
|
||||||
|
assert product_to_out(db, p).image_version is None # kein Bild
|
||||||
|
img = ProductImage(product_id=p.id, content_type="image/png", data=EIN_PIXEL)
|
||||||
|
db.add(img)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(img)
|
||||||
|
assert product_to_out(db, p).image_version == int(img.updated_at.timestamp())
|
||||||
|
|
||||||
|
|
||||||
def test_ohne_bildadresse_gibt_es_nichts_zu_holen(db, monkeypatch):
|
def test_ohne_bildadresse_gibt_es_nichts_zu_holen(db, monkeypatch):
|
||||||
monkeypatch.setattr(images, "fetch", lambda url: (_ for _ in ()).throw(AssertionError))
|
monkeypatch.setattr(images, "fetch", lambda url: (_ for _ in ()).throw(AssertionError))
|
||||||
assert images.ensure(db, _artikel(db)) is None
|
assert images.ensure(db, _artikel(db)) 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)
|
||||||
106
backend/tests/test_stammdaten_umbenennen.py
Normal file
106
backend/tests/test_stammdaten_umbenennen.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Umbenennen von Lagerorten und Einheiten.
|
||||||
|
|
||||||
|
Bis dahin gab es nur Anlegen und Loeschen. Wer sich vertippt hatte, musste den
|
||||||
|
Eintrag wegwerfen und neu anlegen - und verlor dabei die Zuordnung der Chargen
|
||||||
|
bzw. Produkte. Diese Tests halten fest, dass Umbenennen die Verweise behaelt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.models import BaseUnit, Lot, Product, Role, Unit, User
|
||||||
|
from app.routers import locations, units
|
||||||
|
from app.schemas import LocationCreate, LocationUpdate, UnitCreate, UnitUpdate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin(db):
|
||||||
|
person = User(username="chef", password_hash="x", role=Role.admin)
|
||||||
|
db.add(person)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Lagerorte ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_lagerort_umbenennen_behaelt_die_chargen(db, admin, rice):
|
||||||
|
keller = locations.create_location(LocationCreate(name="Keler"), db=db, _=admin)
|
||||||
|
charge = Lot(product_id=rice.id, quantity=500, location_id=keller.id)
|
||||||
|
db.add(charge)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
locations.update_location(keller.id, LocationUpdate(name="Keller"), db=db, _=admin)
|
||||||
|
|
||||||
|
db.refresh(charge)
|
||||||
|
assert charge.location_id == keller.id
|
||||||
|
assert db.get(type(keller), keller.id).name == "Keller"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lagerort_doppelter_name_wird_abgelehnt(db, admin):
|
||||||
|
locations.create_location(LocationCreate(name="Keller"), db=db, _=admin)
|
||||||
|
speis = locations.create_location(LocationCreate(name="Speis"), db=db, _=admin)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as fehler:
|
||||||
|
# Gross-/Kleinschreibung darf keinen Unterschied machen.
|
||||||
|
locations.update_location(speis.id, LocationUpdate(name="keller"), db=db, _=admin)
|
||||||
|
assert fehler.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_lagerort_gleicher_name_bleibt_erlaubt(db, admin):
|
||||||
|
"""Nur die Schreibweise aendern darf nicht am eigenen Eintrag scheitern."""
|
||||||
|
keller = locations.create_location(LocationCreate(name="keller"), db=db, _=admin)
|
||||||
|
geaendert = locations.update_location(
|
||||||
|
keller.id, LocationUpdate(name="Keller"), db=db, _=admin
|
||||||
|
)
|
||||||
|
assert geaendert.name == "Keller"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lagerort_unbekannt(db, admin):
|
||||||
|
with pytest.raises(HTTPException) as fehler:
|
||||||
|
locations.update_location(999, LocationUpdate(name="Keller"), db=db, _=admin)
|
||||||
|
assert fehler.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Einheiten ----
|
||||||
|
|
||||||
|
|
||||||
|
def test_einheit_umbenennen_behaelt_die_produkte(db, admin):
|
||||||
|
einheit = units.create_unit(
|
||||||
|
UnitCreate(name="Beutle", kind="weight", factor=1000), db=db, _=admin
|
||||||
|
)
|
||||||
|
produkt = Product(name="Mehl", base_unit=BaseUnit.gram, display_unit_id=einheit.id)
|
||||||
|
db.add(produkt)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
units.update_unit(einheit.id, UnitUpdate(name="Beutel"), db=db, _=admin)
|
||||||
|
|
||||||
|
db.refresh(produkt)
|
||||||
|
assert produkt.display_unit_id == einheit.id
|
||||||
|
assert db.get(Unit, einheit.id).name == "Beutel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_eingebaute_einheit_laesst_sich_umbenennen(db, admin):
|
||||||
|
"""Anders als beim Loeschen ist Umbenennen auch eingebaut erlaubt -
|
||||||
|
genauso wie bei den Gebinden."""
|
||||||
|
gramm = db.query(Unit).filter(Unit.name == "Gramm").one()
|
||||||
|
assert gramm.is_builtin is True
|
||||||
|
|
||||||
|
units.update_unit(gramm.id, UnitUpdate(name="Gramm (g)"), db=db, _=admin)
|
||||||
|
assert db.get(Unit, gramm.id).name == "Gramm (g)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_einheit_doppelter_name_wird_abgelehnt(db, admin):
|
||||||
|
einheit = units.create_unit(
|
||||||
|
UnitCreate(name="Beutel", kind="weight", factor=1000), db=db, _=admin
|
||||||
|
)
|
||||||
|
with pytest.raises(HTTPException) as fehler:
|
||||||
|
units.update_unit(einheit.id, UnitUpdate(name="gramm"), db=db, _=admin)
|
||||||
|
assert fehler.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_einheit_unbekannt(db, admin):
|
||||||
|
with pytest.raises(HTTPException) as fehler:
|
||||||
|
units.update_unit(999, UnitUpdate(name="Beutel"), db=db, _=admin)
|
||||||
|
assert fehler.value.status_code == 404
|
||||||
168
backend/tests/test_warranty.py
Normal file
168
backend/tests/test_warranty.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""Lokale Garantieende-Schätzung aus Belegtext (ohne echtes PDF)."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.services.warranty import (
|
||||||
|
guess_acquired_on,
|
||||||
|
guess_price_candidates,
|
||||||
|
guess_price_cents,
|
||||||
|
guess_shop,
|
||||||
|
guess_warranty_until,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zeitraum_plus_kaufdatum_vom_stueck():
|
||||||
|
# Beleg nennt nur den Zeitraum; das Kaufdatum kommt vom Einzelstück.
|
||||||
|
text = "Vielen Dank für Ihren Einkauf. 24 Monate Garantie auf dieses Gerät."
|
||||||
|
assert guess_warranty_until(text, acquired_on=date(2024, 3, 31)) == date(2026, 3, 31)
|
||||||
|
|
||||||
|
|
||||||
|
def test_jahre_werden_verstanden():
|
||||||
|
text = "2 Jahre Gewährleistung"
|
||||||
|
assert guess_warranty_until(text, acquired_on=date(2023, 1, 15)) == date(2025, 1, 15)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zeitraum_plus_rechnungsdatum_aus_dem_beleg():
|
||||||
|
text = "Rechnungsdatum: 05.06.2024\nGarantie: 12 Monate"
|
||||||
|
assert guess_warranty_until(text) == date(2025, 6, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_warranty_keyword_before_period_english():
|
||||||
|
text = "Warranty period is 36 months from purchase."
|
||||||
|
assert guess_warranty_until(text, acquired_on=date(2022, 2, 1)) == date(2025, 2, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_explizites_datum_neben_stichwort():
|
||||||
|
text = "Garantie gültig bis 31.12.2027."
|
||||||
|
assert guess_warranty_until(text) == date(2027, 12, 31)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_hinweise_kein_vorschlag():
|
||||||
|
assert guess_warranty_until("Nur ein Kassenbon ohne alles.") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_leerer_text_ist_none():
|
||||||
|
assert guess_warranty_until("", acquired_on=date(2024, 1, 1)) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Preis ----
|
||||||
|
|
||||||
|
def test_preis_nahe_total_stichwort():
|
||||||
|
text = "Artikel 49.00\nVersand 5.90\nGesamtbetrag: 54.90 CHF"
|
||||||
|
assert guess_price_cents(text) == 5490
|
||||||
|
|
||||||
|
|
||||||
|
def test_preis_mit_tausendertrenner_apostroph():
|
||||||
|
text = "Total CHF 1'299.00"
|
||||||
|
assert guess_price_cents(text) == 129900
|
||||||
|
|
||||||
|
|
||||||
|
def test_preis_deutsches_format():
|
||||||
|
text = "Rechnungsbetrag 1.299,00 EUR"
|
||||||
|
assert guess_price_cents(text) == 129900
|
||||||
|
|
||||||
|
|
||||||
|
def test_preis_ohne_waehrung_und_ohne_stichwort_ist_none():
|
||||||
|
# Reine Zahlen ohne Waehrung/Summen-Stichwort werden NICHT als Preis geraten.
|
||||||
|
assert guess_price_cents("Position A 12,90\nPosition B 199,00\nDanke.") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_preis_mit_waehrung_ohne_stichwort_nimmt_groessten():
|
||||||
|
text = "Kabel 12.90 CHF\nGeraet 199.00 CHF"
|
||||||
|
assert guess_price_cents(text) == 19900
|
||||||
|
|
||||||
|
|
||||||
|
def test_datum_wird_nicht_als_preis_gelesen():
|
||||||
|
assert guess_price_cents("Garantie gültig bis 31.12.2027.") is None
|
||||||
|
assert guess_price_candidates("Kaufdatum 05.06.2024, Garantie bis 31.12.2027") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_preis_ohne_betrag_ist_none():
|
||||||
|
assert guess_price_cents("Kein Preis hier.") is None
|
||||||
|
assert guess_price_cents("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_preis_kandidaten_bester_zuerst_ohne_dubletten():
|
||||||
|
text = "Artikel 49.00 CHF\nVersand 5.90 CHF\nGesamtbetrag: 54.90 CHF\nnochmal 49.00 CHF"
|
||||||
|
kandidaten = guess_price_candidates(text)
|
||||||
|
assert kandidaten[0] == 5490 # bester Tipp (Summe) vorne
|
||||||
|
assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten
|
||||||
|
assert guess_price_candidates("") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Kaufdatum & Shop ----
|
||||||
|
|
||||||
|
def test_kaufdatum_aus_beleg():
|
||||||
|
assert guess_acquired_on("Rechnungsdatum: 05.06.2024") == date(2024, 6, 5)
|
||||||
|
assert guess_acquired_on("nichts hier") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_kaufdatum_amazon_monatsname():
|
||||||
|
# Amazon nennt das Kaufdatum als "29 April 2026" (Monatsname statt 29.04.).
|
||||||
|
text = "Bestelldatum 29 April 2026\nBestellnummer 028-6796274-2062709"
|
||||||
|
assert guess_acquired_on(text) == date(2026, 4, 29)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kaufdatum_englischer_monatsname():
|
||||||
|
assert guess_acquired_on("Invoice date: April 29, 2026") == date(2026, 4, 29)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shop_erkennt_bekannten_namen():
|
||||||
|
shops = [(1, "Digitec"), (2, "Galaxus")]
|
||||||
|
assert guess_shop("Rechnung von Digitec AG, Zürich", shops) == (1, "Digitec")
|
||||||
|
|
||||||
|
|
||||||
|
def test_shop_schlaegt_neuen_namen_vor():
|
||||||
|
sid, name = guess_shop("ACME Electronics GmbH\nRechnung\nDatum 01.01.2024", [(1, "Galaxus")])
|
||||||
|
assert sid is None
|
||||||
|
assert name == "ACME Electronics GmbH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shop_ohne_text_leer():
|
||||||
|
assert guess_shop("", [(1, "Digitec")]) == (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Realer Galaxus-Beleg (vereinfacht) ----
|
||||||
|
|
||||||
|
GALAXUS = """Kaufbeleg / Rechnung Auftrag 161498087
|
||||||
|
Bestell- / Rechnungsdatum 05.10.2025
|
||||||
|
Fälligkeitsdatum 26.10.2025
|
||||||
|
Währung CHF
|
||||||
|
Delock USB2.0-Ladekabel 10079671 2 8.10% 16.56 17.90 33.12 35.80
|
||||||
|
24 Monate Garantie (06.10.2025 - 06.10.2027)
|
||||||
|
Logitech G Pro X 13489068 1 8.10% 102.68 111.00 102.68 111.00
|
||||||
|
24 Monate Garantie (06.10.2025 - 06.10.2027)
|
||||||
|
Gesamtbetrag 135.80 146.80
|
||||||
|
Gesamtbetrag enthält folgende Mehrwertsteuer:
|
||||||
|
MwSt. 8.10% 11.00
|
||||||
|
Total aller Lieferungen und Leistungen CHF 146.80
|
||||||
|
03.11.2025 Zahlung geleistet -146.80
|
||||||
|
Offener Betrag 0.00
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_galaxus_kaufdatum_nicht_faelligkeit():
|
||||||
|
assert guess_acquired_on(GALAXUS) == date(2025, 10, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_galaxus_preis_ist_gesamt_inkl():
|
||||||
|
assert guess_price_cents(GALAXUS) == 14680
|
||||||
|
|
||||||
|
|
||||||
|
def test_galaxus_mwst_satz_ist_kein_preis():
|
||||||
|
kandidaten = guess_price_candidates(GALAXUS)
|
||||||
|
assert 810 not in kandidaten # 8.10 % ist der Steuersatz, kein Preis
|
||||||
|
assert kandidaten[0] == 14680 # bester Tipp: Gesamt inkl.
|
||||||
|
|
||||||
|
|
||||||
|
def test_galaxus_garantieende_aus_zeitraum():
|
||||||
|
assert guess_warranty_until(GALAXUS, acquired_on=date(2025, 10, 5)) == date(2027, 10, 6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_galaxus_zeilenpreise_sind_kandidaten():
|
||||||
|
# Bei mehreren Artikeln soll man den Zeilenpreis wählen können.
|
||||||
|
kandidaten = guess_price_candidates(GALAXUS)
|
||||||
|
assert 14680 in kandidaten # Gesamt inkl.
|
||||||
|
assert 11100 in kandidaten # Logitech, Betrag/Preis inkl.
|
||||||
|
assert 3580 in kandidaten # Kabel, Betrag inkl.
|
||||||
|
assert 810 not in kandidaten # MwSt-Satz weiterhin draußen
|
||||||
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)
|
||||||
27
brand/README.md
Normal file
27
brand/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Vorrania – Markenzeichen
|
||||||
|
|
||||||
|
Das Zeichen ist die sechseckige „box"-Silhouette (aus `web/src/components/Icon.jsx`,
|
||||||
|
Name `box`) in Weiß auf der Akzentfläche.
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
- **`app-icon.svg`** – 1024×1024, vollflächig, feste Farbe (`#4d5cd4`), ohne
|
||||||
|
abgerundete Ecken. Für App-Icon (iOS/macOS via Icon Composer oder Xcode
|
||||||
|
„AppIcon", App Store). Die Rundung macht das System selbst.
|
||||||
|
- **`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
|
||||||
|
abgerundeter Platte und Hell-/Dunkel-Umschaltung (für den Browser-Tab).
|
||||||
|
|
||||||
|
## Farben
|
||||||
|
|
||||||
|
- Akzent (hell): `#4d5cd4`
|
||||||
|
- Akzent (dunkel, nur Favicon): `#7c8ae8`
|
||||||
|
- Zeichen: `#ffffff`
|
||||||
|
|
||||||
|
## Als PNG exportieren (falls ein Werkzeug PNG verlangt)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# benötigt librsvg (brew install librsvg)
|
||||||
|
rsvg-convert -w 1024 -h 1024 app-icon.svg -o app-icon-1024.png
|
||||||
|
```
|
||||||
11
brand/app-icon.svg
Normal file
11
brand/app-icon.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Vorrania">
|
||||||
|
<!-- App-Icon: dasselbe "box"-Zeichen wie Favicon/Seitenleiste, aber vollflächig
|
||||||
|
(keine abgerundeten Ecken – iOS/macOS maskieren selbst) und mit fester
|
||||||
|
Farbe (kein Dark-Mode-Umschalten). 1024×1024 für App Store / Icon Composer. -->
|
||||||
|
<rect width="1024" height="1024" fill="#4d5cd4" />
|
||||||
|
<g transform="translate(192 192) scale(26.6667)"
|
||||||
|
fill="none" stroke="#ffffff" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 736 B |
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 |
@@ -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,
|
||||||
@@ -38,23 +38,21 @@
|
|||||||
{
|
{
|
||||||
"fill-specializations" : [
|
"fill-specializations" : [
|
||||||
{
|
{
|
||||||
"appearance" : "dark",
|
|
||||||
"value" : {
|
"value" : {
|
||||||
"automatic-gradient" : "display-p3:0.42536,0.31875,0.88861,1.00000"
|
"solid" : "display-p3:0.99316,0.99316,0.99316,1.00000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appearance" : "tinted",
|
"appearance" : "dark",
|
||||||
"value" : {
|
"value" : {
|
||||||
"automatic-gradient" : "extended-gray:0.25000,1.00000"
|
"solid" : "display-p3:0.99316,0.99316,0.99316,1.00000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"glass" : false,
|
"image-name" : "hexagon.svg",
|
||||||
"image-name" : "barcode-gelesen.svg",
|
"name" : "hexagon",
|
||||||
"name" : "barcode-gelesen",
|
|
||||||
"position" : {
|
"position" : {
|
||||||
"scale" : 1.4,
|
"scale" : 1.18,
|
||||||
"translation-in-points" : [
|
"translation-in-points" : [
|
||||||
0,
|
0,
|
||||||
0
|
0
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
# Vorrania – iOS-App
|
# Vorrania – iOS-App
|
||||||
|
|
||||||
Native SwiftUI-App zum Ein- und Auslagern per Barcode-Scan. Sie spricht dieselbe
|
Native SwiftUI-App für Lebensmittel **und** Gegenstände. Sie spricht dieselbe
|
||||||
REST-API wie die Web-Oberfläche.
|
REST-API wie die Web-Oberfläche.
|
||||||
|
|
||||||
> **Stand:** Läuft auf dem Gerät. Login, Scanner, Einlagern mit mehreren
|
> **Stand:** Voll nutzbar. Login; Scannen von Barcode, MHD/Datum (per
|
||||||
> Chargen/MHDs, MHD per Texterkennung, Auslagern mit Chargenauswahl, Artikel
|
> Texterkennung, auch nur Tag+Monat ohne Jahr), Lagerort-QR und Name/Marke;
|
||||||
> anlegen aus Open Food Facts, Einkaufsliste, Ablaufliste sowie Produkte
|
> Ein-/Auslagern mit mehreren Chargen bzw. Menge je Lagerort; Einzelstücke mit
|
||||||
> ansehen und bearbeiten.
|
> UID/QR, Kaufdatum, Garantie und Beleg; Verbrauchsgegenstände als Charge;
|
||||||
|
> Artikel anlegen (Open Food/Products Facts oder manuell, mit Foto); getrennte
|
||||||
|
> Listen für Lebensmittel/Gegenstände/Einzelstücke, Kategoriefilter, Sortierung;
|
||||||
|
> Einkaufsliste, Ablaufliste, Mindestbestände; Verlauf mit Sprung zum Produkt;
|
||||||
|
> lokale Dashboards und Push-Benachrichtigungen bei ablaufenden Produkten.
|
||||||
|
|
||||||
## Projekt in Xcode öffnen
|
## Projekt in Xcode öffnen
|
||||||
|
|
||||||
@@ -62,12 +66,17 @@ Beide Wege öffnen die App **direkt in der Kamera**.
|
|||||||
| `ScannerView.swift` | Kamera + Barcode-Erkennung (EAN-8/13, UPC-E, Code128, QR) |
|
| `ScannerView.swift` | Kamera + Barcode-Erkennung (EAN-8/13, UPC-E, Code128, QR) |
|
||||||
| `RootView.swift` | Startbildschirm und Routing |
|
| `RootView.swift` | Startbildschirm und Routing |
|
||||||
| `LoginView.swift` | Server + Anmeldung |
|
| `LoginView.swift` | Server + Anmeldung |
|
||||||
| `CheckInView.swift` / `CheckInFormView.swift` | Scan → Menge, mehrere Chargen mit MHD |
|
| `CheckInView.swift` / `CheckInFormView.swift` | Scan → Charge (MHD), Menge je Lagerort oder Einzelstück |
|
||||||
| `CheckOutView.swift` | Scan → Menge, Chargenauswahl oder FEFO |
|
| `CheckOutView.swift` | Scan → Menge, Chargenauswahl oder FEFO |
|
||||||
| `ProductViews.swift` | Artikelsuche und Anlegen (mit OFF-Vorbefüllung) |
|
| `ProductViews.swift` | Artikelsuche und Anlegen (mit OFF-Vorbefüllung, Foto, Text-Scan) |
|
||||||
| `ProductDetailView.swift` | Produkt bearbeiten, Chargen korrigieren und löschen |
|
| `ProductDetailView.swift` | Produkt bearbeiten, Verwaltungsart ändern, Chargen korrigieren |
|
||||||
| `ListViews.swift` | Einkaufsliste, „bald ablaufend", Produktliste |
|
| `ObjectStockView.swift` | Gegenstände: Menge je Lagerort (hinzufügen/umlagern/entfernen) |
|
||||||
| `DateScanView.swift` / `BestBeforeText.swift` | MHD per Texterkennung ablesen |
|
| `ItemViews.swift` / `ItemListView.swift` | Einzelstücke anlegen, bearbeiten und auflisten |
|
||||||
|
| `AssignScanView.swift` | Einzelstück (Scan oder Liste) einem Lagerort-QR zuordnen |
|
||||||
|
| `ListViews.swift` | Produktlisten, Einkaufsliste, „bald ablaufend", Mindestbestände |
|
||||||
|
| `DashboardView.swift` / `DashboardCards.swift` / `ChartCards.swift` | Übersicht mit Karten und Diagrammen |
|
||||||
|
| `NotificationScheduler.swift` / `NotificationSettings*.swift` | Push-Benachrichtigungen bei Ablauf |
|
||||||
|
| `DateScanView.swift` / `BestBeforeText.swift` | MHD/Text per Texterkennung ablesen |
|
||||||
| `DisplaySettings.swift` | Datumsformat und Einheiten-Beschriftungen vom Server |
|
| `DisplaySettings.swift` | Datumsformat und Einheiten-Beschriftungen vom Server |
|
||||||
| `CategoryPicker.swift` | Kategorie-Auswahl und Gruppe anlegen |
|
| `CategoryPicker.swift` | Kategorie-Auswahl und Gruppe anlegen |
|
||||||
|
|
||||||
@@ -84,6 +93,6 @@ Bestand, keinen Mindestbestand und keine EAN-Codes. Filterst du auf eine
|
|||||||
Oberkategorie, erscheinen die Artikel ihrer Unterkategorien mit. Beim Anlegen
|
Oberkategorie, erscheinen die Artikel ihrer Unterkategorien mit. Beim Anlegen
|
||||||
schlägt der Server eine Kategorie aus der Open-Food-Facts-Einordnung vor.
|
schlägt der Server eine Kategorie aus der Open-Food-Facts-Einordnung vor.
|
||||||
|
|
||||||
## Noch offen
|
## Weiteres
|
||||||
- Push-Benachrichtigungen bei ablaufenden Produkten
|
Push-Benachrichtigungen bei Ablauf und Lagerort je Charge beim Einlagern sind
|
||||||
- Lagerort je Charge beim Einlagern wählbar
|
umgesetzt. Weitere Ideen stehen in [../docs/ROADMAP.md](../docs/ROADMAP.md).
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ actor APIClient {
|
|||||||
try await send(try makeRequest("/products/\(id)"), as: Product.self)
|
try await send(try makeRequest("/products/\(id)"), as: Product.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Alles, was an einem Lagerort (inkl. Unterorte) liegt – Ziel des /l/-QR beim Nachschlagen.
|
||||||
|
func locationContents(code: String) async throws -> LocationContents {
|
||||||
|
try await send(try makeRequest("/location/\(code)"), as: LocationContents.self)
|
||||||
|
}
|
||||||
|
|
||||||
func lookup(barcode: String) async throws -> LookupResult {
|
func lookup(barcode: String) async throws -> LookupResult {
|
||||||
let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||||
return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self)
|
return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self)
|
||||||
@@ -121,6 +126,132 @@ actor APIClient {
|
|||||||
return try await send(request, as: Product.self)
|
return try await send(request, as: Product.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mindestbestände je Lagerort ersetzen (Menge in Artikeleinheiten).
|
||||||
|
func setProductLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> Product {
|
||||||
|
var request = try makeRequest("/products/\(id)/location-min-stock", method: "PUT")
|
||||||
|
try jsonBody(&request, list)
|
||||||
|
return try await send(request, as: Product.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aktuelles Artikelfoto laden (mit Anmeldung). Gibt nil bei 404 zurück.
|
||||||
|
func productImage(id: Int) async throws -> Data? {
|
||||||
|
let request = try makeRequest("/products/\(id)/image")
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
if let http = response as? HTTPURLResponse, http.statusCode == 404 { return nil }
|
||||||
|
try check(response, data: data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eigenes Foto (Kamera/Galerie) als multipart hochladen.
|
||||||
|
func uploadProductImage(id: Int, data: Data, contentType: String) async throws -> Product {
|
||||||
|
var request = try makeRequest("/products/\(id)/image", method: "PUT")
|
||||||
|
let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
|
request.setValue("multipart/form-data; boundary=\(boundary)",
|
||||||
|
forHTTPHeaderField: "Content-Type")
|
||||||
|
let ext = contentType == "image/png" ? "png" : "jpg"
|
||||||
|
var body = Data()
|
||||||
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"foto.\(ext)\"\r\n"
|
||||||
|
.data(using: .utf8)!)
|
||||||
|
body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!)
|
||||||
|
body.append(data)
|
||||||
|
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
|
request.httpBody = body
|
||||||
|
return try await send(request, as: Product.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteProductImage(id: Int) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/products/\(id)/image", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Einzelstücke (Items mit UID/QR)
|
||||||
|
|
||||||
|
func items(productId: Int) async throws -> [Item] {
|
||||||
|
try await send(try makeRequest("/products/\(productId)/items"), as: [Item].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Einzelstücke über alle Produkte – für die Einzelstück-Liste.
|
||||||
|
func allItems() async throws -> [Item] {
|
||||||
|
try await send(try makeRequest("/items"), as: [Item].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createItems(productId: Int, _ payload: ItemCreateRequest) async throws -> [Item] {
|
||||||
|
var request = try makeRequest("/products/\(productId)/items", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: [Item].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// QR-Auflösung: UID → Einzelstück.
|
||||||
|
func itemByUid(uid: String) async throws -> Item {
|
||||||
|
let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid
|
||||||
|
return try await send(try makeRequest("/items/by-uid/\(escaped)"), as: Item.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateItem(id: Int, _ payload: ItemUpdateRequest) async throws -> Item {
|
||||||
|
var request = try makeRequest("/items/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: Item.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeItem(id: Int, _ payload: ItemRemoveRequest) async throws {
|
||||||
|
var request = try makeRequest("/items/\(id)/remove", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
try await sendNoContent(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadItemDocument(itemId: Int, data: Data, filename: String,
|
||||||
|
contentType: String) async throws -> ItemDocumentUpload {
|
||||||
|
var request = try makeRequest("/items/\(itemId)/documents", method: "POST")
|
||||||
|
let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
|
request.setValue("multipart/form-data; boundary=\(boundary)",
|
||||||
|
forHTTPHeaderField: "Content-Type")
|
||||||
|
let safeName = filename.replacingOccurrences(of: "\"", with: "")
|
||||||
|
var body = Data()
|
||||||
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(safeName)\"\r\n"
|
||||||
|
.data(using: .utf8)!)
|
||||||
|
body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!)
|
||||||
|
body.append(data)
|
||||||
|
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
|
request.httpBody = body
|
||||||
|
return try await send(request, as: ItemDocumentUpload.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beleg nur analysieren (nichts speichern) – zum Vorbefüllen beim Anlegen.
|
||||||
|
func analyzeItemDocument(data: Data, filename: String,
|
||||||
|
contentType: String) async throws -> DocumentSuggestions {
|
||||||
|
var request = try makeRequest("/items/analyze-document", method: "POST")
|
||||||
|
let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
|
request.setValue("multipart/form-data; boundary=\(boundary)",
|
||||||
|
forHTTPHeaderField: "Content-Type")
|
||||||
|
let safeName = filename.replacingOccurrences(of: "\"", with: "")
|
||||||
|
var body = Data()
|
||||||
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(safeName)\"\r\n"
|
||||||
|
.data(using: .utf8)!)
|
||||||
|
body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!)
|
||||||
|
body.append(data)
|
||||||
|
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
|
request.httpBody = body
|
||||||
|
return try await send(request, as: DocumentSuggestions.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itemDocumentData(itemId: Int, docId: Int) async throws -> Data {
|
||||||
|
let request = try makeRequest("/items/\(itemId)/documents/\(docId)")
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
try check(response, data: data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteItemDocument(itemId: Int, docId: Int) async throws {
|
||||||
|
try await sendNoContent(
|
||||||
|
try makeRequest("/items/\(itemId)/documents/\(docId)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteItem(id: Int) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/items/\(id)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
func groups() async throws -> [GroupItem] {
|
func groups() async throws -> [GroupItem] {
|
||||||
try await send(try makeRequest("/groups"), as: [GroupItem].self)
|
try await send(try makeRequest("/groups"), as: [GroupItem].self)
|
||||||
}
|
}
|
||||||
@@ -149,6 +280,10 @@ actor APIClient {
|
|||||||
try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self)
|
try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shoppingByLocation() async throws -> [LocationNeeds] {
|
||||||
|
try await send(try makeRequest("/shopping-list/by-location"), as: [LocationNeeds].self)
|
||||||
|
}
|
||||||
|
|
||||||
func expiring(days: Int? = nil) async throws -> [ExpiringItem] {
|
func expiring(days: Int? = nil) async throws -> [ExpiringItem] {
|
||||||
let path = days.map { "/expiring?days=\($0)" } ?? "/expiring"
|
let path = days.map { "/expiring?days=\($0)" } ?? "/expiring"
|
||||||
return try await send(try makeRequest(path), as: [ExpiringItem].self)
|
return try await send(try makeRequest(path), as: [ExpiringItem].self)
|
||||||
@@ -166,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)
|
||||||
@@ -179,8 +321,233 @@ actor APIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteLot(id: Int) async throws {
|
func deleteLot(id: Int) async throws {
|
||||||
let request = try makeRequest("/lots/\(id)", method: "DELETE")
|
try await sendNoContent(try makeRequest("/lots/\(id)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fuer Antworten ohne Rumpf (204) - dort gibt es nichts zu dekodieren.
|
||||||
|
private func sendNoContent(_ request: URLRequest) async throws {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
try check(response, data: data) // 204: kein Rumpf zum Auswerten
|
try check(response, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gegenstaende (Non-Food)
|
||||||
|
|
||||||
|
func shops() async throws -> [ShopItem] {
|
||||||
|
try await send(try makeRequest("/shops"), as: [ShopItem].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createShop(_ payload: NewShopRequest) async throws -> ShopItem {
|
||||||
|
var request = try makeRequest("/shops", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: ShopItem.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateShop(id: Int, _ payload: ShopUpdateRequest) async throws -> ShopItem {
|
||||||
|
var request = try makeRequest("/shops/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: ShopItem.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteShop(id: Int) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/shops/\(id)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effektive (vererbte) Felder einer Kategorie – fuer das Artikelformular.
|
||||||
|
func categoryFields(categoryId: Int) async throws -> [FieldDefinition] {
|
||||||
|
try await send(try makeRequest("/categories/\(categoryId)/fields"), as: [FieldDefinition].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nur die *eigenen* Felder einer Kategorie (ohne vererbte) – fuer die
|
||||||
|
/// Feldverwaltung, wo man sie anlegt, bearbeitet und loescht.
|
||||||
|
func ownFieldDefinitions(categoryId: Int) async throws -> [FieldDefinition] {
|
||||||
|
try await send(try makeRequest("/field-definitions?category_id=\(categoryId)"),
|
||||||
|
as: [FieldDefinition].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createFieldDefinition(_ payload: FieldDefinitionCreate) async throws -> FieldDefinition {
|
||||||
|
var request = try makeRequest("/field-definitions", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: FieldDefinition.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateFieldDefinition(id: Int, _ payload: FieldDefinitionUpdate) async throws -> FieldDefinition {
|
||||||
|
var request = try makeRequest("/field-definitions/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: FieldDefinition.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteFieldDefinition(id: Int) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/field-definitions/\(id)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Menge eines Gegenstands an einem Lagerort erhoehen.
|
||||||
|
func objectCheckIn(_ payload: ObjectCheckInRequest) async throws -> StockResponse {
|
||||||
|
var request = try makeRequest("/stock/checkin", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: StockResponse.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Menge von einem Lagerort zum anderen umbuchen (ohne Grund).
|
||||||
|
func relocate(_ payload: RelocateRequest) async throws -> StockResponse {
|
||||||
|
var request = try makeRequest("/stock/relocate", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: StockResponse.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Menge mit Pflicht-Grund aus dem Bestand entfernen.
|
||||||
|
func removeStock(_ payload: RemoveRequest) async throws -> StockResponse {
|
||||||
|
var request = try makeRequest("/stock/remove", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: StockResponse.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entnahme-Statistik (Summe je Grund) und die juengsten Entnahmen.
|
||||||
|
func productRemovals(id: Int) async throws -> RemovalSummary {
|
||||||
|
try await send(try makeRequest("/products/\(id)/removals"), as: RemovalSummary.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Uebersicht und Verlauf
|
||||||
|
|
||||||
|
func dashboardStats() async throws -> DashboardStats {
|
||||||
|
try await send(try makeRequest("/dashboard/stats"), as: DashboardStats.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Dashboards des Benutzers - dieselben wie in der Web-Oberflaeche.
|
||||||
|
func dashboards() async throws -> DashboardList {
|
||||||
|
try await send(try makeRequest("/dashboard/layouts"), as: DashboardList.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Diagrammdaten
|
||||||
|
|
||||||
|
func expirySplit() async throws -> ExpirySplit {
|
||||||
|
try await send(try makeRequest("/dashboard/expiry-split"), as: ExpirySplit.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func byCategory(depth: Int? = nil) async throws -> [CategoryShare] {
|
||||||
|
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] {
|
||||||
|
var path = "/dashboard/timeline?days=\(days)"
|
||||||
|
if let productId { path += "&product_id=\(productId)" }
|
||||||
|
return try await send(try makeRequest(path), as: [TimelinePoint].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activity(days: Int) async throws -> [ActivityPoint] {
|
||||||
|
try await send(try makeRequest("/dashboard/activity?days=\(days)"), as: [ActivityPoint].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Neueste zuerst. `productId` filtert auf einen Artikel.
|
||||||
|
func movements(limit: Int = 100, productId: Int? = nil) async throws -> [Movement] {
|
||||||
|
var path = "/movements?limit=\(limit)"
|
||||||
|
if let productId { path += "&product_id=\(productId)" }
|
||||||
|
return try await send(try makeRequest(path), as: [Movement].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stammdaten
|
||||||
|
|
||||||
|
func createLocation(_ payload: NewLocationRequest) async throws -> StorageLocation {
|
||||||
|
var request = try makeRequest("/locations", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: StorageLocation.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameLocation(id: String, name: String) async throws -> StorageLocation {
|
||||||
|
var request = try makeRequest("/locations/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, RenameRequest(name: name))
|
||||||
|
return try await send(request, as: StorageLocation.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLocation(id: String, _ payload: LocationUpdateRequest) async throws -> StorageLocation {
|
||||||
|
var request = try makeRequest("/locations/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: StorageLocation.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteLocation(id: String) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func createUnit(_ payload: NewUnitRequest) async throws -> Unit {
|
||||||
|
var request = try makeRequest("/units", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: Unit.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameUnit(id: Int, name: String) async throws -> Unit {
|
||||||
|
var request = try makeRequest("/units/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, RenameRequest(name: name))
|
||||||
|
return try await send(request, as: Unit.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteUnit(id: Int) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/units/\(id)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func packageTypes() async throws -> [PackageType] {
|
||||||
|
try await send(try makeRequest("/package-types"), as: [PackageType].self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createPackageType(_ payload: NewPackageTypeRequest) async throws -> PackageType {
|
||||||
|
var request = try makeRequest("/package-types", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: PackageType.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePackageType(id: Int, _ payload: PackageTypeUpdateRequest) async throws -> PackageType {
|
||||||
|
var request = try makeRequest("/package-types/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: PackageType.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletePackageType(id: Int) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/package-types/\(id)", method: "DELETE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCategory(_ payload: NewCategoryRequest) async throws -> CategoryItem {
|
||||||
|
var request = try makeRequest("/categories", method: "POST")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: CategoryItem.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameCategory(id: Int, name: String) async throws -> CategoryItem {
|
||||||
|
var request = try makeRequest("/categories/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, RenameRequest(name: name))
|
||||||
|
return try await send(request, as: CategoryItem.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCategory(id: Int, _ payload: CategoryUpdateRequest) async throws -> CategoryItem {
|
||||||
|
var request = try makeRequest("/categories/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, payload)
|
||||||
|
return try await send(request, as: CategoryItem.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCategory(id: Int) async throws {
|
||||||
|
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 {
|
||||||
|
var request = try makeRequest("/groups/\(id)", method: "PATCH")
|
||||||
|
try jsonBody(&request, RenameRequest(name: name))
|
||||||
|
return try await send(request, as: GroupItem.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteGroup(id: Int) async throws {
|
||||||
|
try await sendNoContent(try makeRequest("/groups/\(id)", method: "DELETE"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
ios/Sources/AppDashboards.swift
Normal file
130
ios/Sources/AppDashboards.swift
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Eine **eigene** Übersicht der App - unabhängig von den Web-Dashboards und nur
|
||||||
|
/// auf diesem Gerät gespeichert. Je Server wählbar, ob die Web-Dashboards
|
||||||
|
/// gespiegelt werden oder diese lokale Übersicht gilt.
|
||||||
|
|
||||||
|
/// Modus je Server.
|
||||||
|
enum OverviewMode: String, Codable {
|
||||||
|
case mirror // die Web-Dashboards spiegeln (Standard)
|
||||||
|
case local // eigene App-Übersicht
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Karte der lokalen Übersicht. Einspaltig - die Reihenfolge im Array
|
||||||
|
/// bestimmt die Anordnung, deshalb kein Raster.
|
||||||
|
struct AppCard: Codable, Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var type: String
|
||||||
|
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,
|
||||||
|
mitOhneMhd: Bool? = nil, detail: Bool? = nil, tiefe: Int? = nil) {
|
||||||
|
self.id = id
|
||||||
|
self.type = type
|
||||||
|
self.productId = productId
|
||||||
|
self.mitOhneMhd = mitOhneMhd
|
||||||
|
self.detail = detail
|
||||||
|
self.tiefe = tiefe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppDashboard: Codable, Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var name: String
|
||||||
|
var cards: [AppCard]
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), name: String, cards: [AppCard] = []) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.cards = cards
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sinnvolle Startkarten, wenn jemand die eigene Übersicht zum ersten Mal
|
||||||
|
/// anlegt - eine brauchbare Zusammenstellung statt einer leeren Seite.
|
||||||
|
static func standard() -> AppDashboard {
|
||||||
|
AppDashboard(name: "Übersicht", cards: [
|
||||||
|
AppCard(type: "status"),
|
||||||
|
AppCard(type: "checkin-quick"),
|
||||||
|
AppCard(type: "shopping"),
|
||||||
|
AppCard(type: "expiry-all"),
|
||||||
|
AppCard(type: "movements"),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was ein Server lokal an Übersicht hat: der Modus und die eigenen Dashboards.
|
||||||
|
struct AppOverviewConfig: Codable, Equatable {
|
||||||
|
var mode: OverviewMode = .mirror
|
||||||
|
var dashboards: [AppDashboard] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ablage je Profil-UUID in den UserDefaults, wie `NotificationStore`.
|
||||||
|
enum AppDashboardStore {
|
||||||
|
static let key = "app_overviews"
|
||||||
|
|
||||||
|
static func loadAll() -> [String: AppOverviewConfig] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: key),
|
||||||
|
let dict = try? JSONDecoder().decode([String: AppOverviewConfig].self, from: data)
|
||||||
|
else { return [:] }
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
static func config(for profileID: UUID) -> AppOverviewConfig {
|
||||||
|
loadAll()[profileID.uuidString] ?? AppOverviewConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ config: AppOverviewConfig, for profileID: UUID) {
|
||||||
|
var alle = loadAll()
|
||||||
|
alle[profileID.uuidString] = config
|
||||||
|
guard let data = try? JSONEncoder().encode(alle) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func remove(for profileID: UUID) {
|
||||||
|
var alle = loadAll()
|
||||||
|
alle.removeValue(forKey: profileID.uuidString)
|
||||||
|
guard let data = try? JSONEncoder().encode(alle) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verfügbare Kartenarten für die lokale Übersicht - Grundlage des Hinzufügen-Menüs.
|
||||||
|
struct CardKind: Identifiable {
|
||||||
|
let type: String
|
||||||
|
let needsProduct: Bool
|
||||||
|
var id: String { type }
|
||||||
|
var title: String { CardCatalog.titel(type) }
|
||||||
|
var isChart: Bool { CardCatalog.istDiagramm(type) }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CardCatalogList {
|
||||||
|
/// Reihenfolge wie sie im Hinzufügen-Menü erscheinen soll.
|
||||||
|
static let all: [CardKind] = [
|
||||||
|
CardKind(type: "status", needsProduct: false),
|
||||||
|
CardKind(type: "kpi-products", needsProduct: false),
|
||||||
|
CardKind(type: "kpi-units", needsProduct: false),
|
||||||
|
CardKind(type: "checkin-quick", needsProduct: false),
|
||||||
|
CardKind(type: "checkout-quick", needsProduct: false),
|
||||||
|
CardKind(type: "actions", needsProduct: false),
|
||||||
|
CardKind(type: "shopping", needsProduct: false),
|
||||||
|
CardKind(type: "expiry-all", needsProduct: false),
|
||||||
|
CardKind(type: "expiring", needsProduct: false),
|
||||||
|
CardKind(type: "expired", needsProduct: false),
|
||||||
|
CardKind(type: "movements", needsProduct: false),
|
||||||
|
CardKind(type: "product-stock", needsProduct: true),
|
||||||
|
CardKind(type: "product-timeline", needsProduct: true),
|
||||||
|
CardKind(type: "expiry-donut", needsProduct: false),
|
||||||
|
CardKind(type: "category-donut", needsProduct: false),
|
||||||
|
CardKind(type: "category-bars", needsProduct: false),
|
||||||
|
CardKind(type: "stock-timeline", needsProduct: false),
|
||||||
|
CardKind(type: "activity-timeline", needsProduct: false),
|
||||||
|
]
|
||||||
|
|
||||||
|
static func kind(for type: String) -> CardKind? { all.first { $0.type == type } }
|
||||||
|
}
|
||||||
148
ios/Sources/AssignScanView.swift
Normal file
148
ios/Sources/AssignScanView.swift
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Zuordnen per Scan: erst ein Einzelstück (…/i/<UID>) scannen, dann einen
|
||||||
|
/// Lagerort (…/l/<ID>) – das Stück wird dem Ort zugewiesen. Danach gleich das
|
||||||
|
/// nächste Stück, ohne Tippen.
|
||||||
|
struct AssignScanView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var paused = false
|
||||||
|
@State private var torchOn = false
|
||||||
|
@State private var torchLocked = false
|
||||||
|
@State private var pending: Item? // gescanntes/gewähltes Stück, wartet auf Ort
|
||||||
|
@State private var status: String?
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var showPicker = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
ScannerView(onCode: { code in Task { await handle(code) } },
|
||||||
|
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||||||
|
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if let pending {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Einzelstück \(pending.uid)").font(.headline)
|
||||||
|
Text("Jetzt den Lagerort-QR scannen")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
Button("Abbrechen") { self.pending = nil }
|
||||||
|
.font(.caption).padding(.top, 2)
|
||||||
|
}
|
||||||
|
.padding().frame(maxWidth: .infinity)
|
||||||
|
.background(Color.marke.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12)).padding(.horizontal)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("Schritt 1: Einzelstück-QR scannen – oder ohne Code:")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
// Für Stücke ohne eigenen QR (z.B. Kleidung): aus der Liste wählen.
|
||||||
|
Button { showPicker = true } label: {
|
||||||
|
Label("Einzelstück aus Liste wählen", systemImage: "list.bullet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let status {
|
||||||
|
Text(status).font(.callout).foregroundStyle(.green)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Text(error).font(.callout).foregroundStyle(.red)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
.navigationTitle("Ort zuordnen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||||||
|
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPicker) {
|
||||||
|
ItemPickerSheet { it in pending = it; status = nil; error = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(_ code: String) async {
|
||||||
|
// Lagerort-QR (…/l/<Code>)? Der Code ist eine 10-stellige Zeichenkette.
|
||||||
|
if let r = code.range(of: "/l/") {
|
||||||
|
let locCode = String(code[r.upperBound...])
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
|
||||||
|
.uppercased()
|
||||||
|
if !locCode.isEmpty {
|
||||||
|
guard let it = pending else {
|
||||||
|
error = "Erst ein Einzelstück scannen."; status = nil; return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest(
|
||||||
|
locationId: locCode, shopId: it.shopId,
|
||||||
|
acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note))
|
||||||
|
status = "\(it.uid) zugeordnet. Nächstes Stück scannen."
|
||||||
|
error = nil
|
||||||
|
pending = nil
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Einzelstück-QR (…/i/<UID>)?
|
||||||
|
if let r = code.range(of: "/i/") {
|
||||||
|
let uid = String(code[r.upperBound...])
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased()
|
||||||
|
if !uid.isEmpty, let it = try? await APIClient.shared.itemByUid(uid: uid) {
|
||||||
|
pending = it; status = nil; error = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error = "Unbekannter Code – bitte einen Einzelstück- oder Lagerort-QR scannen."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Einzelstück aus einer durchsuchbaren Liste wählen – für Stücke ohne eigenen QR.
|
||||||
|
struct ItemPickerSheet: View {
|
||||||
|
var onPick: (Item) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var items: [Item] = []
|
||||||
|
@State private var locations: [StorageLocation] = []
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var busy = true
|
||||||
|
|
||||||
|
private var gefiltert: [Item] {
|
||||||
|
let q = query.trimmingCharacters(in: .whitespaces).lowercased()
|
||||||
|
guard !q.isEmpty else { return items }
|
||||||
|
return items.filter {
|
||||||
|
[$0.productName, $0.uid, $0.productBrand, $0.note]
|
||||||
|
.compactMap { $0 }.joined(separator: " ").lowercased().contains(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
if busy && items.isEmpty { ProgressView() }
|
||||||
|
ForEach(gefiltert) { it in
|
||||||
|
Button { onPick(it); dismiss() } label: {
|
||||||
|
ItemRow(item: it, locations: locations)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
if !busy && gefiltert.isEmpty {
|
||||||
|
Text("Nichts gefunden.").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $query, prompt: "Produkt, UID oder Notiz")
|
||||||
|
.navigationTitle("Einzelstück wählen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||||||
|
.task {
|
||||||
|
items = (try? await APIClient.shared.allItems()) ?? []
|
||||||
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ enum BestBeforeText {
|
|||||||
(#"(\d{1,2})[./-](\d{4})"#, ["m", "y"], "month"),
|
(#"(\d{1,2})[./-](\d{4})"#, ["m", "y"], "month"),
|
||||||
(#"(\d{4})[./-](\d{1,2})(?!\d)"#, ["y", "m"], "month"),
|
(#"(\d{4})[./-](\d{1,2})(?!\d)"#, ["y", "m"], "month"),
|
||||||
(#"(\d{1,2})[./-](\d{2})(?!\d)"#, ["m", "Y"], "month"),
|
(#"(\d{1,2})[./-](\d{2})(?!\d)"#, ["m", "Y"], "month"),
|
||||||
|
// Nur Tag und Monat, kein Jahr (z.B. „05.01" bei Frischware / „zu
|
||||||
|
// verbrauchen bis"). Das Jahr ergänzt build(). Steht bewusst zuletzt,
|
||||||
|
// damit Muster mit Jahr Vorrang haben.
|
||||||
|
(#"(\d{1,2})[./-](\d{1,2})(?!\d)"#, ["d", "m"], "day"),
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Alle plausiblen Daten aus einem Textstueck, in Fundreihenfolge und ohne Dubletten.
|
/// Alle plausiblen Daten aus einem Textstueck, in Fundreihenfolge und ohne Dubletten.
|
||||||
@@ -73,6 +77,18 @@ enum BestBeforeText {
|
|||||||
jahr = vier
|
jahr = vier
|
||||||
} else if let zwei = teile["Y"] {
|
} else if let zwei = teile["Y"] {
|
||||||
jahr = 2000 + zwei
|
jahr = 2000 + zwei
|
||||||
|
} else if precision == "day", let tag = teile["d"] {
|
||||||
|
// Kein Jahr aufgedruckt (Frischware): aktuelles Jahr annehmen; liegt
|
||||||
|
// das Datum schon in der Vergangenheit, das nächste Jahr nehmen.
|
||||||
|
let cal = Calendar.current
|
||||||
|
let aktuell = cal.component(.year, from: Date())
|
||||||
|
var probe = DateComponents()
|
||||||
|
probe.year = aktuell; probe.month = monat; probe.day = tag
|
||||||
|
if let d = cal.date(from: probe), d < cal.startOfDay(for: Date()) {
|
||||||
|
jahr = aktuell + 1
|
||||||
|
} else {
|
||||||
|
jahr = aktuell
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -92,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)"
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import SwiftUI
|
|||||||
struct CategoryPicker: View {
|
struct CategoryPicker: View {
|
||||||
let categories: [CategoryItem]
|
let categories: [CategoryItem]
|
||||||
@Binding var selection: Int?
|
@Binding var selection: Int?
|
||||||
|
/// Nur Kategorien dieses Typs zeigen ("food"/"object"); nil = alle.
|
||||||
|
var tracking: String? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Picker("Kategorie", selection: $selection) {
|
Picker("Kategorie", selection: $selection) {
|
||||||
@@ -18,17 +20,23 @@ struct CategoryPicker: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var gefiltert: [CategoryItem] {
|
||||||
|
guard let tracking else { return categories }
|
||||||
|
return categories.filter { $0.tracking == tracking }
|
||||||
|
}
|
||||||
|
|
||||||
private func flattened() -> [(item: CategoryItem, depth: Int)] {
|
private func flattened() -> [(item: CategoryItem, depth: Int)] {
|
||||||
let ids = Set(categories.map(\.id))
|
let quelle = gefiltert
|
||||||
|
let ids = Set(quelle.map(\.id))
|
||||||
var result: [(CategoryItem, Int)] = []
|
var result: [(CategoryItem, Int)] = []
|
||||||
|
|
||||||
func walk(_ node: CategoryItem, _ depth: Int) {
|
func walk(_ node: CategoryItem, _ depth: Int) {
|
||||||
result.append((node, depth))
|
result.append((node, depth))
|
||||||
for child in categories.filter({ $0.parentId == node.id }) {
|
for child in quelle.filter({ $0.parentId == node.id }) {
|
||||||
walk(child, depth + 1)
|
walk(child, depth + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
for root in quelle.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
||||||
walk(root, 0)
|
walk(root, 0)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
312
ios/Sources/ChartCards.swift
Normal file
312
ios/Sources/ChartCards.swift
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Charts
|
||||||
|
|
||||||
|
/// Native Diagrammkarten der Übersicht (Swift Charts). Ersetzt den früheren
|
||||||
|
/// „gibt es nur im Web"-Hinweis.
|
||||||
|
///
|
||||||
|
/// Ringe (SectorMark) gibt es erst ab iOS 17; auf iOS 16 tritt an ihre Stelle
|
||||||
|
/// ein 100-%-Balken, der dieselben Anteile zeigt. Balken und Linien laufen auf
|
||||||
|
/// beiden.
|
||||||
|
|
||||||
|
/// Farben der Ablaufzustände - überall gleich, damit Grün/Orange/Rot dasselbe
|
||||||
|
/// bedeuten.
|
||||||
|
enum ExpiryColor {
|
||||||
|
static let ok = Color.green
|
||||||
|
static let soon = Color.orange
|
||||||
|
static let expired = Color.red
|
||||||
|
static let noDate = Color.gray
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Anteil für Ring bzw. 100-%-Balken.
|
||||||
|
private struct Segment: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let label: String
|
||||||
|
let value: Double
|
||||||
|
let color: Color
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ring (iOS 17+) oder 100-%-Balken (iOS 16) plus Legende.
|
||||||
|
private struct ProportionChart: View {
|
||||||
|
let segments: [Segment]
|
||||||
|
let einheit: String
|
||||||
|
|
||||||
|
private var sichtbar: [Segment] { segments.filter { $0.value > 0 } }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if sichtbar.isEmpty {
|
||||||
|
Text("Keine Daten.").font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
Chart(sichtbar) { seg in
|
||||||
|
SectorMark(angle: .value("Anteil", seg.value),
|
||||||
|
innerRadius: .ratio(0.62), angularInset: 1.5)
|
||||||
|
.foregroundStyle(seg.color)
|
||||||
|
}
|
||||||
|
.frame(height: 160)
|
||||||
|
} else {
|
||||||
|
Chart(sichtbar) { seg in
|
||||||
|
BarMark(x: .value("Anteil", seg.value), y: .value("", "Bestand"))
|
||||||
|
.foregroundStyle(seg.color)
|
||||||
|
}
|
||||||
|
.chartXAxis(.hidden).chartYAxis(.hidden)
|
||||||
|
.frame(height: 28)
|
||||||
|
}
|
||||||
|
legende
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var legende: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
ForEach(sichtbar) { seg in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle().fill(seg.color).frame(width: 8, height: 8)
|
||||||
|
Text(seg.label).font(.caption)
|
||||||
|
Spacer()
|
||||||
|
Text("\(formatAmount(seg.value)) \(einheit)")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waehlt anhand der Art die passende Diagrammkarte (ohne Artikelbezug).
|
||||||
|
struct ChartCardView: View {
|
||||||
|
let type: String
|
||||||
|
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 {
|
||||||
|
switch type {
|
||||||
|
case "expiry-donut": ExpiryDonutCard(stand: stand, mitOhneMhd: mitOhneMhd)
|
||||||
|
case "category-donut": CategoryDonutCard(stand: stand, detail: detail, tiefe: tiefe)
|
||||||
|
case "category-bars": CategoryBarsCard(stand: stand)
|
||||||
|
case "stock-timeline": StockTimelineCard(stand: stand)
|
||||||
|
case "activity-timeline": ActivityTimelineCard(stand: stand)
|
||||||
|
default:
|
||||||
|
Text("Unbekanntes Diagramm „\(type)“.").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ladehülle
|
||||||
|
|
||||||
|
/// Nimmt die wiederkehrende Lade-/Fehlerlogik der Diagrammkarten ab.
|
||||||
|
private struct ChartLoader<Data, Inhalt: View>: View {
|
||||||
|
let laden: () async throws -> Data
|
||||||
|
let stand: Int
|
||||||
|
@ViewBuilder let inhalt: (Data) -> Inhalt
|
||||||
|
|
||||||
|
@State private var daten: Data?
|
||||||
|
@State private var fehler: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let fehler {
|
||||||
|
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||||
|
} else if let daten {
|
||||||
|
inhalt(daten)
|
||||||
|
} else {
|
||||||
|
Text("Lädt…").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: stand) {
|
||||||
|
do { daten = try await laden(); fehler = nil }
|
||||||
|
catch { self.fehler = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ablauf-Anteile
|
||||||
|
|
||||||
|
struct ExpiryDonutCard: View {
|
||||||
|
let stand: Int
|
||||||
|
// Web-Karte „Ohne MHD einbeziehen": aus, blendet den grauen Anteil aus.
|
||||||
|
var mitOhneMhd: Bool = true
|
||||||
|
var body: some View {
|
||||||
|
ChartLoader(laden: { try await APIClient.shared.expirySplit() }, stand: stand) { s in
|
||||||
|
var segmente = [
|
||||||
|
Segment(label: "In Ordnung", value: s.ok, color: ExpiryColor.ok),
|
||||||
|
Segment(label: "Bald ablaufend", value: s.soon, color: ExpiryColor.soon),
|
||||||
|
Segment(label: "Abgelaufen", value: s.expired, color: ExpiryColor.expired),
|
||||||
|
]
|
||||||
|
if mitOhneMhd {
|
||||||
|
segmente.append(Segment(label: "Ohne MHD", value: s.noDate, color: ExpiryColor.noDate))
|
||||||
|
}
|
||||||
|
return ProportionChart(segments: segmente, einheit: "Artikeleinheiten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kategorien-Anteile
|
||||||
|
|
||||||
|
struct CategoryDonutCard: View {
|
||||||
|
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.
|
||||||
|
private static let palette: [Color] = [
|
||||||
|
.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 {
|
||||||
|
ChartLoader(laden: { try await APIClient.shared.byCategory(depth: tiefe) }, stand: stand) { liste in
|
||||||
|
let sortiert = liste.filter { $0.articleUnits > 0 }
|
||||||
|
.sorted { $0.articleUnits > $1.articleUnits }
|
||||||
|
let segmente: [Segment]
|
||||||
|
if detail {
|
||||||
|
// Detailansicht: alle Kategorien einzeln, nichts wird gebuendelt.
|
||||||
|
segmente = sortiert.enumerated().map { i, c in
|
||||||
|
Segment(label: c.name, value: c.articleUnits, color: Self.farbe(i))
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
return ProportionChart(segments: segmente, einheit: "Artikeleinheiten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kategorien nach Zustand (gestapelte Balken)
|
||||||
|
|
||||||
|
struct CategoryBarsCard: View {
|
||||||
|
let stand: Int
|
||||||
|
|
||||||
|
private struct Zeile: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let kategorie: String
|
||||||
|
let zustand: String
|
||||||
|
let wert: Double
|
||||||
|
let farbe: Color
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ChartLoader(laden: { try await APIClient.shared.byCategory() }, stand: stand) { liste in
|
||||||
|
let top = liste.filter { $0.articleUnits > 0 }
|
||||||
|
.sorted { $0.articleUnits > $1.articleUnits }.prefix(8)
|
||||||
|
let zeilen: [Zeile] = top.flatMap { c in
|
||||||
|
[
|
||||||
|
Zeile(kategorie: c.name, zustand: "In Ordnung", wert: c.ok, farbe: ExpiryColor.ok),
|
||||||
|
Zeile(kategorie: c.name, zustand: "Bald", wert: c.soon, farbe: ExpiryColor.soon),
|
||||||
|
Zeile(kategorie: c.name, zustand: "Abgelaufen", wert: c.expired, farbe: ExpiryColor.expired),
|
||||||
|
Zeile(kategorie: c.name, zustand: "Ohne MHD", wert: c.noDate, farbe: ExpiryColor.noDate),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return Chart(zeilen) { z in
|
||||||
|
BarMark(x: .value("Artikeleinheiten", z.wert),
|
||||||
|
y: .value("Kategorie", z.kategorie))
|
||||||
|
.foregroundStyle(z.farbe)
|
||||||
|
}
|
||||||
|
.chartLegend(.hidden)
|
||||||
|
.frame(height: max(120, CGFloat(top.count) * 34))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bestandsverlauf (Linie)
|
||||||
|
|
||||||
|
struct StockTimelineCard: View {
|
||||||
|
let stand: Int
|
||||||
|
var body: some View {
|
||||||
|
ChartLoader(laden: { try await APIClient.shared.timeline(days: 90) }, stand: stand) { punkte in
|
||||||
|
LinienDiagramm(punkte: punkte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verlauf **eines** Artikels - dieselbe Linie, nur gefiltert.
|
||||||
|
struct ProductTimelineChart: View {
|
||||||
|
let productId: Int?
|
||||||
|
let stand: Int
|
||||||
|
var body: some View {
|
||||||
|
if let productId {
|
||||||
|
ChartLoader(laden: { try await APIClient.shared.timeline(days: 90, productId: productId) },
|
||||||
|
stand: stand) { punkte in
|
||||||
|
LinienDiagramm(punkte: punkte)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Kein Artikel gewählt – beim Bearbeiten einstellen.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LinienDiagramm: View {
|
||||||
|
let punkte: [TimelinePoint]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if punkte.isEmpty {
|
||||||
|
Text("Keine Daten.").font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Chart(punkte) { p in
|
||||||
|
if let datum = DisplaySettings.parseTimestamp(p.at) {
|
||||||
|
LineMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
|
||||||
|
.foregroundStyle(Color.marke)
|
||||||
|
AreaMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
|
||||||
|
.foregroundStyle(Color.marke.opacity(0.12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 170)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ein- und Auslagerungen (gruppierte Balken)
|
||||||
|
|
||||||
|
struct ActivityTimelineCard: View {
|
||||||
|
let stand: Int
|
||||||
|
|
||||||
|
private struct Balken: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let datum: Date
|
||||||
|
let richtung: String
|
||||||
|
let wert: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ChartLoader(laden: { try await APIClient.shared.activity(days: 30) }, stand: stand) { punkte in
|
||||||
|
let balken: [Balken] = punkte.compactMap { p in
|
||||||
|
DisplaySettings.parseTimestamp(p.at).map { d in [
|
||||||
|
Balken(datum: d, richtung: "Eingelagert", wert: p.checkedIn),
|
||||||
|
Balken(datum: d, richtung: "Ausgelagert", wert: p.checkedOut),
|
||||||
|
] } ?? []
|
||||||
|
}.flatMap { $0 }
|
||||||
|
|
||||||
|
return Chart(balken) { b in
|
||||||
|
BarMark(x: .value("Tag", b.datum, unit: .day), y: .value("Anzahl", b.wert))
|
||||||
|
.foregroundStyle(by: .value("Richtung", b.richtung))
|
||||||
|
.position(by: .value("Richtung", b.richtung))
|
||||||
|
}
|
||||||
|
.chartForegroundStyleScale([
|
||||||
|
"Eingelagert": Color.green, "Ausgelagert": Color.orange,
|
||||||
|
])
|
||||||
|
.frame(height: 170)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,10 @@ struct LabeledField: View {
|
|||||||
Spacer(minLength: 12)
|
Spacer(minLength: 12)
|
||||||
TextField(label, text: $text)
|
TextField(label, text: $text)
|
||||||
.keyboardType(keyboard)
|
.keyboardType(keyboard)
|
||||||
.multilineTextAlignment(.trailing)
|
// Linksbündig: bei rechtsbündigen Feldern rendert iOS ein
|
||||||
|
// getipptes Leerzeichen am Ende nicht, bis ein weiteres Zeichen
|
||||||
|
// folgt – das wirkt, als würde die Eingabe nicht reagieren.
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
.fieldBox(width: 190)
|
.fieldBox(width: 190)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +141,7 @@ struct CheckInFormView: View {
|
|||||||
var onDone: (String) -> Void
|
var onDone: (String) -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
|
||||||
struct Line: Identifiable {
|
struct Line: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
@@ -153,14 +157,26 @@ struct CheckInFormView: View {
|
|||||||
/// Charge, fuer die gerade das MHD abgescannt wird.
|
/// Charge, fuer die gerade das MHD abgescannt wird.
|
||||||
@State private var scanLineId: LineRef?
|
@State private var scanLineId: LineRef?
|
||||||
@State private var unit: String = ""
|
@State private var unit: String = ""
|
||||||
@State private var locationId: Int?
|
@State private var locationId: String?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
|
// Lagerort per QR (…/l/<Code>) setzen, statt in der Liste zu suchen.
|
||||||
|
@State private var locScanShown = false
|
||||||
|
@State private var locScanPaused = false
|
||||||
|
@State private var locTorchOn = false
|
||||||
|
@State private var locTorchLocked = false
|
||||||
|
@State private var locScanError: String?
|
||||||
|
@State private var locScanHinweis: String?
|
||||||
|
|
||||||
/// 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".
|
||||||
@@ -189,13 +205,31 @@ struct CheckInFormView: View {
|
|||||||
Picker("Einheit", selection: $unit) {
|
Picker("Einheit", selection: $unit) {
|
||||||
ForEach(unitOptions) { Text($0.label).tag($0.value) }
|
ForEach(unitOptions) { Text($0.label).tag($0.value) }
|
||||||
}
|
}
|
||||||
if !locations.isEmpty {
|
}
|
||||||
|
|
||||||
|
if !locations.isEmpty {
|
||||||
|
Section {
|
||||||
Picker("Lagerort", selection: $locationId) {
|
Picker("Lagerort", selection: $locationId) {
|
||||||
Text("– keiner –").tag(Int?.none)
|
Text("– keiner –").tag(String?.none)
|
||||||
ForEach(locations) { location in
|
ForEach(locations) { location in
|
||||||
Text(location.name).tag(Int?.some(location.id))
|
Text(location.path(in: locations)).tag(String?.some(location.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
locScanError = nil
|
||||||
|
locScanPaused = false
|
||||||
|
locScanShown = true
|
||||||
|
} label: {
|
||||||
|
Label("Lagerort-QR scannen", systemImage: "qrcode.viewfinder")
|
||||||
|
}
|
||||||
|
if let locScanHinweis {
|
||||||
|
Label(locScanHinweis, systemImage: "checkmark.circle.fill")
|
||||||
|
.font(.caption).foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Lagerort")
|
||||||
|
} footer: {
|
||||||
|
Text("Den QR am Regal/Fach scannen, statt den Ort in der Liste zu suchen.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +315,7 @@ struct CheckInFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(isPresented: $locScanShown) { locationScannerCover }
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
|
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
|
||||||
unit = hasPackage ? UnitOption.packageValue : product.unitName
|
unit = hasPackage ? UnitOption.packageValue : product.unitName
|
||||||
@@ -289,6 +324,60 @@ struct CheckInFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Lagerort scannen
|
||||||
|
|
||||||
|
private var locationScannerCover: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScannerView(onCode: { code in Task { await handleLocationScan(code) } },
|
||||||
|
isPaused: $locScanPaused, torchOn: $locTorchOn, torchLocked: $locTorchLocked)
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
if let locScanError {
|
||||||
|
Text(locScanError)
|
||||||
|
.font(.callout).foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||||
|
.background(.red, in: Capsule())
|
||||||
|
}
|
||||||
|
Text("QR am Regal/Fach vor die Kamera halten")
|
||||||
|
.font(.callout)
|
||||||
|
.padding(10)
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
}
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.navigationTitle("Lagerort scannen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Abbrechen") { locScanShown = false }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
TorchButton(isOn: $locTorchOn, locked: $locTorchLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { locScanPaused = false; locScanError = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aus einem gescannten Lagerort-QR (…/l/<Code>) den Zielort setzen.
|
||||||
|
private func handleLocationScan(_ code: String) async {
|
||||||
|
guard let r = code.range(of: "/l/") else {
|
||||||
|
locScanError = "Das ist kein Lagerort-QR."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let ziel = String(code[r.upperBound...])
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
|
||||||
|
.uppercased()
|
||||||
|
if let treffer = locations.first(where: { $0.id == ziel }) {
|
||||||
|
locationId = treffer.id
|
||||||
|
locScanHinweis = treffer.name
|
||||||
|
locScanShown = false
|
||||||
|
} else {
|
||||||
|
locScanError = "Dieser Lagerort ist hier nicht bekannt."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func format(_ value: Double) -> String {
|
private func format(_ value: Double) -> String {
|
||||||
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||||||
}
|
}
|
||||||
@@ -323,7 +412,8 @@ struct CheckInFormView: View {
|
|||||||
BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines)
|
BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines)
|
||||||
)
|
)
|
||||||
let total = response.productStock / product.articleUnitFactor
|
let total = response.productStock / product.articleUnitFactor
|
||||||
onDone("Eingelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
|
// Titel + Detail (zweizeilig im Banner); Einheit mit Mehrzahl.
|
||||||
|
onDone("Eingelagert\nNeuer Bestand: \(format(total)) \(display.articleUnit(product, count: total))")
|
||||||
dismiss()
|
dismiss()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
|||||||
@@ -1,83 +1,127 @@
|
|||||||
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
|
||||||
|
|
||||||
|
// Kamera bewusst nur auf Wunsch: „Einlagern“ öffnet ein Formular, kein Sucher.
|
||||||
|
@State private var scanShown = false
|
||||||
@State private var paused = false
|
@State private var paused = false
|
||||||
@State private var torchOn = false
|
@State private var torchOn = false
|
||||||
|
@State private var torchLocked = false
|
||||||
|
|
||||||
@State private var status: String?
|
@State private var status: String?
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
@State private var product: Product?
|
@State private var product: Product?
|
||||||
|
@State private var scannedItem: Item?
|
||||||
@State private var suggestion: LookupResult.Suggestion?
|
@State private var suggestion: LookupResult.Suggestion?
|
||||||
@State private var suggestedGroupId: Int?
|
@State private var suggestedGroupId: Int?
|
||||||
@State private var suggestedGroupName: String?
|
@State private var suggestedGroupName: String?
|
||||||
|
@State private var suggestedCategoryId: Int?
|
||||||
@State private var suggestedCategoryName: String?
|
@State private var suggestedCategoryName: String?
|
||||||
@State private var unknownCode: String?
|
@State private var unknownCode: String?
|
||||||
@State private var manualCodeShown = false
|
@State private var manualCodeShown = false
|
||||||
@State private var manualCode = ""
|
@State private var manualCode = ""
|
||||||
|
|
||||||
|
// Manuelle Artikelsuche (statt Scanzwang).
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var results: [Product] = []
|
||||||
|
@State private var searching = false
|
||||||
|
|
||||||
@State private var units: [Unit] = []
|
@State private var units: [Unit] = []
|
||||||
@State private var locations: [StorageLocation] = []
|
@State private var locations: [StorageLocation] = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// Kamera bewusst nur als Feld oben statt bildschirmfuellend - der
|
Form {
|
||||||
// Sucher ueber die ganze Flaeche wirkte erschlagend.
|
if let status { Section { banner(status, color: .green) } }
|
||||||
ScrollView {
|
if let error { Section { banner(error, color: .red) } }
|
||||||
VStack(spacing: 14) {
|
|
||||||
ScannerView(onCode: { code in Task { await resolve(code) } },
|
|
||||||
isPaused: $paused, torchOn: $torchOn)
|
|
||||||
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
Text("Barcode vor die Kamera halten")
|
Section {
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
Button {
|
||||||
|
error = nil; status = nil
|
||||||
|
scanShown = true
|
||||||
|
} label: {
|
||||||
|
Label("EAN scannen", systemImage: "barcode.viewfinder")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
if let status { banner(status, color: .green) }
|
Button {
|
||||||
if let error { banner(error, color: .red) }
|
manualCode = ""
|
||||||
|
manualCodeShown = true
|
||||||
if let suggestion {
|
} label: {
|
||||||
suggestionCard(suggestion)
|
Label("EAN von Hand eingeben", systemImage: "keyboard")
|
||||||
} else if let unknownCode {
|
|
||||||
unknownCard(unknownCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
NavigationLink {
|
||||||
Button {
|
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||||||
paused = true
|
product = created
|
||||||
manualCodeShown = true
|
|
||||||
} label: {
|
|
||||||
Label("EAN manuell", systemImage: "keyboard")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
} label: {
|
||||||
|
Label("Neuen Artikel anlegen", systemImage: "plus")
|
||||||
NavigationLink {
|
|
||||||
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
|
||||||
product = created
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Artikel anlegen", systemImage: "plus")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
} header: {
|
||||||
|
Text("Neu erfassen")
|
||||||
|
} footer: {
|
||||||
|
Text("Die Kamera öffnet sich erst beim Tippen auf „EAN scannen“ – kein Zwang.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let suggestion {
|
||||||
|
suggestionSection(suggestion)
|
||||||
|
} else if let unknownCode {
|
||||||
|
unknownSection(unknownCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||||
|
TextField("Name oder Marke …", text: $query)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
if !query.isEmpty {
|
||||||
|
Button { query = "" } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if searching {
|
||||||
|
HStack { ProgressView(); Text("Suche …").foregroundStyle(.secondary) }
|
||||||
|
}
|
||||||
|
ForEach(results) { p in
|
||||||
|
Button {
|
||||||
|
error = nil; status = nil
|
||||||
|
product = p
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(p.name).foregroundStyle(.primary)
|
||||||
|
let unter = [p.brand, "Bestand: \(bestandText(p))"]
|
||||||
|
.compactMap { $0 }.joined(separator: " · ")
|
||||||
|
Text(unter).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !query.trimmingCharacters(in: .whitespaces).isEmpty && results.isEmpty && !searching {
|
||||||
|
Text("Kein Artikel gefunden – oben neu anlegen.")
|
||||||
|
.foregroundStyle(.secondary).font(.callout)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Vorhandenen Artikel wählen")
|
||||||
}
|
}
|
||||||
.padding(.vertical)
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Einlagern")
|
.navigationTitle("Einlagern")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||||||
Button("Fertig") { dismiss() }
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) }
|
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
units = (try? await APIClient.shared.units()) ?? []
|
units = (try? await APIClient.shared.units()) ?? []
|
||||||
locations = (try? await APIClient.shared.locations()) ?? []
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
}
|
}
|
||||||
|
.task(id: query) { await search() }
|
||||||
|
.fullScreenCover(isPresented: $scanShown) { scannerCover }
|
||||||
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
||||||
TextField("z.B. 8076809572569", text: $manualCode)
|
TextField("z.B. 8076809572569", text: $manualCode)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
@@ -86,30 +130,87 @@ struct CheckInView: View {
|
|||||||
manualCode = ""
|
manualCode = ""
|
||||||
Task { await resolve(code) }
|
Task { await resolve(code) }
|
||||||
}
|
}
|
||||||
Button("Abbrechen", role: .cancel) { paused = false }
|
Button("Abbrechen", role: .cancel) { }
|
||||||
}
|
}
|
||||||
.sheet(item: $product) { item in
|
.sheet(item: $product) { item in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
CheckInFormView(product: item, units: units, locations: locations) { message in
|
if item.isIndividual {
|
||||||
status = message
|
// Einzelstücke werden nicht als Charge eingelagert, sondern als
|
||||||
product = nil
|
// physische Exemplare (UID/QR) angelegt – die zählen den Bestand.
|
||||||
paused = false
|
ItemAddSheet(productId: item.id, onDone: {
|
||||||
|
status = "Eingelagert\n\(item.name): Einzelstück(e) angelegt."
|
||||||
|
})
|
||||||
|
} else if item.foodLike {
|
||||||
|
// Lebensmittel + Verbrauchsgegenstand: Charge mit Menge/MHD.
|
||||||
|
CheckInFormView(product: item, units: units, locations: locations) { message in
|
||||||
|
status = message
|
||||||
|
product = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Menge je Lagerort (Gegenstand): ohne MHD, direkt Menge + Lagerort.
|
||||||
|
ObjectAddSheet(product: item, locations: locations,
|
||||||
|
einheit: item.unitName.isEmpty ? "Stück" : item.unitName,
|
||||||
|
perform: { status = "Eingelagert\n\(item.name): Menge hinzugefügt." })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(item: $scannedItem) { it in
|
||||||
|
NavigationStack { ItemEditView(item: it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scanner (nur auf Wunsch)
|
||||||
|
|
||||||
|
private var scannerCover: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScannerView(onCode: { code in Task { await resolve(code) } },
|
||||||
|
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Text("Barcode vor die Kamera halten")
|
||||||
|
.font(.callout)
|
||||||
|
.padding(10)
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.navigationTitle("EAN scannen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Abbrechen") { scanShown = false }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
TorchButton(isOn: $torchOn, locked: $torchLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { paused = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bausteine
|
// MARK: - Bausteine
|
||||||
|
|
||||||
private func banner(_ text: String, color: Color) -> some View {
|
private func banner(_ text: String, color: Color) -> some View {
|
||||||
Text(text)
|
// Erste Zeile = Titel (fett), Rest = Detail – ergibt eine saubere,
|
||||||
.font(.callout)
|
// rundum eingerückte Melde-Pille statt eines randlosen Balkens.
|
||||||
.padding(10)
|
let zeilen = text.components(separatedBy: "\n")
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
.background(color.opacity(0.9))
|
Text(zeilen.first ?? text).font(.headline)
|
||||||
.foregroundStyle(.white)
|
if zeilen.count > 1 {
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
Text(zeilen.dropFirst().joined(separator: "\n")).font(.subheadline)
|
||||||
.padding(.horizontal)
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.background(color, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bestandText(_ p: Product) -> String {
|
||||||
|
let wert = p.stockInArticleUnits
|
||||||
|
let zahl = wert == wert.rounded() ? String(Int(wert)) : String(format: "%.2f", wert)
|
||||||
|
return "\(zahl) \(p.articleUnitLabel)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum.
|
/// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum.
|
||||||
@@ -117,8 +218,8 @@ struct CheckInView: View {
|
|||||||
private func groupNote() -> some View {
|
private func groupNote() -> some View {
|
||||||
if let kategorie = suggestedCategoryName {
|
if let kategorie = suggestedCategoryName {
|
||||||
noteRow(symbol: "square.grid.2x2",
|
noteRow(symbol: "square.grid.2x2",
|
||||||
title: "Kategorie „\(kategorie)“ vorgeschlagen",
|
title: "Wird der Kategorie „\(kategorie)“ zugeordnet",
|
||||||
detail: "Aus der Einordnung bei Open Food Facts.")
|
detail: "Aus der Produkt-Datenbank vorgeschlagen – im Formular änderbar.")
|
||||||
}
|
}
|
||||||
if let name = suggestedGroupName {
|
if let name = suggestedGroupName {
|
||||||
noteRow(symbol: "tag",
|
noteRow(symbol: "tag",
|
||||||
@@ -135,63 +236,73 @@ struct CheckInView: View {
|
|||||||
Text(detail).font(.caption2).foregroundStyle(.secondary)
|
Text(detail).font(.caption2).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(Color.accentColor.opacity(0.15))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func suggestionCard(_ item: LookupResult.Suggestion) -> some View {
|
@ViewBuilder
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
private func suggestionSection(_ item: LookupResult.Suggestion) -> some View {
|
||||||
Text(item.name).font(.headline)
|
Section {
|
||||||
Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · "))
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
Text(item.name).font(.headline)
|
||||||
Text("Bei Open Food Facts gefunden, noch nicht im Katalog.")
|
Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · "))
|
||||||
.font(.caption2).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
groupNote()
|
Text("In einer Produkt-Datenbank gefunden, noch nicht im Katalog.")
|
||||||
NavigationLink {
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId,
|
groupNote()
|
||||||
suggestion: item) { created in
|
NavigationLink {
|
||||||
suggestion = nil
|
ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId,
|
||||||
product = created
|
categoryId: suggestedCategoryId, suggestion: item) { created in
|
||||||
|
suggestion = nil
|
||||||
|
product = created
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Anlegen & einlagern", systemImage: "plus.circle.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
} label: {
|
.buttonStyle(.borderedProminent)
|
||||||
Label("Anlegen & einlagern", systemImage: "plus.circle.fill")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
} header: {
|
||||||
|
Text("Vorschlag zum Scan")
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.background(.thinMaterial)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func unknownCard(_ code: String) -> some View {
|
@ViewBuilder
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
private func unknownSection(_ code: String) -> some View {
|
||||||
Text("Unbekannter Code \(code)").font(.headline)
|
Section {
|
||||||
Text("Weder im Katalog noch bei Open Food Facts.")
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
Text("Unbekannter Code \(code)").font(.headline)
|
||||||
groupNote()
|
Text("Weder im Katalog noch in Open Food / Products Facts.")
|
||||||
NavigationLink {
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
ProductFormView(prefillBarcode: code, groupId: suggestedGroupId) { created in
|
groupNote()
|
||||||
unknownCode = nil
|
NavigationLink {
|
||||||
product = created
|
ProductFormView(prefillBarcode: code, groupId: suggestedGroupId,
|
||||||
|
categoryId: suggestedCategoryId) { created in
|
||||||
|
unknownCode = nil
|
||||||
|
product = created
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Artikel anlegen", systemImage: "plus")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
} label: {
|
.buttonStyle(.borderedProminent)
|
||||||
Label("Artikel anlegen", systemImage: "plus")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
} header: {
|
||||||
|
Text("Zum Scan")
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.background(.thinMaterial)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Logik
|
// MARK: - Logik
|
||||||
|
|
||||||
|
private func search() async {
|
||||||
|
let q = query.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard q.count >= 2 else { results = []; searching = false; return }
|
||||||
|
// Kurze Verzögerung, damit nicht bei jedem Tastendruck abgefragt wird.
|
||||||
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
searching = true
|
||||||
|
defer { searching = false }
|
||||||
|
results = (try? await APIClient.shared.searchProducts(q)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
private func resolve(_ code: String) async {
|
private func resolve(_ code: String) async {
|
||||||
paused = true
|
paused = true
|
||||||
error = nil
|
error = nil
|
||||||
@@ -199,9 +310,21 @@ struct CheckInView: View {
|
|||||||
suggestion = nil
|
suggestion = nil
|
||||||
unknownCode = nil
|
unknownCode = nil
|
||||||
do {
|
do {
|
||||||
|
// Einzelstück-QR (…/i/<UID>) → direkt das Stück öffnen statt Barcode-Suche.
|
||||||
|
if let r = code.range(of: "/i/") {
|
||||||
|
let uid = String(code[r.upperBound...])
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
|
||||||
|
.uppercased()
|
||||||
|
if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) {
|
||||||
|
scannedItem = item
|
||||||
|
scanShown = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
let result = try await APIClient.shared.lookup(barcode: code)
|
let result = try await APIClient.shared.lookup(barcode: code)
|
||||||
suggestedGroupId = result.groupId
|
suggestedGroupId = result.groupId
|
||||||
suggestedGroupName = result.groupName
|
suggestedGroupName = result.groupName
|
||||||
|
suggestedCategoryId = result.categoryId
|
||||||
suggestedCategoryName = result.categoryName
|
suggestedCategoryName = result.categoryName
|
||||||
if let existing = result.existingProduct {
|
if let existing = result.existingProduct {
|
||||||
product = existing
|
product = existing
|
||||||
@@ -210,9 +333,448 @@ struct CheckInView: View {
|
|||||||
} else {
|
} else {
|
||||||
unknownCode = code
|
unknownCode = code
|
||||||
}
|
}
|
||||||
|
scanShown = false
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
paused = false
|
scanShown = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)) } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ struct CheckOutView: View {
|
|||||||
|
|
||||||
@State private var paused = false
|
@State private var paused = false
|
||||||
@State private var torchOn = false
|
@State private var torchOn = false
|
||||||
|
@State private var torchLocked = false
|
||||||
@State private var status: String?
|
@State private var status: String?
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var product: Product?
|
@State private var product: Product?
|
||||||
@@ -18,7 +19,7 @@ struct CheckOutView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
ScannerView(onCode: { code in Task { await resolve(code) } },
|
ScannerView(onCode: { code in Task { await resolve(code) } },
|
||||||
isPaused: $paused, torchOn: $torchOn)
|
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||||||
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -56,7 +57,7 @@ struct CheckOutView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) }
|
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||||||
}
|
}
|
||||||
.task { units = (try? await APIClient.shared.units()) ?? [] }
|
.task { units = (try? await APIClient.shared.units()) ?? [] }
|
||||||
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
||||||
@@ -135,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".
|
||||||
@@ -214,7 +219,7 @@ struct CheckOutFormView: View {
|
|||||||
CheckOutRequest(productId: product.id, quantity: amount, unit: unit, lotId: lotId)
|
CheckOutRequest(productId: product.id, quantity: amount, unit: unit, lotId: lotId)
|
||||||
)
|
)
|
||||||
let total = response.productStock / product.articleUnitFactor
|
let total = response.productStock / product.articleUnitFactor
|
||||||
onDone("Ausgelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
|
onDone("Ausgelagert\nNeuer Bestand: \(format(total)) \(display.articleUnit(product, count: total))")
|
||||||
dismiss()
|
dismiss()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
|
|||||||
410
ios/Sources/DashboardCards.swift
Normal file
410
ios/Sources/DashboardCards.swift
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Ein- und Auslagern ohne Umweg
|
||||||
|
|
||||||
|
/// Buchen direkt aus einer Karte heraus: Artikel suchen, Menge, fertig.
|
||||||
|
///
|
||||||
|
/// MHD und Lagerort bleiben bewusst weg - wer die braucht, ist im Scan-Ablauf
|
||||||
|
/// besser aufgehoben. Hier zaehlt der schnelle Alltagsfall.
|
||||||
|
struct QuickBookingCard: View {
|
||||||
|
enum Richtung { case ein, aus }
|
||||||
|
|
||||||
|
let richtung: Richtung
|
||||||
|
let onChange: () -> Void
|
||||||
|
|
||||||
|
@State private var suche = ""
|
||||||
|
@State private var treffer: [Product] = []
|
||||||
|
@State private var artikel: Product?
|
||||||
|
@State private var menge = "1"
|
||||||
|
@State private var meldung: String?
|
||||||
|
@State private var fehler: String?
|
||||||
|
@State private var laeuft = false
|
||||||
|
|
||||||
|
private var einlagern: Bool { richtung == .ein }
|
||||||
|
private var zahl: Double? {
|
||||||
|
Double(menge.replacingOccurrences(of: ",", with: "."))
|
||||||
|
}
|
||||||
|
private var bereit: Bool {
|
||||||
|
artikel != nil && (zahl ?? 0) > 0 && !laeuft
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
if let artikel {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(artikel.name).font(.subheadline).bold()
|
||||||
|
Text("\(formatAmount(artikel.stockInArticleUnits)) \(artikel.articleUnitLabel) im Bestand")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
self.artikel = nil
|
||||||
|
suche = ""
|
||||||
|
meldung = nil
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TextField("Artikel suchen…", text: $suche)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.onChange(of: suche) { _ in Task { await suchen() } }
|
||||||
|
ForEach(treffer.prefix(4)) { produkt in
|
||||||
|
Button {
|
||||||
|
artikel = produkt
|
||||||
|
treffer = []
|
||||||
|
meldung = nil
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(produkt.name).font(.callout)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "plus.circle").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
TextField("1", text: $menge)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.frame(width: 80)
|
||||||
|
Button {
|
||||||
|
Task { await buchen() }
|
||||||
|
} label: {
|
||||||
|
Label(einlagern ? "Einlagern" : "Auslagern",
|
||||||
|
systemImage: einlagern ? "arrow.down.to.line" : "arrow.up.to.line")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(einlagern ? Color.marke : .orange)
|
||||||
|
.disabled(!bereit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let meldung {
|
||||||
|
Text(meldung).font(.caption).foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
if let fehler {
|
||||||
|
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func suchen() async {
|
||||||
|
let text = suche.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard text.count >= 2 else { treffer = []; return }
|
||||||
|
treffer = (try? await APIClient.shared.searchProducts(text)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buchen() async {
|
||||||
|
guard let artikel, let wert = zahl else { return }
|
||||||
|
laeuft = true
|
||||||
|
defer { laeuft = false }
|
||||||
|
fehler = nil
|
||||||
|
meldung = nil
|
||||||
|
do {
|
||||||
|
// Menge in Artikeleinheiten: Gebinde -> Packung, sonst Basiseinheit.
|
||||||
|
// Der Server kennt kein "article".
|
||||||
|
let unit = (artikel.packageSize ?? 0) > 0 ? "package" : artikel.baseUnit
|
||||||
|
if einlagern {
|
||||||
|
let zeile = CheckInLine(quantity: wert, bestBefore: nil,
|
||||||
|
bestBeforePrecision: "day", locationId: nil)
|
||||||
|
_ = try await APIClient.shared.checkInBatch(
|
||||||
|
BatchCheckInRequest(productId: artikel.id, unit: unit, lines: [zeile]))
|
||||||
|
} else {
|
||||||
|
_ = try await APIClient.shared.checkOut(
|
||||||
|
CheckOutRequest(productId: artikel.id, quantity: wert,
|
||||||
|
unit: unit, lotId: nil))
|
||||||
|
}
|
||||||
|
meldung = "\(formatAmount(wert)) \(einlagern ? "eingelagert" : "ausgelagert")."
|
||||||
|
menge = "1"
|
||||||
|
// Bestand in der Karte nachziehen, sonst steht dort die alte Zahl.
|
||||||
|
self.artikel = try? await APIClient.shared.product(id: artikel.id)
|
||||||
|
onChange()
|
||||||
|
} catch {
|
||||||
|
self.fehler = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schnellzugriff
|
||||||
|
|
||||||
|
struct ActionsCard: View {
|
||||||
|
@EnvironmentObject private var router: Router
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button { router.route = .checkin } label: {
|
||||||
|
Label("Einlagern", systemImage: "arrow.down.to.line")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
Button { router.route = .checkout } label: {
|
||||||
|
Label("Auslagern", systemImage: "arrow.up.to.line")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kennzahlen
|
||||||
|
|
||||||
|
struct StatusCard: View {
|
||||||
|
let art: String
|
||||||
|
let stand: Int
|
||||||
|
|
||||||
|
@State private var stats: DashboardStats?
|
||||||
|
@State private var fehler: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let fehler {
|
||||||
|
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||||
|
} else if let stats {
|
||||||
|
switch art {
|
||||||
|
case "kpi-products":
|
||||||
|
Kennzahl(wert: String(stats.productsInStock), label: "Artikel mit Bestand",
|
||||||
|
detail: "von \(stats.productsTotal)", tint: .marke)
|
||||||
|
case "kpi-units":
|
||||||
|
Kennzahl(wert: formatAmount(stats.articleUnits), label: "Artikeleinheiten",
|
||||||
|
detail: "", tint: .marke)
|
||||||
|
default:
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Kennzahl(wert: String(stats.expiringSoon), label: "Bald ablaufend",
|
||||||
|
detail: "", tint: stats.expiringSoon > 0 ? .orange : .secondary)
|
||||||
|
Kennzahl(wert: String(stats.expired), label: "Abgelaufen",
|
||||||
|
detail: "", tint: stats.expired > 0 ? .red : .secondary)
|
||||||
|
Kennzahl(wert: String(stats.shoppingItems), label: "Einzukaufen",
|
||||||
|
detail: "", tint: stats.shoppingItems > 0 ? .orange : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Lädt…").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: stand) {
|
||||||
|
do {
|
||||||
|
stats = try await APIClient.shared.dashboardStats()
|
||||||
|
fehler = nil
|
||||||
|
} catch {
|
||||||
|
self.fehler = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Kennzahl: View {
|
||||||
|
let wert: String
|
||||||
|
let label: String
|
||||||
|
let detail: String
|
||||||
|
let tint: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label).font(.caption2).foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1).minimumScaleFactor(0.8)
|
||||||
|
Text(wert).font(.title2).bold().foregroundStyle(tint)
|
||||||
|
if !detail.isEmpty {
|
||||||
|
Text(detail).font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Listen
|
||||||
|
|
||||||
|
struct ShoppingCard: View {
|
||||||
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
let stand: Int
|
||||||
|
|
||||||
|
@State private var items: [ShoppingItem] = []
|
||||||
|
@State private var fehler: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let fehler {
|
||||||
|
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||||
|
} else if items.isEmpty {
|
||||||
|
Text("Alle Mindestbestände sind gedeckt.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ForEach(items.prefix(5)) { item in
|
||||||
|
HStack {
|
||||||
|
Text(item.name).font(.callout)
|
||||||
|
Spacer()
|
||||||
|
Text("fehlt \(display.amountText(item.deficit, packageSize: item.packageSize, baseUnit: item.baseUnit))")
|
||||||
|
.font(.caption).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NavigationLink("Ganze Liste") { ShoppingListView() }
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: stand) {
|
||||||
|
do {
|
||||||
|
items = try await APIClient.shared.shoppingList()
|
||||||
|
fehler = nil
|
||||||
|
} catch {
|
||||||
|
self.fehler = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExpiryCard: View {
|
||||||
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
let modus: String
|
||||||
|
let stand: Int
|
||||||
|
|
||||||
|
@State private var items: [ExpiringItem] = []
|
||||||
|
@State private var fehler: String?
|
||||||
|
|
||||||
|
/// "expiring" zeigt nur Laufendes, "expired" nur Ueberfaelliges,
|
||||||
|
/// "expiry-all" beides - wie in der Web-Oberflaeche.
|
||||||
|
private var gefiltert: [ExpiringItem] {
|
||||||
|
switch modus {
|
||||||
|
case "expiring": return items.filter { $0.daysLeft >= 0 }
|
||||||
|
case "expired": return items.filter { $0.daysLeft < 0 }
|
||||||
|
default: return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let fehler {
|
||||||
|
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||||
|
} else if gefiltert.isEmpty {
|
||||||
|
Text("Nichts läuft demnächst ab.").font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ForEach(gefiltert.prefix(5)) { item in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(item.productName).font(.callout)
|
||||||
|
Text("MHD \(display.formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision))")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(item.restlaufzeitText)
|
||||||
|
.font(.caption).bold()
|
||||||
|
.foregroundStyle(item.daysLeft < 0 ? .red : .orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NavigationLink("Ganze Liste") { ExpiringView() }.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: stand) {
|
||||||
|
do {
|
||||||
|
items = try await APIClient.shared.expiring()
|
||||||
|
fehler = nil
|
||||||
|
} catch {
|
||||||
|
self.fehler = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MovementsCard: View {
|
||||||
|
let stand: Int
|
||||||
|
|
||||||
|
@State private var movements: [Movement] = []
|
||||||
|
@State private var fehler: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let fehler {
|
||||||
|
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||||
|
} else if movements.isEmpty {
|
||||||
|
Text("Noch keine Bewegungen.").font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(movements.prefix(5)) { movement in
|
||||||
|
// Tippen auf eine Bewegung öffnet das jeweilige Produkt.
|
||||||
|
NavigationLink {
|
||||||
|
ProductByIdView(productId: movement.productId)
|
||||||
|
} label: {
|
||||||
|
MovementRow(movement: movement)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
NavigationLink("Ganzer Verlauf") { HistoryView() }.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: stand) {
|
||||||
|
do {
|
||||||
|
movements = try await APIClient.shared.movements(limit: 5)
|
||||||
|
fehler = nil
|
||||||
|
} catch {
|
||||||
|
self.fehler = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ein fester Artikel
|
||||||
|
|
||||||
|
struct ProductCard: View {
|
||||||
|
let productId: Int?
|
||||||
|
let stand: Int
|
||||||
|
|
||||||
|
@State private var produkt: Product?
|
||||||
|
@State private var fehler: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if productId == nil {
|
||||||
|
Text("Kein Artikel gewählt – einstellen in der Web-Oberfläche.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else if let fehler {
|
||||||
|
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||||
|
} else if let produkt {
|
||||||
|
NavigationLink {
|
||||||
|
ProductDetailView(product: produkt)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(produkt.name).font(.callout)
|
||||||
|
if let hinweis = mindestbestand(produkt) {
|
||||||
|
Text(hinweis).font(.caption2).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("\(formatAmount(produkt.stockInArticleUnits)) \(produkt.articleUnitLabel)")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Lädt…").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: "\(productId ?? 0)-\(stand)") {
|
||||||
|
guard let productId else { return }
|
||||||
|
do {
|
||||||
|
produkt = try await APIClient.shared.product(id: productId)
|
||||||
|
fehler = nil
|
||||||
|
} catch {
|
||||||
|
self.fehler = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mindestbestand(_ produkt: Product) -> String? {
|
||||||
|
switch produkt.stockLevel {
|
||||||
|
case .below: return "unter Mindestbestand"
|
||||||
|
case .close: return "bald nachkaufen"
|
||||||
|
case .ok, .none: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
216
ios/Sources/DashboardView.swift
Normal file
216
ios/Sources/DashboardView.swift
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Der Start-Tab. Je Server wird entweder die Web-Übersicht gespiegelt oder eine
|
||||||
|
/// eigene, lokale App-Übersicht gezeigt - umschaltbar über das Menü oben.
|
||||||
|
struct DashboardView: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
|
||||||
|
@State private var mode: OverviewMode = .mirror
|
||||||
|
@State private var manageShown = false
|
||||||
|
|
||||||
|
private var profileID: UUID? { session.activeProfileID }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if mode == .local, let profileID {
|
||||||
|
LocalOverview(profileID: profileID)
|
||||||
|
} else {
|
||||||
|
MirrorOverview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(session.activeProfile?.name ?? "Übersicht")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Menu {
|
||||||
|
Picker("Übersicht", selection: Binding(
|
||||||
|
get: { mode },
|
||||||
|
set: { neu in setzeModus(neu) }
|
||||||
|
)) {
|
||||||
|
Label("Web-Übersicht spiegeln", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
.tag(OverviewMode.mirror)
|
||||||
|
Label("Eigene App-Übersicht", systemImage: "square.grid.2x2")
|
||||||
|
.tag(OverviewMode.local)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "rectangle.3.group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
ServerMenu(manageShown: $manageShown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $manageShown) { ServerListView() }
|
||||||
|
}
|
||||||
|
.task(id: profileID) { ladeModus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ladeModus() {
|
||||||
|
guard let profileID else { mode = .mirror; return }
|
||||||
|
mode = AppDashboardStore.config(for: profileID).mode
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setzeModus(_ neu: OverviewMode) {
|
||||||
|
guard let profileID else { return }
|
||||||
|
var config = AppDashboardStore.config(for: profileID)
|
||||||
|
// Beim ersten Umschalten auf „eigen" gleich eine sinnvolle Übersicht anlegen.
|
||||||
|
if neu == .local && config.dashboards.isEmpty {
|
||||||
|
config.dashboards = [AppDashboard.standard()]
|
||||||
|
}
|
||||||
|
config.mode = neu
|
||||||
|
AppDashboardStore.save(config, for: profileID)
|
||||||
|
mode = neu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Web-Übersicht spiegeln
|
||||||
|
|
||||||
|
/// Zeigt die Dashboards der Web-Oberfläche (schreibgeschützt). Das Raster
|
||||||
|
/// (x/y/w/h) lässt sich auf dem Telefon nicht nachbilden - die Karten stehen
|
||||||
|
/// deshalb untereinander, in der Lesereihenfolge des Rasters.
|
||||||
|
struct MirrorOverview: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
|
||||||
|
@State private var dashboards: [Dashboard] = []
|
||||||
|
@State private var auswahl: Int?
|
||||||
|
@State private var busy = true
|
||||||
|
@State private var fehler: String?
|
||||||
|
@State private var stand = 0
|
||||||
|
|
||||||
|
private var aktives: Dashboard? {
|
||||||
|
dashboards.first { $0.id == auswahl } ?? dashboards.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private var karten: [DashboardCard] {
|
||||||
|
(aktives?.layout ?? []).sorted { $0.y == $1.y ? $0.x < $1.x : $0.y < $1.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if let fehler {
|
||||||
|
Section { Text(fehler).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
if dashboards.count > 1 {
|
||||||
|
Section {
|
||||||
|
Picker("Dashboard", selection: Binding(
|
||||||
|
get: { aktives?.id ?? 0 }, set: { auswahl = $0 }
|
||||||
|
)) {
|
||||||
|
ForEach(dashboards) { Text($0.name).tag($0.id) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(karten) { karte in
|
||||||
|
Section {
|
||||||
|
DashboardCardView(type: karte.type, productId: karte.props?.productId,
|
||||||
|
stand: stand,
|
||||||
|
detail: karte.props?.detail ?? false,
|
||||||
|
tiefe: karte.props?.tiefe.flatMap { Int($0) },
|
||||||
|
mitOhneMhd: karte.props?.mitOhneMhd ?? true) { stand += 1 }
|
||||||
|
} header: {
|
||||||
|
Text(CardCatalog.titel(karte.type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if karten.isEmpty && !busy {
|
||||||
|
Text("Dieses Dashboard hat keine Karten. Zusammenstellen lässt es sich in der Web-Oberfläche.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await laden() }
|
||||||
|
.task(id: session.activeProfileID) { await laden() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func laden() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
let antwort = try await APIClient.shared.dashboards()
|
||||||
|
dashboards = antwort.dashboards.sorted { $0.position < $1.position }
|
||||||
|
if auswahl == nil || !dashboards.contains(where: { $0.id == auswahl }) {
|
||||||
|
auswahl = dashboards.first?.id
|
||||||
|
}
|
||||||
|
fehler = nil
|
||||||
|
} catch {
|
||||||
|
self.fehler = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beschriftungen der Kartenarten - dieselben wie in web/src/dashboard/cards.jsx.
|
||||||
|
enum CardCatalog {
|
||||||
|
static func titel(_ type: String) -> String {
|
||||||
|
switch type {
|
||||||
|
case "actions": return "Schnellzugriff"
|
||||||
|
case "checkin-quick": return "Einlagern"
|
||||||
|
case "checkout-quick": return "Auslagern"
|
||||||
|
case "status": return "Status"
|
||||||
|
case "kpi-products": return "Artikel mit Bestand"
|
||||||
|
case "kpi-units": return "Artikeleinheiten"
|
||||||
|
case "expiring": return "Bald ablaufend"
|
||||||
|
case "expired": return "Abgelaufen"
|
||||||
|
case "expiry-all": return "Ablauf-Übersicht"
|
||||||
|
case "shopping": return "Einkaufsliste"
|
||||||
|
case "movements": return "Letzte Bewegungen"
|
||||||
|
case "product-stock": return "Artikel im Blick"
|
||||||
|
case "product-timeline": return "Verlauf eines Artikels"
|
||||||
|
case "expiry-donut": return "Ablauf-Anteile"
|
||||||
|
case "category-donut": return "Kategorien-Anteile"
|
||||||
|
case "category-bars": return "Kategorien nach Zustand"
|
||||||
|
case "stock-timeline": return "Bestandsverlauf"
|
||||||
|
case "activity-timeline": return "Ein- und Auslagerungen"
|
||||||
|
default: return "Karte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Karten, die nur als Diagramm Sinn ergeben. Die App zeigt statt eines
|
||||||
|
/// halbgaren Nachbaus einen ehrlichen Hinweis.
|
||||||
|
static func istDiagramm(_ type: String) -> Bool {
|
||||||
|
["expiry-donut", "category-donut", "category-bars",
|
||||||
|
"stock-timeline", "activity-timeline"].contains(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waehlt anhand der Kartenart den passenden Inhalt. Wird sowohl beim Spiegeln
|
||||||
|
/// der Web-Dashboards als auch in der eigenen App-Uebersicht verwendet - daher
|
||||||
|
/// nur ueber Art und (fuer Artikelkarten) Produkt-Id parametrisiert.
|
||||||
|
struct DashboardCardView: View {
|
||||||
|
let type: String
|
||||||
|
let productId: 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
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch type {
|
||||||
|
case "checkin-quick":
|
||||||
|
QuickBookingCard(richtung: .ein, onChange: onChange)
|
||||||
|
case "checkout-quick":
|
||||||
|
QuickBookingCard(richtung: .aus, onChange: onChange)
|
||||||
|
case "actions":
|
||||||
|
ActionsCard()
|
||||||
|
case "status", "kpi-products", "kpi-units":
|
||||||
|
StatusCard(art: type, stand: stand)
|
||||||
|
case "shopping":
|
||||||
|
ShoppingCard(stand: stand)
|
||||||
|
case "expiring", "expired", "expiry-all":
|
||||||
|
ExpiryCard(modus: type, stand: stand)
|
||||||
|
case "movements":
|
||||||
|
MovementsCard(stand: stand)
|
||||||
|
case "product-stock":
|
||||||
|
ProductCard(productId: productId, stand: stand)
|
||||||
|
case "product-timeline":
|
||||||
|
ProductTimelineChart(productId: productId, stand: stand)
|
||||||
|
case "expiry-donut", "category-donut", "category-bars",
|
||||||
|
"stock-timeline", "activity-timeline":
|
||||||
|
ChartCardView(type: type, stand: stand,
|
||||||
|
detail: detail, tiefe: tiefe, mitOhneMhd: mitOhneMhd)
|
||||||
|
default:
|
||||||
|
Text("Unbekannte Karte „\(type)“.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,139 @@ struct DateScanView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Liest eine Textzeile (Name/Marke) per Texterkennung ein – die erkannten
|
||||||
|
/// Zeilen erscheinen zum Antippen, wie beim MHD-Scan. Nützlich, wenn ein Produkt
|
||||||
|
/// nicht in Open Food/Products Facts steht und man nicht abtippen will.
|
||||||
|
struct TextScanView: View {
|
||||||
|
let titel: String
|
||||||
|
var onPick: (String) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var zeilen: [String] = []
|
||||||
|
|
||||||
|
static var isSupported: Bool {
|
||||||
|
DataScannerViewController.isSupported && DataScannerViewController.isAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
if Self.isSupported {
|
||||||
|
TextLinesScannerRepresentable { erkannt in
|
||||||
|
// Ein erkannter Textblock kann mehrere Zeilen umfassen – strikt
|
||||||
|
// in einzelne Zeilen zerlegen, damit jede Auswahl genau eine ist.
|
||||||
|
let einzeln = erkannt.flatMap { $0.components(separatedBy: .newlines) }
|
||||||
|
for t in einzeln.reversed() {
|
||||||
|
let sauber = t.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard sauber.count >= 2, !zeilen.contains(sauber) else { continue }
|
||||||
|
zeilen.insert(sauber, at: 0)
|
||||||
|
}
|
||||||
|
if zeilen.count > 8 { zeilen = Array(zeilen.prefix(8)) }
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
} else {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
Text("Dieses Gerät unterstützt keine Texterkennung.")
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if zeilen.isEmpty {
|
||||||
|
hinweis("Text in den Sucher halten und die passende Zeile antippen.")
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(zeilen, id: \.self) { zeile in
|
||||||
|
Button {
|
||||||
|
onPick(zeile)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(zeile).font(.headline).lineLimit(2)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Image(systemName: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.thinMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle(titel)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hinweis(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(10)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie TextScannerRepresentable, meldet aber die einzelnen erkannten Zeilen
|
||||||
|
/// (statt sie zu einem String zu verketten) – für die Zeilenauswahl.
|
||||||
|
private struct TextLinesScannerRepresentable: UIViewControllerRepresentable {
|
||||||
|
var onLines: ([String]) -> Void
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(onLines: onLines) }
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> DataScannerViewController {
|
||||||
|
let controller = DataScannerViewController(
|
||||||
|
recognizedDataTypes: [.text()],
|
||||||
|
qualityLevel: .accurate,
|
||||||
|
recognizesMultipleItems: true,
|
||||||
|
isHighFrameRateTrackingEnabled: false,
|
||||||
|
isHighlightingEnabled: true
|
||||||
|
)
|
||||||
|
controller.delegate = context.coordinator
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ controller: DataScannerViewController, context: Context) {
|
||||||
|
guard !controller.isScanning else { return }
|
||||||
|
try? controller.startScanning()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dismantleUIViewController(_ controller: DataScannerViewController, coordinator: Coordinator) {
|
||||||
|
controller.stopScanning()
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
||||||
|
let onLines: ([String]) -> Void
|
||||||
|
init(onLines: @escaping ([String]) -> Void) { self.onLines = onLines }
|
||||||
|
|
||||||
|
func dataScanner(_ scanner: DataScannerViewController,
|
||||||
|
didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
|
||||||
|
handle(allItems)
|
||||||
|
}
|
||||||
|
func dataScanner(_ scanner: DataScannerViewController,
|
||||||
|
didUpdate updatedItems: [RecognizedItem], allItems: [RecognizedItem]) {
|
||||||
|
handle(allItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(_ items: [RecognizedItem]) {
|
||||||
|
let lines = items.compactMap { item -> String? in
|
||||||
|
if case .text(let erkannt) = item { return erkannt.transcript }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard !lines.isEmpty else { return }
|
||||||
|
onLines(lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Duenne Huelle um den Text-Scanner von VisionKit.
|
/// Duenne Huelle um den Text-Scanner von VisionKit.
|
||||||
private struct TextScannerRepresentable: UIViewControllerRepresentable {
|
private struct TextScannerRepresentable: UIViewControllerRepresentable {
|
||||||
var onText: (String) -> Void
|
var onText: (String) -> Void
|
||||||
|
|||||||
@@ -13,18 +13,39 @@ final class DisplaySettings: ObservableObject {
|
|||||||
static let fallbackFormat = "de"
|
static let fallbackFormat = "de"
|
||||||
|
|
||||||
@Published private(set) var dateFormat = DisplaySettings.fallbackFormat
|
@Published private(set) var dateFormat = DisplaySettings.fallbackFormat
|
||||||
|
/// Gebinde-Mehrzahlformen (Einzahl -> Mehrzahl), z.B. „Packung" -> „Packungen".
|
||||||
|
@Published private(set) var packagePlural: [String: String] = ["Packung": "Packungen"]
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
/// Nach der Anmeldung aufrufen. Schlaegt der Abruf fehl, bleibt es beim
|
/// Nach der Anmeldung aufrufen. Schlaegt ein Abruf fehl, bleibt es beim
|
||||||
/// deutschen Standard - an einer fehlenden Einstellung darf die Anzeige
|
/// deutschen Standard - an einer fehlenden Einstellung darf die Anzeige
|
||||||
/// nicht scheitern.
|
/// nicht scheitern.
|
||||||
func load() async {
|
func load() async {
|
||||||
guard let entries = try? await APIClient.shared.settings() else { return }
|
if let entries = try? await APIClient.shared.settings(),
|
||||||
if let entry = entries.first(where: { $0.key == DisplaySettings.dateFormatKey }),
|
let entry = entries.first(where: { $0.key == DisplaySettings.dateFormatKey }),
|
||||||
!entry.value.isEmpty {
|
!entry.value.isEmpty {
|
||||||
dateFormat = entry.value
|
dateFormat = entry.value
|
||||||
}
|
}
|
||||||
|
if let typen = try? await APIClient.shared.packageTypes() {
|
||||||
|
var map = ["Packung": "Packungen"]
|
||||||
|
for t in typen where !t.singular.isEmpty { map[t.singular] = t.plural }
|
||||||
|
packagePlural = map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Gebinde in der passenden Zahlform: „1 Packung", aber „2 Packungen".
|
||||||
|
func gebinde(_ count: Double, _ singular: String) -> String {
|
||||||
|
count == 1 ? singular : (packagePlural[singular] ?? singular)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artikeleinheit mit Mehrzahl: bei Packungen die Gebinde-Mehrzahl, sonst die
|
||||||
|
/// Basiseinheit (Gramm/Milliliter/Stück – im Deutschen ohne Mehrzahl).
|
||||||
|
func articleUnit(_ product: Product, count: Double) -> String {
|
||||||
|
if let size = product.packageSize, size > 0 {
|
||||||
|
return gebinde(count, product.packageLabel ?? "Packung")
|
||||||
|
}
|
||||||
|
return product.articleUnitLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Datum
|
// MARK: - Datum
|
||||||
@@ -60,6 +81,60 @@ final class DisplaySettings: ObservableObject {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// MARK: - Zeitstempel
|
||||||
|
|
||||||
|
/// Zeitpunkte der API ("2026-07-23T16:20:59.510258").
|
||||||
|
///
|
||||||
|
/// Der Server erzeugt sie mit `datetime.now(timezone.utc)`, haengt an die
|
||||||
|
/// Antwort aber keine Zeitzone an, solange SQLite dahinter steht - Postgres
|
||||||
|
/// liefert dagegen ein Offset mit. Deshalb erst der Versuch mit Offset,
|
||||||
|
/// danach die nackte Form, die **als UTC** gelesen wird. Ohne das zeigte die
|
||||||
|
/// App die Uhrzeit um die eigene Zeitverschiebung daneben.
|
||||||
|
static func parseTimestamp(_ raw: String) -> Date? {
|
||||||
|
for optionen: ISO8601DateFormatter.Options in [
|
||||||
|
[.withInternetDateTime, .withFractionalSeconds], [.withInternetDateTime],
|
||||||
|
] {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = optionen
|
||||||
|
if let date = formatter.date(from: raw) { return date }
|
||||||
|
}
|
||||||
|
for muster in ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ss"] {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.timeZone = TimeZone(identifier: "UTC")
|
||||||
|
formatter.dateFormat = muster
|
||||||
|
if let date = formatter.date(from: raw) { return date }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uhrzeit einer Bewegung ("17:22").
|
||||||
|
func formatTime(_ raw: String) -> String {
|
||||||
|
guard let date = DisplaySettings.parseTimestamp(raw) else { return "" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "de_DE")
|
||||||
|
formatter.dateFormat = "HH:mm"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ueberschrift einer Tagesgruppe. "Heute" und "Gestern" sind schneller zu
|
||||||
|
/// erfassen als ein Datum.
|
||||||
|
func formatDay(_ raw: String) -> String {
|
||||||
|
guard let date = DisplaySettings.parseTimestamp(raw) else { return raw }
|
||||||
|
let kalender = Calendar.current
|
||||||
|
if kalender.isDateInToday(date) { return "Heute" }
|
||||||
|
if kalender.isDateInYesterday(date) { return "Gestern" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "de_DE")
|
||||||
|
formatter.dateFormat = DisplaySettings.pattern(for: dateFormat)
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tagesschluessel zum Gruppieren - unabhaengig von der Schreibweise.
|
||||||
|
static func dayKey(_ raw: String) -> String {
|
||||||
|
String(raw.prefix(10))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Einheiten
|
// MARK: - Einheiten
|
||||||
|
|
||||||
/// Kanonische Basiseinheit der API auf Deutsch.
|
/// Kanonische Basiseinheit der API auf Deutsch.
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
131
ios/Sources/HistoryView.swift
Normal file
131
ios/Sources/HistoryView.swift
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Bewegungsverlauf: wer hat wann was ein- und ausgelagert.
|
||||||
|
///
|
||||||
|
/// Dieselbe Ansicht dient dem Gesamtverlauf und dem Verlauf eines einzelnen
|
||||||
|
/// Artikels - der Unterschied ist nur `productId`.
|
||||||
|
struct HistoryView: View {
|
||||||
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
|
||||||
|
var productId: Int?
|
||||||
|
|
||||||
|
@State private var movements: [Movement] = []
|
||||||
|
@State private var limit = 100
|
||||||
|
@State private var busy = true
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(tage, id: \.key) { tag in
|
||||||
|
Section(display.formatDay(tag.eintraege[0].createdAt)) {
|
||||||
|
ForEach(tag.eintraege) { movement in
|
||||||
|
// Im Gesamtverlauf führt jede Zeile zum Produkt; im
|
||||||
|
// Artikel-eigenen Verlauf ist man schon dort (kein Link).
|
||||||
|
if productId == nil {
|
||||||
|
NavigationLink {
|
||||||
|
ProductByIdView(productId: movement.productId)
|
||||||
|
} label: {
|
||||||
|
MovementRow(movement: movement, showProduct: true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MovementRow(movement: movement, showProduct: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if movements.isEmpty && !busy {
|
||||||
|
Text("Noch keine Bewegungen.").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nachladen erst anbieten, wenn die aktuelle Menge auch voll ist.
|
||||||
|
if movements.count >= limit {
|
||||||
|
Button("Mehr laden") {
|
||||||
|
limit += 100
|
||||||
|
Task { await load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Verlauf")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.refreshable { await load() }
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Tag {
|
||||||
|
let key: String
|
||||||
|
let eintraege: [Movement]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nach Tagen gruppiert, neueste zuerst. Die API liefert bereits sortiert,
|
||||||
|
/// deshalb reicht es, der Reihe nach zu sammeln.
|
||||||
|
private var tage: [Tag] {
|
||||||
|
var result: [Tag] = []
|
||||||
|
for movement in movements {
|
||||||
|
let key = DisplaySettings.dayKey(movement.createdAt)
|
||||||
|
if let last = result.last, last.key == key {
|
||||||
|
result[result.count - 1] = Tag(key: key, eintraege: last.eintraege + [movement])
|
||||||
|
} else {
|
||||||
|
result.append(Tag(key: key, eintraege: [movement]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
movements = try await APIClient.shared.movements(limit: limit, productId: productId)
|
||||||
|
error = nil
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MovementRow: View {
|
||||||
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
|
||||||
|
let movement: Movement
|
||||||
|
var showProduct = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: movement.isIncoming ? "arrow.down.circle.fill" : "arrow.up.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(movement.isIncoming ? Color.green : Color.orange)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if showProduct {
|
||||||
|
Text(movement.productName)
|
||||||
|
}
|
||||||
|
Text(menge)
|
||||||
|
.font(showProduct ? .caption : .body)
|
||||||
|
.foregroundStyle(showProduct ? .secondary : .primary)
|
||||||
|
if let note = movement.note, !note.isEmpty {
|
||||||
|
Text(note).font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(display.formatTime(movement.createdAt))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
if let user = movement.username, !user.isEmpty {
|
||||||
|
Text(user).font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vorzeichen macht die Richtung auch ohne Farbe lesbar.
|
||||||
|
private var menge: String {
|
||||||
|
let zeichen = movement.isIncoming ? "+" : "−"
|
||||||
|
return zeichen + display.amountText(movement.quantity,
|
||||||
|
packageSize: movement.packageSize,
|
||||||
|
baseUnit: movement.baseUnit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,16 +28,30 @@
|
|||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<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
|
||||||
|
Systems (HTTPS ueber Apples Betriebssystem) und faellt damit unter die
|
||||||
|
Ausnahme. Mit diesem Schluessel entfaellt die "App Encryption
|
||||||
|
Documentation"-Rueckfrage bei jedem Upload nach App Store Connect. -->
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
|
|
||||||
<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>
|
||||||
@@ -58,6 +72,17 @@
|
|||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
||||||
|
<!-- Hintergrund-Aktualisierung: die App frischt gelegentlich den Plan der
|
||||||
|
Ablauf-Benachrichtigungen auf. Zeitpunkt bestimmt iOS. -->
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
</array>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.scarriffle.vorrania.refresh</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
<!-- Schnellaktionen beim langen Druck auf das App-Symbol -->
|
<!-- Schnellaktionen beim langen Druck auf das App-Symbol -->
|
||||||
<key>UIApplicationShortcutItems</key>
|
<key>UIApplicationShortcutItems</key>
|
||||||
<array>
|
<array>
|
||||||
|
|||||||
182
ios/Sources/ItemListView.swift
Normal file
182
ios/Sources/ItemListView.swift
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Liste aller Einzelstücke (physische Exemplare) über alle Produkte – anders als
|
||||||
|
/// die Produktliste, die nur die Katalog-Typen zeigt. Such- und Filterfunktion,
|
||||||
|
/// v.a. nach Lagerort. Tippen öffnet das Einzelstück.
|
||||||
|
struct ItemListView: View {
|
||||||
|
@State private var items: [Item] = []
|
||||||
|
@State private var locations: [StorageLocation] = []
|
||||||
|
@State private var categories: [CategoryItem] = []
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var locationFilter: String? // Lagerort-ID
|
||||||
|
@State private var categoryFilter: Int? // Kategorie-ID (Unterkategorien zählen mit)
|
||||||
|
@State private var busy = true
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
private struct TreeEntry: Identifiable {
|
||||||
|
let item: CategoryItem
|
||||||
|
let depth: Int
|
||||||
|
var id: Int { item.id }
|
||||||
|
var label: String { String(repeating: " ", count: depth) + item.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var catById: [Int: CategoryItem] {
|
||||||
|
Dictionary(categories.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ist `catId` die Kategorie `target` oder eine ihrer Unterkategorien?
|
||||||
|
private func imTeilbaum(_ catId: Int?, target: Int) -> Bool {
|
||||||
|
let map = catById
|
||||||
|
var cur = catId
|
||||||
|
var g = 0
|
||||||
|
while let c = cur, g < 50 {
|
||||||
|
if c == target { return true }
|
||||||
|
cur = map[c]?.parentId
|
||||||
|
g += 1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kategorien fürs Filter-Menü: alle, die selbst oder als Oberkategorie zu einem
|
||||||
|
/// vorhandenen Einzelstück gehören – so ist z.B. auch „Klamotten" wählbar. Baum
|
||||||
|
/// mit eingerückter Beschriftung (hierarchisch).
|
||||||
|
private var filterKategorien: [TreeEntry] {
|
||||||
|
let map = catById
|
||||||
|
var relevant = Set<Int>()
|
||||||
|
for it in items {
|
||||||
|
var cur = it.categoryId
|
||||||
|
var g = 0
|
||||||
|
while let c = cur, !relevant.contains(c), g < 50 {
|
||||||
|
relevant.insert(c)
|
||||||
|
cur = map[c]?.parentId
|
||||||
|
g += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let liste = categories.filter { relevant.contains($0.id) }
|
||||||
|
let ids = Set(liste.map(\.id))
|
||||||
|
var result: [TreeEntry] = []
|
||||||
|
func walk(_ node: CategoryItem, _ depth: Int) {
|
||||||
|
result.append(TreeEntry(item: node, depth: depth))
|
||||||
|
for child in liste.filter({ $0.parentId == node.id }) { walk(child, depth + 1) }
|
||||||
|
}
|
||||||
|
for root in liste.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) { walk(root, 0) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filterKategorieName: String {
|
||||||
|
if let id = categoryFilter, let t = categories.first(where: { $0.id == id }) { return t.name }
|
||||||
|
return "Alle"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gefiltert: [Item] {
|
||||||
|
let q = query.trimmingCharacters(in: .whitespaces).lowercased()
|
||||||
|
return items.filter { it in
|
||||||
|
if let cat = categoryFilter, !imTeilbaum(it.categoryId, target: cat) { return false }
|
||||||
|
if let loc = locationFilter, it.locationId != loc { return false }
|
||||||
|
if !q.isEmpty {
|
||||||
|
let heu = [it.productName, it.uid, it.note, it.productBrand]
|
||||||
|
.compactMap { $0 }.joined(separator: " ").lowercased()
|
||||||
|
if !heu.contains(q) { return false }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hatFilter: Bool { locationFilter != nil || categoryFilter != nil }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if !busy && gefiltert.isEmpty {
|
||||||
|
Text(items.isEmpty ? "Noch keine Einzelstücke." : "Nichts gefunden.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach(gefiltert) { it in
|
||||||
|
NavigationLink { ItemEditView(item: it) } label: {
|
||||||
|
ItemRow(item: it, locations: locations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $query, prompt: "Produkt, UID oder Notiz")
|
||||||
|
.navigationTitle("Einzelstücke")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .topBarTrailing) { filterMenu } }
|
||||||
|
.overlay { if busy && items.isEmpty { ProgressView() } }
|
||||||
|
.refreshable { await load() }
|
||||||
|
// Auch beim Zurückkommen aus dem Einzelstück neu laden (z.B. Lagerort geändert).
|
||||||
|
.onAppear { Task { await load() } }
|
||||||
|
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: { Text(error ?? "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filterMenu: some View {
|
||||||
|
Menu {
|
||||||
|
Menu("Lagerort") {
|
||||||
|
check("Alle", locationFilter == nil) { locationFilter = nil }
|
||||||
|
ForEach(locations) { loc in
|
||||||
|
check(loc.path(in: locations), locationFilter == loc.id) { locationFilter = loc.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !filterKategorien.isEmpty {
|
||||||
|
Menu("Kategorie") {
|
||||||
|
check("Alle", categoryFilter == nil) { categoryFilter = nil }
|
||||||
|
ForEach(filterKategorien) { e in
|
||||||
|
check(e.label, categoryFilter == e.item.id) { categoryFilter = e.item.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hatFilter {
|
||||||
|
Divider()
|
||||||
|
Button(role: .destructive) { locationFilter = nil; categoryFilter = nil } label: {
|
||||||
|
Label("Filter zurücksetzen", systemImage: "xmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: hatFilter
|
||||||
|
? "line.3.horizontal.decrease.circle.fill"
|
||||||
|
: "line.3.horizontal.decrease.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func check(_ title: String, _ selected: Bool, _ action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
if selected { Label(title, systemImage: "checkmark") } else { Text(title) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
items = try await APIClient.shared.allItems()
|
||||||
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
|
categories = (try? await APIClient.shared.categories()) ?? []
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Einzelstück-Zeile: Produktbild, Produktname, UID und Lagerort-Pfad.
|
||||||
|
struct ItemRow: View {
|
||||||
|
let item: Item
|
||||||
|
let locations: [StorageLocation]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ProductThumb(productId: item.productId)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(item.productName ?? "—")
|
||||||
|
let pfad = locationPath(item.locationId, in: locations)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(item.uid).font(.caption).foregroundStyle(.secondary)
|
||||||
|
if !pfad.isEmpty {
|
||||||
|
Text("· \(pfad)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
691
ios/Sources/ItemViews.swift
Normal file
691
ios/Sources/ItemViews.swift
Normal file
@@ -0,0 +1,691 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import CoreImage
|
||||||
|
import PhotosUI
|
||||||
|
import QuickLook
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// QR-Code als Bild (CoreImage). Inhalt ist der Link auf das Einzelstück, damit
|
||||||
|
/// ein Scan – auch mit der Systemkamera – die App/Weboberfläche öffnet.
|
||||||
|
enum QRImage {
|
||||||
|
static func make(_ text: String, scale: CGFloat = 8) -> UIImage? {
|
||||||
|
guard let data = text.data(using: .utf8),
|
||||||
|
let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
|
||||||
|
filter.setValue(data, forKey: "inputMessage")
|
||||||
|
filter.setValue("M", forKey: "inputCorrectionLevel")
|
||||||
|
guard let ci = filter.outputImage?
|
||||||
|
.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) else { return nil }
|
||||||
|
let context = CIContext()
|
||||||
|
guard let cg = context.createCGImage(ci, from: ci.extent) else { return nil }
|
||||||
|
return UIImage(cgImage: cg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func qrLink(forUid uid: String) -> String {
|
||||||
|
let base = Session.shared.baseURL?.absoluteString
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
||||||
|
return base.isEmpty ? uid : "\(base)/i/\(uid)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dateToString(_ d: Date) -> String {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: d)
|
||||||
|
}
|
||||||
|
private func stringToDate(_ s: String) -> Date? {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.date(from: s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Datumszeile mit einheitlichem Format (dd.MM.yyyy). Die native compact-DatePicker
|
||||||
|
/// zeigt das Jahr je nach Feld mal zwei-, mal vierstellig – deshalb hier ein fester
|
||||||
|
/// Formatierer und ein grafischer Picker im Popover.
|
||||||
|
struct DateRow: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var date: Date
|
||||||
|
@State private var open = false
|
||||||
|
|
||||||
|
private static let fmt: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "de_DE")
|
||||||
|
f.dateFormat = "dd.MM.yyyy"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
Button { withAnimation(.easeInOut(duration: 0.15)) { open.toggle() } } label: {
|
||||||
|
HStack {
|
||||||
|
Text(label).foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
Text(Self.fmt.string(from: date))
|
||||||
|
.foregroundStyle(open ? Color.marke : .secondary)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
if open {
|
||||||
|
DatePicker(label, selection: $date, displayedComponents: .date)
|
||||||
|
.datePickerStyle(.graphical)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Liste (in der Produkt-Detailansicht)
|
||||||
|
|
||||||
|
/// Einzelstücke eines Gegenstands: Liste mit Anlegen; jedes Stück öffnet die
|
||||||
|
/// Detail-/Bearbeiten-Ansicht.
|
||||||
|
struct ProductItemsSection: View {
|
||||||
|
let product: Product
|
||||||
|
var onChanged: () async -> Void
|
||||||
|
/// Bis zur Liste schließen (für „Anlegen & fertig").
|
||||||
|
var onFinish: (() -> Void)? = nil
|
||||||
|
|
||||||
|
@State private var items: [Item] = []
|
||||||
|
@State private var showAdd = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section("Einzelstücke (\(items.count))") {
|
||||||
|
ForEach(items) { it in
|
||||||
|
NavigationLink {
|
||||||
|
ItemEditView(item: it) { Task { await refresh() } }
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(it.uid).font(.body.monospaced()).bold()
|
||||||
|
Text(untertitel(it)).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if items.isEmpty {
|
||||||
|
Text("Noch keine Einzelstücke.").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Button { showAdd = true } label: {
|
||||||
|
Label("Einzelstücke anlegen", systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: product.id) { await reload() }
|
||||||
|
.sheet(isPresented: $showAdd) {
|
||||||
|
NavigationStack {
|
||||||
|
ItemAddSheet(productId: product.id, onDone: { await refresh() },
|
||||||
|
onFinish: onFinish.map { finish in { showAdd = false; finish() } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func untertitel(_ it: Item) -> String {
|
||||||
|
[it.locationName, it.acquiredOn.map { "gekauft \($0)" }, it.shopName]
|
||||||
|
.compactMap { $0 }.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
private func reload() async {
|
||||||
|
items = (try? await APIClient.shared.items(productId: product.id)) ?? []
|
||||||
|
}
|
||||||
|
private func refresh() async { await reload(); await onChanged() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detail / Bearbeiten (auch Ziel eines Scans)
|
||||||
|
|
||||||
|
/// Lagerort per QR (…/l/<Code>) auswählen – selbstständige Scan-Ansicht, meldet
|
||||||
|
/// den getroffenen Lagerort zurück. So kann man einen Ort auch ohne den QR des
|
||||||
|
/// Einzelstücks setzen (z.B. bei Kleidung ohne eigenen Code).
|
||||||
|
struct LocationScannerView: View {
|
||||||
|
let locations: [StorageLocation]
|
||||||
|
var onPicked: (StorageLocation) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var paused = false
|
||||||
|
@State private var torchOn = false
|
||||||
|
@State private var torchLocked = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScannerView(onCode: { code in handle(code) },
|
||||||
|
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.font(.callout).foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||||
|
.background(.red, in: Capsule())
|
||||||
|
}
|
||||||
|
Text("QR am Regal/Fach vor die Kamera halten")
|
||||||
|
.font(.callout).padding(10)
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
}
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.navigationTitle("Lagerort scannen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
TorchButton(isOn: $torchOn, locked: $torchLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(_ code: String) {
|
||||||
|
guard let r = code.range(of: "/l/") else {
|
||||||
|
error = "Das ist kein Lagerort-QR."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let ziel = String(code[r.upperBound...])
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
|
||||||
|
.uppercased()
|
||||||
|
if let treffer = locations.first(where: { $0.id == ziel }) {
|
||||||
|
onPicked(treffer)
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
error = "Dieser Lagerort ist hier nicht bekannt."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ItemEditView: View {
|
||||||
|
let item: Item
|
||||||
|
var onChanged: (() -> Void)? = nil
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var locations: [StorageLocation] = []
|
||||||
|
@State private var shops: [ShopItem] = []
|
||||||
|
@State private var photo: Data?
|
||||||
|
@State private var locationId: String?
|
||||||
|
@State private var shopId: Int?
|
||||||
|
@State private var hasAcquired = false
|
||||||
|
@State private var acquired = Date()
|
||||||
|
@State private var hasWarranty = false
|
||||||
|
@State private var warranty = Date()
|
||||||
|
@State private var note = ""
|
||||||
|
@State private var priceText = ""
|
||||||
|
@State private var currency = "CHF"
|
||||||
|
@State private var documents: [ItemDocument] = []
|
||||||
|
@State private var suggWarranty: String?
|
||||||
|
@State private var suggPrice: Int?
|
||||||
|
@State private var suggPriceCandidates: [Int] = []
|
||||||
|
@State private var suggAcquired: String?
|
||||||
|
@State private var suggShopId: Int?
|
||||||
|
@State private var suggShopName: String?
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var busyDoc = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var showRemove = false
|
||||||
|
@State private var reason = "broken"
|
||||||
|
@State private var removeNote = ""
|
||||||
|
@State private var showFileImporter = false
|
||||||
|
@State private var pickerItem: PhotosPickerItem?
|
||||||
|
@State private var previewURL: URL?
|
||||||
|
@State private var locScanShown = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
if let photo, let ui = UIImage(data: photo) {
|
||||||
|
Image(uiImage: ui)
|
||||||
|
.resizable().scaledToFit()
|
||||||
|
.frame(maxWidth: .infinity).frame(maxHeight: 220)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
if let name = item.productName {
|
||||||
|
LabeledContent("Artikel", value: name)
|
||||||
|
}
|
||||||
|
if let brand = item.productBrand, !brand.isEmpty {
|
||||||
|
LabeledContent("Marke", value: brand)
|
||||||
|
}
|
||||||
|
LabeledContent("UID", value: item.uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Lagerort", selection: $locationId) {
|
||||||
|
Text("– ohne –").tag(String?.none)
|
||||||
|
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
|
||||||
|
}
|
||||||
|
// Lagerort per QR setzen – ohne den (evtl. nicht vorhandenen) QR
|
||||||
|
// des Einzelstücks. Praktisch z.B. bei Kleidung.
|
||||||
|
Button { locScanShown = true } label: {
|
||||||
|
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||||||
|
}
|
||||||
|
Picker("Gekauft bei", selection: $shopId) {
|
||||||
|
Text("– unbekannt –").tag(Int?.none)
|
||||||
|
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
|
||||||
|
}
|
||||||
|
Toggle("Kaufdatum", isOn: $hasAcquired)
|
||||||
|
if hasAcquired { DateRow(label: "Gekauft am", date: $acquired) }
|
||||||
|
Toggle("Garantie", isOn: $hasWarranty)
|
||||||
|
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
|
||||||
|
LabeledField(label: "Notiz", text: $note)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Kaufpreis") {
|
||||||
|
HStack {
|
||||||
|
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
|
||||||
|
Picker("", selection: $currency) {
|
||||||
|
Text("CHF").tag("CHF")
|
||||||
|
Text("EUR").tag("EUR")
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented).frame(width: 130)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Belege (Rechnung/Garantieschein)") {
|
||||||
|
if hatVorschlag {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary)
|
||||||
|
if let a = suggAcquired { Text("Gekauft am \(a)") }
|
||||||
|
if let w = suggWarranty { Text("Garantie bis \(w)") }
|
||||||
|
if suggPriceCandidates.count > 1 {
|
||||||
|
// Mehrere Beträge gefunden – Auswahl anbieten.
|
||||||
|
Picker("Kaufpreis", selection: Binding(
|
||||||
|
get: { suggPrice ?? suggPriceCandidates.first ?? 0 },
|
||||||
|
set: { suggPrice = $0 }
|
||||||
|
)) {
|
||||||
|
ForEach(suggPriceCandidates, id: \.self) { c in
|
||||||
|
Text("\(ItemEditView.formatCents(c)) \(currency)").tag(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
} else if let p = suggPrice {
|
||||||
|
Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)")
|
||||||
|
}
|
||||||
|
if let sid = suggShopId {
|
||||||
|
Text("Shop: \(shops.first(where: { $0.id == sid })?.name ?? "bekannt")")
|
||||||
|
} else if let name = suggShopName {
|
||||||
|
Text("Shop anlegen: \(name)")
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button("Übernehmen") { Task { await applySuggestion() } }
|
||||||
|
Spacer()
|
||||||
|
Button("Verwerfen") { verwerfeVorschlag() }
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(documents) { d in
|
||||||
|
Button { Task { await openDocument(d) } } label: {
|
||||||
|
Label(d.filename, systemImage: "doc.text")
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
Button("Löschen", role: .destructive) { Task { await deleteDoc(d) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if documents.isEmpty {
|
||||||
|
Text("Noch keine Belege.").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
PhotosPicker(selection: $pickerItem, matching: .images) {
|
||||||
|
Label("Bild hochladen", systemImage: "photo")
|
||||||
|
}
|
||||||
|
Button { showFileImporter = true } label: {
|
||||||
|
Label("PDF hochladen", systemImage: "doc.badge.plus")
|
||||||
|
}
|
||||||
|
if busyDoc { ProgressView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }.disabled(busy)
|
||||||
|
Button("Entfernen (mit Grund)", role: .destructive) { showRemove = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Einzelstück")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } }
|
||||||
|
.task { await load() }
|
||||||
|
.fullScreenCover(isPresented: $locScanShown) {
|
||||||
|
LocationScannerView(locations: locations) { loc in locationId = loc.id }
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showRemove) {
|
||||||
|
NavigationStack { removeSheet }
|
||||||
|
}
|
||||||
|
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
|
||||||
|
if case .success(let url) = result {
|
||||||
|
Task {
|
||||||
|
let scoped = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
if let data = try? Data(contentsOf: url) {
|
||||||
|
await upload(data: data, filename: url.lastPathComponent,
|
||||||
|
contentType: "application/pdf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if case .failure(let err) = result {
|
||||||
|
error = err.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: pickerItem) { neu in
|
||||||
|
guard let neu else { return }
|
||||||
|
Task {
|
||||||
|
if let data = try? await neu.loadTransferable(type: Data.self) {
|
||||||
|
await upload(data: data, filename: "foto.jpg", contentType: "image/jpeg")
|
||||||
|
}
|
||||||
|
pickerItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.quickLookPreview($previewURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var removeSheet: some View {
|
||||||
|
Form {
|
||||||
|
Picker("Grund", selection: $reason) {
|
||||||
|
ForEach(RemovalReasons.all, id: \.value) { Text($0.label).tag($0.value) }
|
||||||
|
}
|
||||||
|
LabeledField(label: "Notiz", text: $removeNote)
|
||||||
|
Section {
|
||||||
|
Button("Entfernen", role: .destructive) { Task { await remove() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Entfernen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showRemove = false } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
|
shops = (try? await APIClient.shared.shops()) ?? []
|
||||||
|
photo = try? await APIClient.shared.productImage(id: item.productId)
|
||||||
|
locationId = item.locationId
|
||||||
|
shopId = item.shopId
|
||||||
|
note = item.note ?? ""
|
||||||
|
documents = item.documents
|
||||||
|
currency = item.currency ?? "CHF"
|
||||||
|
if let p = item.priceCents { priceText = ItemEditView.formatCents(p) }
|
||||||
|
if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true }
|
||||||
|
if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
busy = true; defer { busy = false }; error = nil
|
||||||
|
let cents = ItemEditView.parseCents(priceText)
|
||||||
|
do {
|
||||||
|
_ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest(
|
||||||
|
locationId: locationId, shopId: shopId,
|
||||||
|
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
|
||||||
|
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
|
||||||
|
note: note.isEmpty ? nil : note,
|
||||||
|
priceCents: cents,
|
||||||
|
currency: cents == nil ? nil : currency))
|
||||||
|
onChanged?()
|
||||||
|
dismiss()
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Belege
|
||||||
|
|
||||||
|
private func upload(data: Data, filename: String, contentType: String) async {
|
||||||
|
busyDoc = true
|
||||||
|
defer { busyDoc = false }
|
||||||
|
do {
|
||||||
|
let res = try await APIClient.shared.uploadItemDocument(
|
||||||
|
itemId: item.id, data: data, filename: filename, contentType: contentType)
|
||||||
|
let s = res.suggestions
|
||||||
|
suggWarranty = s.suggestedWarrantyUntil
|
||||||
|
suggPrice = s.suggestedPriceCents
|
||||||
|
suggPriceCandidates = s.suggestedPriceCandidates
|
||||||
|
suggAcquired = s.suggestedAcquiredOn
|
||||||
|
suggShopId = s.suggestedShopId
|
||||||
|
suggShopName = s.suggestedShopName
|
||||||
|
await reloadDocuments()
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadDocuments() async {
|
||||||
|
if let fresh = try? await APIClient.shared.itemByUid(uid: item.uid) {
|
||||||
|
documents = fresh.documents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openDocument(_ d: ItemDocument) async {
|
||||||
|
do {
|
||||||
|
let data = try await APIClient.shared.itemDocumentData(itemId: item.id, docId: d.id)
|
||||||
|
let name = d.filename.isEmpty ? "beleg" : d.filename
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
|
||||||
|
try data.write(to: url)
|
||||||
|
previewURL = url
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteDoc(_ d: ItemDocument) async {
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.deleteItemDocument(itemId: item.id, docId: d.id)
|
||||||
|
await reloadDocuments()
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hatVorschlag: Bool {
|
||||||
|
suggWarranty != nil || suggPrice != nil || suggAcquired != nil
|
||||||
|
|| suggShopId != nil || suggShopName != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySuggestion() async {
|
||||||
|
if let a = suggAcquired, let d = stringToDate(a) { acquired = d; hasAcquired = true }
|
||||||
|
if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true }
|
||||||
|
if let p = suggPrice { priceText = ItemEditView.formatCents(p) }
|
||||||
|
if let sid = suggShopId {
|
||||||
|
shopId = sid
|
||||||
|
} else if let name = suggShopName,
|
||||||
|
let shop = try? await APIClient.shared.createShop(
|
||||||
|
NewShopRequest(name: name, website: nil)) {
|
||||||
|
if !shops.contains(where: { $0.id == shop.id }) { shops.append(shop) }
|
||||||
|
shopId = shop.id
|
||||||
|
}
|
||||||
|
verwerfeVorschlag()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verwerfeVorschlag() {
|
||||||
|
suggWarranty = nil
|
||||||
|
suggPrice = nil
|
||||||
|
suggPriceCandidates = []
|
||||||
|
suggAcquired = nil
|
||||||
|
suggShopId = nil
|
||||||
|
suggShopName = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eingabe in Hauptwährungseinheit → Rappen/Cent.
|
||||||
|
static func parseCents(_ s: String) -> Int? {
|
||||||
|
let t = s.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")
|
||||||
|
guard !t.isEmpty, let v = Double(t) else { return nil }
|
||||||
|
return Int((v * 100).rounded())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func formatCents(_ c: Int) -> String {
|
||||||
|
String(format: "%.2f", Double(c) / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func remove() async {
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest(
|
||||||
|
reason: reason, note: removeNote.isEmpty ? nil : removeNote))
|
||||||
|
showRemove = false
|
||||||
|
onChanged?()
|
||||||
|
dismiss()
|
||||||
|
} catch { self.error = error.localizedDescription; showRemove = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Anlegen
|
||||||
|
|
||||||
|
struct ItemAddSheet: View {
|
||||||
|
let productId: Int
|
||||||
|
var onDone: () async -> Void
|
||||||
|
/// Wenn gesetzt: „Anlegen & fertig" schließt bis zur Liste (nicht nur das Sheet).
|
||||||
|
var onFinish: (() -> Void)? = nil
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var count = 1
|
||||||
|
@State private var locations: [StorageLocation] = []
|
||||||
|
@State private var shops: [ShopItem] = []
|
||||||
|
@State private var locationId: String?
|
||||||
|
@State private var shopId: Int?
|
||||||
|
@State private var hasAcquired = false
|
||||||
|
@State private var acquired = Date()
|
||||||
|
@State private var hasWarranty = false
|
||||||
|
@State private var warranty = Date()
|
||||||
|
@State private var note = ""
|
||||||
|
@State private var priceText = ""
|
||||||
|
@State private var currency = "CHF"
|
||||||
|
@State private var docData: Data?
|
||||||
|
@State private var docName = ""
|
||||||
|
@State private var docType = ""
|
||||||
|
@State private var newShopName: String?
|
||||||
|
@State private var addInfo: String?
|
||||||
|
@State private var showFileImporter = false
|
||||||
|
@State private var pickerItem: PhotosPickerItem?
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var locScanShown = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Stepper("Anzahl: \(count)", value: $count, in: 1...200)
|
||||||
|
Picker("Lagerort", selection: $locationId) {
|
||||||
|
Text("– ohne –").tag(String?.none)
|
||||||
|
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
|
||||||
|
}
|
||||||
|
Button { locScanShown = true } label: {
|
||||||
|
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||||||
|
}
|
||||||
|
Picker("Gekauft bei", selection: $shopId) {
|
||||||
|
Text("– unbekannt –").tag(Int?.none)
|
||||||
|
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
|
||||||
|
}
|
||||||
|
Toggle("Kaufdatum", isOn: $hasAcquired)
|
||||||
|
if hasAcquired { DateRow(label: "Gekauft am", date: $acquired) }
|
||||||
|
Toggle("Garantie", isOn: $hasWarranty)
|
||||||
|
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
|
||||||
|
LabeledField(label: "Notiz", text: $note)
|
||||||
|
} footer: {
|
||||||
|
Text("Gemeinsame Startwerte – danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Kaufpreis") {
|
||||||
|
HStack {
|
||||||
|
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
|
||||||
|
Picker("", selection: $currency) {
|
||||||
|
Text("CHF").tag("CHF")
|
||||||
|
Text("EUR").tag("EUR")
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented).frame(width: 130)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
if docData != nil { Text(docName).font(.callout) }
|
||||||
|
PhotosPicker(selection: $pickerItem, matching: .images) {
|
||||||
|
Label("Bild wählen", systemImage: "photo")
|
||||||
|
}
|
||||||
|
Button { showFileImporter = true } label: {
|
||||||
|
Label("PDF wählen", systemImage: "doc.badge.plus")
|
||||||
|
}
|
||||||
|
if let addInfo { Text(addInfo).font(.caption).foregroundStyle(.secondary) }
|
||||||
|
if let newShopName {
|
||||||
|
Text("Neuer Shop „\(newShopName)“ wird beim Anlegen erstellt.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Beleg (Rechnung/Garantieschein)")
|
||||||
|
} footer: {
|
||||||
|
Text("Der Beleg wird an das erste angelegte Stück gehängt; aus einem PDF werden Kaufdatum, Garantie, Preis und Shop vorgeschlagen.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||||||
|
Section {
|
||||||
|
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create(finish: false) } }.disabled(busy)
|
||||||
|
if onFinish != nil {
|
||||||
|
Button(busy ? "Anlegen…" : "Anlegen & fertig") { Task { await create(finish: true) } }
|
||||||
|
.disabled(busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Einzelstücke anlegen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||||||
|
.fullScreenCover(isPresented: $locScanShown) {
|
||||||
|
LocationScannerView(locations: locations) { loc in locationId = loc.id }
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
|
||||||
|
if case .success(let url) = result {
|
||||||
|
Task {
|
||||||
|
let scoped = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
if let data = try? Data(contentsOf: url) {
|
||||||
|
await analyze(data, name: url.lastPathComponent, type: "application/pdf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: pickerItem) { neu in
|
||||||
|
guard let neu else { return }
|
||||||
|
Task {
|
||||||
|
if let data = try? await neu.loadTransferable(type: Data.self) {
|
||||||
|
await analyze(data, name: "foto.jpg", type: "image/jpeg")
|
||||||
|
}
|
||||||
|
pickerItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
|
shops = (try? await APIClient.shared.shops()) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beleg beim Anlegen analysieren und Felder vorbefüllen (nur bei PDF liefert
|
||||||
|
/// der Server Treffer).
|
||||||
|
private func analyze(_ data: Data, name: String, type: String) async {
|
||||||
|
docData = data
|
||||||
|
docName = name
|
||||||
|
docType = type
|
||||||
|
addInfo = nil
|
||||||
|
newShopName = nil
|
||||||
|
guard let s = try? await APIClient.shared.analyzeItemDocument(
|
||||||
|
data: data, filename: name, contentType: type) else { return }
|
||||||
|
var teile: [String] = []
|
||||||
|
if let a = s.suggestedAcquiredOn, let d = stringToDate(a) {
|
||||||
|
acquired = d; hasAcquired = true; teile.append("Kaufdatum")
|
||||||
|
}
|
||||||
|
if let w = s.suggestedWarrantyUntil, let d = stringToDate(w) {
|
||||||
|
warranty = d; hasWarranty = true; teile.append("Garantie")
|
||||||
|
}
|
||||||
|
if let p = s.suggestedPriceCents {
|
||||||
|
priceText = ItemEditView.formatCents(p); teile.append("Preis")
|
||||||
|
}
|
||||||
|
if let sid = s.suggestedShopId {
|
||||||
|
shopId = sid; teile.append("Shop")
|
||||||
|
} else if let sn = s.suggestedShopName {
|
||||||
|
newShopName = sn
|
||||||
|
}
|
||||||
|
addInfo = teile.isEmpty ? "Beleg erkannt – keine Automatik-Treffer."
|
||||||
|
: "Übernommen: \(teile.joined(separator: ", "))."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func create(finish: Bool) async {
|
||||||
|
busy = true; defer { busy = false }; error = nil
|
||||||
|
do {
|
||||||
|
var sid = shopId
|
||||||
|
if sid == nil, let name = newShopName,
|
||||||
|
let shop = try? await APIClient.shared.createShop(
|
||||||
|
NewShopRequest(name: name, website: nil)) {
|
||||||
|
sid = shop.id
|
||||||
|
}
|
||||||
|
let cents = ItemEditView.parseCents(priceText)
|
||||||
|
let created = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
|
||||||
|
count: count, locationId: locationId, shopId: sid,
|
||||||
|
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
|
||||||
|
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
|
||||||
|
note: note.isEmpty ? nil : note,
|
||||||
|
priceCents: cents,
|
||||||
|
currency: cents == nil ? nil : currency))
|
||||||
|
if let data = docData, let first = created.first {
|
||||||
|
_ = try? await APIClient.shared.uploadItemDocument(
|
||||||
|
itemId: first.id, data: data,
|
||||||
|
filename: docName.isEmpty ? "beleg" : docName, contentType: docType)
|
||||||
|
}
|
||||||
|
await onDone()
|
||||||
|
if finish, let onFinish { onFinish() } else { dismiss() }
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ struct ShoppingListView: View {
|
|||||||
|
|
||||||
@State private var items: [ShoppingItem] = []
|
@State private var items: [ShoppingItem] = []
|
||||||
@State private var groups: [GroupShoppingItem] = []
|
@State private var groups: [GroupShoppingItem] = []
|
||||||
|
@State private var byLocation: [LocationNeeds] = []
|
||||||
@State private var busy = true
|
@State private var busy = true
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
@@ -75,12 +76,34 @@ 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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if items.isEmpty && groups.isEmpty && !busy {
|
ForEach(byLocation) { ort in
|
||||||
|
Section("Bedarf: \(ort.locationName)") {
|
||||||
|
ForEach(ort.products) { p in
|
||||||
|
ListRow(
|
||||||
|
systemImage: "shippingbox",
|
||||||
|
title: p.name,
|
||||||
|
subtitle: "fehlt \(formatAmount(p.deficit)) \(p.unitLabel)"
|
||||||
|
) { EmptyView() }
|
||||||
|
}
|
||||||
|
ForEach(ort.groups) { g in
|
||||||
|
ListRow(
|
||||||
|
systemImage: "square.stack.3d.up",
|
||||||
|
title: g.name,
|
||||||
|
subtitle: "fehlt \(formatAmount(g.deficit)) \(g.unitName)"
|
||||||
|
) { GroupBadge() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if items.isEmpty && groups.isEmpty && byLocation.isEmpty && !busy {
|
||||||
Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.")
|
Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -97,6 +120,7 @@ struct ShoppingListView: View {
|
|||||||
do {
|
do {
|
||||||
items = try await APIClient.shared.shoppingList()
|
items = try await APIClient.shared.shoppingList()
|
||||||
groups = try await APIClient.shared.shoppingGroups()
|
groups = try await APIClient.shared.shoppingGroups()
|
||||||
|
byLocation = try await APIClient.shared.shoppingByLocation()
|
||||||
error = nil
|
error = nil
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
@@ -126,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)
|
||||||
}
|
}
|
||||||
@@ -161,22 +185,237 @@ 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" }
|
// MARK: - Mindestbestände
|
||||||
return days == 1 ? "1 Tag" : "\(days) Tage"
|
|
||||||
|
/// Übersicht aller Mindestbestände – nach LAGERORT gegliedert.
|
||||||
|
///
|
||||||
|
/// Ein Mindestbestand ist immer ein Bedarf an einem Ort. „Überall" (egal wo,
|
||||||
|
/// 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 {
|
||||||
|
@State private var products: [Product] = []
|
||||||
|
@State private var groups: [GroupItem] = []
|
||||||
|
@State private var busy = true
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
private struct MinRow: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let soll: String
|
||||||
|
let bestand: String?
|
||||||
|
let productId: Int? // Tippziel (Artikel)
|
||||||
|
let groupId: Int? // Tippziel (Gruppe)
|
||||||
|
let unter: Bool
|
||||||
|
let untergruppen: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct OrtBlock: Identifiable {
|
||||||
|
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 }) {
|
||||||
|
let disp = p.unitFactor == 0 ? 1 : p.unitFactor // Basis -> Anzeigeeinheit
|
||||||
|
for l in (p.locationMinStocks ?? []) {
|
||||||
|
merken(l.locationId, l.locationName, MinRow(
|
||||||
|
id: "p\(p.id)l\(l.id)", title: p.name,
|
||||||
|
soll: "\(formatAmount(l.minStock / disp)) \(p.unitName)",
|
||||||
|
bestand: l.stock.map { "\(formatAmount($0 / disp)) \(p.unitName)" },
|
||||||
|
productId: p.id, groupId: nil,
|
||||||
|
unter: (l.stock ?? 0) < l.minStock, untergruppen: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for g in groups.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
|
||||||
|
let faktor = g.minFaktor == 0 ? 1 : g.minFaktor
|
||||||
|
let einheit = g.minEinheit
|
||||||
|
for l in (g.locationMinStocks ?? []) {
|
||||||
|
merken(l.locationId, l.locationName, MinRow(
|
||||||
|
id: "g\(g.id)l\(l.id)", title: g.name,
|
||||||
|
soll: "\(formatAmount(l.minStock / faktor)) \(einheit)",
|
||||||
|
bestand: l.stock.map { "\(formatAmount($0 / faktor)) \(einheit)" },
|
||||||
|
productId: nil, groupId: g.id,
|
||||||
|
unter: (l.stock ?? 0) < l.minStock,
|
||||||
|
untergruppen: g.childIds?.count ?? 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// „Ü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 {
|
||||||
|
List {
|
||||||
|
if !busy && bloecke.isEmpty {
|
||||||
|
Text("Noch keine Mindestbestände gesetzt.").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach(bloecke) { block in
|
||||||
|
Section(block.id == UEBERALL_ID ? "Überall (egal wo)" : block.name) {
|
||||||
|
ForEach(block.zeilen) { r in
|
||||||
|
if let pid = r.productId, let p = products.first(where: { $0.id == pid }) {
|
||||||
|
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")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.overlay { if busy && bloecke.isEmpty { ProgressView() } }
|
||||||
|
.refreshable { await load() }
|
||||||
|
.onAppear { Task { await load() } }
|
||||||
|
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: { Text(error ?? "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func zeile(_ r: MinRow) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(r.title)
|
||||||
|
if r.groupId != nil {
|
||||||
|
Text("Gruppe").font(.caption2)
|
||||||
|
.padding(.horizontal, 6).padding(.vertical, 1)
|
||||||
|
.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("Soll \(r.soll)" + (r.bestand.map { " · Bestand \($0)" } ?? ""))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if r.unter {
|
||||||
|
Text("niedrig").font(.caption2)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||||
|
.background(Color.orange.opacity(0.2), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
do {
|
||||||
|
async let ps = APIClient.shared.searchProducts("")
|
||||||
|
async let gs = APIClient.shared.groups()
|
||||||
|
products = try await ps
|
||||||
|
groups = try await gs
|
||||||
|
Task { await ProductImageCache.shared.preload(products) }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Produktliste
|
// MARK: - Produktliste
|
||||||
|
|
||||||
struct ProductListView: View {
|
struct ProductListView: View {
|
||||||
|
/// nil = alle, "food" = nur Lebensmittel, "object" = nur Gegenstände.
|
||||||
|
/// Getrennte Listen wie im Web (dort über `fixedType`/`p.tracking`).
|
||||||
|
var fixedType: String? = nil
|
||||||
|
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
@State private var query = ""
|
@State private var query = ""
|
||||||
@State private var products: [Product] = []
|
@State private var products: [Product] = []
|
||||||
@State private var categories: [CategoryItem] = []
|
@State private var categories: [CategoryItem] = []
|
||||||
/// nil = alle, 0 = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit).
|
/// nil = alle, 0 = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit).
|
||||||
@State private var categoryId: Int?
|
@State private var categoryId: Int?
|
||||||
|
@State private var sortMode: SortMode = .nameAsc
|
||||||
|
/// nil = alle; sonst "count"/"individual"/"bulk" (nur in der Gegenstände-Liste).
|
||||||
|
@State private var verwaltungFilter: String?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
|
@State private var showNew = false
|
||||||
|
|
||||||
|
enum SortMode: String, CaseIterable, Identifiable {
|
||||||
|
case nameAsc = "Name A–Z"
|
||||||
|
case nameDesc = "Name Z–A"
|
||||||
|
case updated = "Zuletzt geändert"
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let verwaltungOptionen: [(String, String)] = [
|
||||||
|
("count", "Menge je Lagerort"),
|
||||||
|
("individual", "Einzelstücke"),
|
||||||
|
("bulk", "Verbrauchsgegenstand"),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Verwaltungsart eines Gegenstands.
|
||||||
|
private func verwaltung(_ p: Product) -> String {
|
||||||
|
p.isIndividual ? "individual" : (p.isBulk ? "bulk" : "count")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auf den festen Typ und ggf. die Verwaltungsart gefiltert (wie im Web).
|
||||||
|
private var sichtbar: [Product] {
|
||||||
|
products.filter { p in
|
||||||
|
if let fixedType, (p.isObject ? "object" : "food") != fixedType { return false }
|
||||||
|
if let vf = verwaltungFilter, verwaltung(p) != vf { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sichtbare Produkte in der gewählten Reihenfolge.
|
||||||
|
private var sortiert: [Product] {
|
||||||
|
switch sortMode {
|
||||||
|
case .nameAsc:
|
||||||
|
return sichtbar.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
case .nameDesc:
|
||||||
|
return sichtbar.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedDescending }
|
||||||
|
case .updated:
|
||||||
|
// ISO-Strings gleichen Formats sind chronologisch vergleichbar; neueste zuerst.
|
||||||
|
return sichtbar.sorted { ($0.updatedAt ?? "") > ($1.updatedAt ?? "") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nur Kategorien des Listentyps (Lebensmittel-Liste zeigt nur Food-Kategorien usw.).
|
||||||
|
private var typKategorien: [CategoryItem] {
|
||||||
|
guard let fixedType else { return categories }
|
||||||
|
return categories.filter { ($0.tracking ?? "food") == fixedType }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titel: String {
|
||||||
|
switch fixedType {
|
||||||
|
case "food": return "Lebensmittel"
|
||||||
|
case "object": return "Gegenstände"
|
||||||
|
default: return "Produkte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var filterLabel: String {
|
private var filterLabel: String {
|
||||||
if categoryId == 0 { return "Ohne Kategorie" }
|
if categoryId == 0 { return "Ohne Kategorie" }
|
||||||
@@ -203,30 +442,73 @@ struct ProductListView: View {
|
|||||||
Text(filterLabel).foregroundStyle(.secondary)
|
Text(filterLabel).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if fixedType == "object" {
|
||||||
|
Picker(selection: $verwaltungFilter) {
|
||||||
|
Text("Alle").tag(String?.none)
|
||||||
|
ForEach(verwaltungOptionen, id: \.0) { opt in
|
||||||
|
Text(opt.1).tag(String?.some(opt.0))
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Verwaltung", systemImage: "square.grid.2x2")
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
Picker(selection: $sortMode) {
|
||||||
|
ForEach(SortMode.allCases) { Text($0.rawValue).tag($0) }
|
||||||
|
} label: {
|
||||||
|
Label("Sortierung", systemImage: "arrow.up.arrow.down")
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
ForEach(products) { product in
|
ForEach(sortiert) { product in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ProductDetailView(product: product)
|
ProductDetailView(product: product)
|
||||||
} label: {
|
} label: {
|
||||||
ProductRow(product: product)
|
ProductRow(product: product)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if products.isEmpty && !busy {
|
if sichtbar.isEmpty && !busy {
|
||||||
Text("Keine Treffer").foregroundStyle(.secondary)
|
Text("Keine Treffer").foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.searchable(text: $query, prompt: "Artikel suchen")
|
.searchable(text: $query, prompt: "Artikel suchen")
|
||||||
.onChange(of: query) { _ in Task { await load() } }
|
.onChange(of: query) { _ in Task { await load() } }
|
||||||
.navigationTitle("Produkte")
|
.navigationTitle(titel)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.refreshable { await load() }
|
.refreshable { await load() }
|
||||||
.task {
|
.toolbar {
|
||||||
categories = (try? await APIClient.shared.categories()) ?? []
|
if session.isAdmin {
|
||||||
await load()
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button { showNew = true } label: {
|
||||||
|
Label("Artikel anlegen", systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showNew) {
|
||||||
|
NavigationStack {
|
||||||
|
// Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste
|
||||||
|
// vorbelegen. Nach dem Anlegen schließt sich das Formular und die
|
||||||
|
// Liste wird aufgefrischt.
|
||||||
|
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { _ in
|
||||||
|
showNew = false
|
||||||
|
Task { await load() }
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Abbrechen") { showNew = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { categories = (try? await APIClient.shared.categories()) ?? [] }
|
||||||
|
// Bei jedem Erscheinen neu laden – auch beim Zurückkommen aus der
|
||||||
|
// Detailansicht, damit ein dort geänderter Bestand (z.B. neu angelegte
|
||||||
|
// Einzelstücke) sofort stimmt und nicht „0" hängen bleibt.
|
||||||
|
.onAppear { Task { await load() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pick(_ id: Int?) {
|
private func pick(_ id: Int?) {
|
||||||
@@ -243,17 +525,18 @@ struct ProductListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func categoryTree() -> [TreeEntry] {
|
private func categoryTree() -> [TreeEntry] {
|
||||||
let ids = Set(categories.map(\.id))
|
let liste = typKategorien
|
||||||
|
let ids = Set(liste.map(\.id))
|
||||||
var result: [TreeEntry] = []
|
var result: [TreeEntry] = []
|
||||||
|
|
||||||
func walk(_ node: CategoryItem, _ depth: Int) {
|
func walk(_ node: CategoryItem, _ depth: Int) {
|
||||||
result.append(TreeEntry(item: node, depth: depth))
|
result.append(TreeEntry(item: node, depth: depth))
|
||||||
for child in categories.filter({ $0.parentId == node.id }) {
|
for child in liste.filter({ $0.parentId == node.id }) {
|
||||||
walk(child, depth + 1)
|
walk(child, depth + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene.
|
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene.
|
||||||
for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
for root in liste.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
||||||
walk(root, 0)
|
walk(root, 0)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -262,7 +545,11 @@ struct ProductListView: View {
|
|||||||
private func load() async {
|
private func load() async {
|
||||||
busy = true
|
busy = true
|
||||||
defer { busy = false }
|
defer { busy = false }
|
||||||
products = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? []
|
let geladen = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? []
|
||||||
|
products = geladen
|
||||||
|
// Bilder im Hintergrund sicherstellen – nur fehlende/geänderte werden geholt,
|
||||||
|
// damit die Thumbnails auch nach App-Neustart sofort aus dem Cache kommen.
|
||||||
|
Task { await ProductImageCache.shared.preload(geladen) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,8 +559,7 @@ struct ProductRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "shippingbox")
|
ProductThumb(productId: product.id)
|
||||||
.font(.body).foregroundStyle(.secondary).frame(width: 24)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(product.name)
|
Text(product.name)
|
||||||
Text(bestand)
|
Text(bestand)
|
||||||
|
|||||||
324
ios/Sources/LocalOverview.swift
Normal file
324
ios/Sources/LocalOverview.swift
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Die eigene, lokale App-Übersicht eines Servers: mehrere Dashboards, Karten
|
||||||
|
/// untereinander, frei zusammenstellbar. Nur auf diesem Gerät gespeichert.
|
||||||
|
///
|
||||||
|
/// Anzeige und Bearbeitung sind getrennt: Beim Bearbeiten zeigt eine kompakte,
|
||||||
|
/// sortierbare Liste die Karten (so funktionieren Ziehen und Löschen sauber),
|
||||||
|
/// beim Ansehen die vollen Karten.
|
||||||
|
struct LocalOverview: View {
|
||||||
|
let profileID: UUID
|
||||||
|
|
||||||
|
@State private var config = AppOverviewConfig()
|
||||||
|
@State private var auswahl: UUID?
|
||||||
|
@State private var bearbeiten = false
|
||||||
|
@State private var addShown = false
|
||||||
|
@State private var produkte: [Product] = []
|
||||||
|
@State private var stand = 0
|
||||||
|
@State private var renameText = ""
|
||||||
|
@State private var renameShown = false
|
||||||
|
|
||||||
|
private var aktives: AppDashboard? {
|
||||||
|
config.dashboards.first { $0.id == auswahl } ?? config.dashboards.first
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if bearbeiten {
|
||||||
|
editierListe
|
||||||
|
} else {
|
||||||
|
anzeige
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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) {
|
||||||
|
CardPickerView { neu in fuegeHinzu(neu) }
|
||||||
|
}
|
||||||
|
.alert("Dashboard umbenennen", isPresented: $renameShown) {
|
||||||
|
TextField("Name", text: $renameText)
|
||||||
|
Button("Sichern") { speichereName() }
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
}
|
||||||
|
.task(id: profileID) {
|
||||||
|
config = AppDashboardStore.config(for: profileID)
|
||||||
|
if auswahl == nil || !config.dashboards.contains(where: { $0.id == auswahl }) {
|
||||||
|
auswahl = config.dashboards.first?.id
|
||||||
|
}
|
||||||
|
produkte = (try? await APIClient.shared.searchProducts("")) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Anzeige
|
||||||
|
|
||||||
|
private var anzeige: some View {
|
||||||
|
List {
|
||||||
|
if config.dashboards.count > 1 {
|
||||||
|
Section { dashboardPicker }
|
||||||
|
}
|
||||||
|
if let aktives {
|
||||||
|
ForEach(aktives.cards) { karte in
|
||||||
|
Section {
|
||||||
|
DashboardCardView(type: karte.type, productId: karte.productId,
|
||||||
|
stand: stand,
|
||||||
|
detail: karte.detail ?? false,
|
||||||
|
tiefe: karte.tiefe,
|
||||||
|
mitOhneMhd: karte.mitOhneMhd ?? true) { stand += 1 }
|
||||||
|
} header: {
|
||||||
|
Text(titel(karte))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if aktives.cards.isEmpty {
|
||||||
|
Text("Noch keine Karten. Über „Bearbeiten“ lassen sich welche hinzufügen.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { stand += 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bearbeiten
|
||||||
|
|
||||||
|
private var editierListe: some View {
|
||||||
|
List {
|
||||||
|
if config.dashboards.count > 1 {
|
||||||
|
Section { dashboardPicker }
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
ForEach(aktives?.cards ?? []) { karte in
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(CardCatalog.titel(karte.type))
|
||||||
|
if CardCatalogList.kind(for: karte.type)?.needsProduct == true {
|
||||||
|
artikelWahl(fuer: karte)
|
||||||
|
}
|
||||||
|
diagrammEinstellungen(fuer: karte)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete(perform: loesche)
|
||||||
|
.onMove(perform: verschiebe)
|
||||||
|
} header: {
|
||||||
|
Text("Karten – ziehen zum Sortieren")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.editMode, .constant(.active))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dashboardPicker: some View {
|
||||||
|
Picker("Dashboard", selection: Binding(
|
||||||
|
get: { aktives?.id ?? UUID() }, set: { auswahl = $0 }
|
||||||
|
)) {
|
||||||
|
ForEach(config.dashboards) { Text($0.name).tag($0.id) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var werkzeugleiste: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Divider()
|
||||||
|
HStack {
|
||||||
|
if bearbeiten {
|
||||||
|
Button { addShown = true } label: { Label("Karte", systemImage: "plus") }
|
||||||
|
Spacer()
|
||||||
|
Menu {
|
||||||
|
Button("Dashboard hinzufügen", systemImage: "plus") { neuesDashboard() }
|
||||||
|
if aktives != nil {
|
||||||
|
Button("Umbenennen", systemImage: "pencil") {
|
||||||
|
renameText = aktives?.name ?? ""; renameShown = true
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func titel(_ karte: AppCard) -> String {
|
||||||
|
if let pid = karte.productId, let p = produkte.first(where: { $0.id == pid }) {
|
||||||
|
return "\(CardCatalog.titel(karte.type)): \(p.name)"
|
||||||
|
}
|
||||||
|
return CardCatalog.titel(karte.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func artikelWahl(fuer karte: AppCard) -> some View {
|
||||||
|
Menu {
|
||||||
|
Button("– kein Artikel –") { setzeProdukt(karte, nil) }
|
||||||
|
ForEach(produkte) { p in
|
||||||
|
Button(p.name) { setzeProdukt(karte, p.id) }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(produkte.first { $0.id == karte.productId }?.name ?? "Artikel wählen")
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Datenänderungen (alle sofort gespeichert)
|
||||||
|
|
||||||
|
private func mutiere(_ block: (inout AppDashboard) -> Void) {
|
||||||
|
guard let id = aktives?.id,
|
||||||
|
let index = config.dashboards.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
block(&config.dashboards[index])
|
||||||
|
AppDashboardStore.save(config, for: profileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fuegeHinzu(_ karte: AppCard) { mutiere { $0.cards.append(karte) } }
|
||||||
|
|
||||||
|
/// Ändert genau eine Karte und lädt die Anzeige neu.
|
||||||
|
private func mutiereKarte(_ karte: AppCard, _ block: (inout AppCard) -> Void) {
|
||||||
|
mutiere { dash in
|
||||||
|
if let i = dash.cards.firstIndex(where: { $0.id == karte.id }) {
|
||||||
|
block(&dash.cards[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 verschiebe(from: IndexSet, to: Int) { mutiere { $0.cards.move(fromOffsets: from, toOffset: to) } }
|
||||||
|
|
||||||
|
private func neuesDashboard() {
|
||||||
|
let neu = AppDashboard(name: "Neues Dashboard")
|
||||||
|
config.dashboards.append(neu)
|
||||||
|
AppDashboardStore.save(config, for: profileID)
|
||||||
|
auswahl = neu.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loescheDashboard() {
|
||||||
|
guard let id = aktives?.id else { return }
|
||||||
|
config.dashboards.removeAll { $0.id == id }
|
||||||
|
AppDashboardStore.save(config, for: profileID)
|
||||||
|
auswahl = config.dashboards.first?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func speichereName() {
|
||||||
|
let name = renameText.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !name.isEmpty else { return }
|
||||||
|
mutiere { $0.name = name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auswahl einer neuen Karte. Diagramme sind eigens gekennzeichnet.
|
||||||
|
struct CardPickerView: View {
|
||||||
|
let onPick: (AppCard) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section("Karten") {
|
||||||
|
ForEach(CardCatalogList.all.filter { !$0.isChart }) { kind in
|
||||||
|
zeile(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section("Diagramme") {
|
||||||
|
ForEach(CardCatalogList.all.filter { $0.isChart }) { kind in
|
||||||
|
zeile(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Karte hinzufügen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func zeile(_ kind: CardKind) -> some View {
|
||||||
|
Button {
|
||||||
|
onPick(AppCard(type: kind.type))
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(kind.title)
|
||||||
|
if kind.needsProduct {
|
||||||
|
Text("· Artikel").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "plus.circle").foregroundStyle(Color.marke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ import SwiftUI
|
|||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
@EnvironmentObject private var session: Session
|
@EnvironmentObject private var session: Session
|
||||||
|
|
||||||
@State private var server: String = Session.shared.baseURL?.absoluteString ?? ""
|
@State private var server: String = ""
|
||||||
@State private var username = ""
|
@State private var username = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var stayLoggedIn = Session.shared.stayLoggedIn
|
@State private var stayLoggedIn = Session.shared.stayLoggedIn
|
||||||
|
@State private var manageShown = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -18,6 +19,15 @@ struct LoginView: View {
|
|||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.keyboardType(.URL)
|
.keyboardType(.URL)
|
||||||
|
// Mit mehreren Servern ist das hier der Weg zurueck, wenn man
|
||||||
|
// am falschen gelandet ist.
|
||||||
|
if session.profiles.count > 1 {
|
||||||
|
Picker("Ausgewählt", selection: profileSelection) {
|
||||||
|
ForEach(session.profiles) { profile in
|
||||||
|
Text(profile.name).tag(profile.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Section("Anmeldung") {
|
Section("Anmeldung") {
|
||||||
TextField("Benutzername", text: $username)
|
TextField("Benutzername", text: $username)
|
||||||
@@ -40,8 +50,31 @@ struct LoginView: View {
|
|||||||
.disabled(busy || server.isEmpty || username.isEmpty)
|
.disabled(busy || server.isEmpty || username.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Vorrania")
|
.navigationTitle(session.activeProfile?.name ?? "Vorrania")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Server verwalten", systemImage: "server.rack") { manageShown = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $manageShown) { ServerListView() }
|
||||||
}
|
}
|
||||||
|
// Beim Start und bei jedem Serverwechsel die Felder auf das aktive
|
||||||
|
// Profil setzen - sonst steht die Adresse des vorigen Servers da.
|
||||||
|
.task(id: session.activeProfileID) { syncFromProfile() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var profileSelection: Binding<UUID> {
|
||||||
|
Binding(
|
||||||
|
get: { session.activeProfileID ?? session.profiles.first?.id ?? UUID() },
|
||||||
|
set: { session.switchTo($0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncFromProfile() {
|
||||||
|
server = session.activeProfile?.urlString ?? ""
|
||||||
|
username = session.activeProfile?.username ?? ""
|
||||||
|
password = ""
|
||||||
|
error = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func login() async {
|
private func login() async {
|
||||||
|
|||||||
163
ios/Sources/LookupScanView.swift
Normal file
163
ios/Sources/LookupScanView.swift
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Nur ansehen: Barcode → Artikel, Einzelstück-QR → genau dieses Stück,
|
||||||
|
/// Lagerort-QR → Liste aller Artikel an diesem Ort. Kein Ein-/Auslagern.
|
||||||
|
struct LookupScanView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
|
||||||
|
@State private var paused = false
|
||||||
|
@State private var torchOn = false
|
||||||
|
@State private var torchLocked = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var item: Item?
|
||||||
|
@State private var product: Product?
|
||||||
|
@State private var contents: LocationContents?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
ScannerView(onCode: { code in Task { await resolve(code) } },
|
||||||
|
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||||||
|
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Text("Barcode, Einzelstück- oder Lagerort-QR scannen – nur ansehen")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Text(error).font(.callout).foregroundStyle(.red)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
.navigationTitle("Nachschlagen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||||||
|
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
||||||
|
}
|
||||||
|
.sheet(item: $item, onDismiss: { paused = false }) { it in
|
||||||
|
NavigationStack { ItemEditView(item: it) }
|
||||||
|
}
|
||||||
|
.sheet(item: $product, onDismiss: { paused = false }) { p in
|
||||||
|
NavigationStack { ProductDetailView(product: p) }
|
||||||
|
.environmentObject(display)
|
||||||
|
}
|
||||||
|
.sheet(item: $contents, onDismiss: { paused = false }) { c in
|
||||||
|
NavigationStack { LocationContentsView(contents: c) }
|
||||||
|
.environmentObject(display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolve(_ code: String) async {
|
||||||
|
paused = true
|
||||||
|
error = nil
|
||||||
|
// Einzelstück-QR (…/i/<UID>)?
|
||||||
|
if let r = code.range(of: "/i/") {
|
||||||
|
let uid = String(code[r.upperBound...])
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased()
|
||||||
|
if !uid.isEmpty, let found = try? await APIClient.shared.itemByUid(uid: uid) {
|
||||||
|
item = found
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Lagerort-QR (…/l/<Code>)? → alles zeigen, was dort (inkl. Unterorte) liegt.
|
||||||
|
if let r = code.range(of: "/l/") {
|
||||||
|
let loc = String(code[r.upperBound...])
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased()
|
||||||
|
if !loc.isEmpty {
|
||||||
|
do {
|
||||||
|
contents = try await APIClient.shared.locationContents(code: loc)
|
||||||
|
} catch {
|
||||||
|
self.error = "Lagerort nicht gefunden: \(loc)"
|
||||||
|
paused = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sonst als Barcode auflösen.
|
||||||
|
do {
|
||||||
|
let result = try await APIClient.shared.lookup(barcode: code)
|
||||||
|
if let existing = result.existingProduct {
|
||||||
|
product = existing
|
||||||
|
} else {
|
||||||
|
error = "Nicht im Katalog: \(code)"
|
||||||
|
paused = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
paused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liste aller Artikel eines gescannten Lagerorts (inkl. Unterorte).
|
||||||
|
struct LocationContentsView: View {
|
||||||
|
let contents: LocationContents
|
||||||
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if contents.products.isEmpty {
|
||||||
|
Text("Hier ist nichts eingelagert.").foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Section {
|
||||||
|
ForEach(contents.products) { e in
|
||||||
|
NavigationLink {
|
||||||
|
ProductLoaderView(productId: e.productId).environmentObject(display)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(e.name)
|
||||||
|
if let brand = e.brand, !brand.isEmpty {
|
||||||
|
Text(brand).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("\(mengeText(e.stock)) \(e.unitLabel)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Bestand inkl. der Unterorte. Tippen öffnet den Artikel.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(contents.locationName)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mengeText(_ v: Double) -> String {
|
||||||
|
v == v.rounded() ? String(Int(v)) : String(format: "%.2f", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt einen Artikel per ID nach und zeigt dann seine Detailansicht.
|
||||||
|
struct ProductLoaderView: View {
|
||||||
|
let productId: Int
|
||||||
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
@State private var product: Product?
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let product {
|
||||||
|
ProductDetailView(product: product).environmentObject(display)
|
||||||
|
} else if let error {
|
||||||
|
Text(error).foregroundStyle(.red).padding()
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
do { product = try await APIClient.shared.product(id: productId) }
|
||||||
|
catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1225
ios/Sources/MasterDataViews.swift
Normal file
1225
ios/Sources/MasterDataViews.swift
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
260
ios/Sources/NotificationScheduler.swift
Normal file
260
ios/Sources/NotificationScheduler.swift
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
// MARK: - Reine Planung (netzfrei, testbar)
|
||||||
|
|
||||||
|
/// Eine fertig berechnete Meldung: an welchem Tag, welcher Art, mit welchen
|
||||||
|
/// Produkten. Bewusst ohne iOS-Typen, damit sich die Logik ohne Geraet pruefen
|
||||||
|
/// laesst.
|
||||||
|
struct PlannedNotification: Equatable {
|
||||||
|
enum Kind { case upcoming, expired }
|
||||||
|
|
||||||
|
let profileID: UUID
|
||||||
|
let serverName: String
|
||||||
|
/// Tagesbeginn des Meldetags.
|
||||||
|
let day: Date
|
||||||
|
let kind: Kind
|
||||||
|
/// Produktnamen fuer diesen Tag, ohne Dopplung, in stabiler Reihenfolge.
|
||||||
|
let products: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rechnet aus den Ablaufdaten eines Servers die Meldungen aus. Alle
|
||||||
|
/// Entscheidungen des Nutzers stecken hier - deshalb strikt getrennt vom
|
||||||
|
/// Netz- und iOS-Teil.
|
||||||
|
enum ExpiryPlanner {
|
||||||
|
static func plan(
|
||||||
|
profileID: UUID,
|
||||||
|
serverName: String,
|
||||||
|
config: ServerNotificationConfig,
|
||||||
|
snapshot: ServerFetch.Snapshot,
|
||||||
|
today: Date,
|
||||||
|
horizonDays: Int,
|
||||||
|
calendar: Calendar = .current
|
||||||
|
) -> [PlannedNotification] {
|
||||||
|
guard config.hasSomethingToSchedule else { return [] }
|
||||||
|
|
||||||
|
let heute = calendar.startOfDay(for: today)
|
||||||
|
guard let horizont = calendar.date(byAdding: .day, value: horizonDays, to: heute) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Produkt -> Kategorie, und der Kategorie-Baum fuer die Nachfahren.
|
||||||
|
let kategorieVon = Dictionary(
|
||||||
|
snapshot.products.map { ($0.id, $0.categoryId) },
|
||||||
|
uniquingKeysWith: { first, _ in first }
|
||||||
|
)
|
||||||
|
let nachfahren = descendantMap(snapshot.categories)
|
||||||
|
|
||||||
|
let allgemeine = config.rules.filter { $0.categoryId == nil }
|
||||||
|
let kategorieRegeln = config.rules.filter { $0.categoryId != nil }
|
||||||
|
|
||||||
|
// Tag -> Produktnamen, getrennt nach bald-ablaufend und abgelaufen.
|
||||||
|
var bald: [Date: [String]] = [:]
|
||||||
|
var abgelaufen: [Date: [String]] = [:]
|
||||||
|
|
||||||
|
func ergaenze(_ eimer: inout [Date: [String]], _ tag: Date, _ name: String) {
|
||||||
|
var liste = eimer[tag] ?? []
|
||||||
|
if !liste.contains(name) { liste.append(name) }
|
||||||
|
eimer[tag] = liste
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in snapshot.expiring {
|
||||||
|
guard let mhd = parseTag(item.bestBefore, calendar: calendar) else { continue }
|
||||||
|
let mhdTag = calendar.startOfDay(for: mhd)
|
||||||
|
|
||||||
|
// Welche Regeln gelten fuer dieses Produkt? Kategorie ersetzt Allgemeine:
|
||||||
|
// greift eine Kategorie-Regel (Produktkategorie im Teilbaum), zaehlen
|
||||||
|
// nur diese, sonst die allgemeinen.
|
||||||
|
let produktKategorie = kategorieVon[item.productId] ?? nil
|
||||||
|
let passendeKategorie = kategorieRegeln.filter { regel in
|
||||||
|
guard let ziel = regel.categoryId, let pk = produktKategorie else { return false }
|
||||||
|
return pk == ziel || (nachfahren[ziel]?.contains(pk) ?? false)
|
||||||
|
}
|
||||||
|
let regeln = passendeKategorie.isEmpty ? allgemeine : passendeKategorie
|
||||||
|
|
||||||
|
for regel in regeln {
|
||||||
|
guard let fensterStart = calendar.date(
|
||||||
|
byAdding: .day, value: -regel.leadDays, to: mhdTag) else { continue }
|
||||||
|
|
||||||
|
// Erinnerungen liegen vor dem Ablauftag (der Ablauftag gehoert der
|
||||||
|
// letzten Warnung). Einmalig: nur der Starttag; wiederkehrend: jeder
|
||||||
|
// Tag im Fenster bis zum Vortag des Ablaufs.
|
||||||
|
let letzterTag = regel.recurring
|
||||||
|
? (calendar.date(byAdding: .day, value: -1, to: mhdTag) ?? fensterStart)
|
||||||
|
: fensterStart
|
||||||
|
|
||||||
|
var tag = fensterStart
|
||||||
|
while tag <= letzterTag {
|
||||||
|
if tag >= heute && tag <= horizont && tag < mhdTag {
|
||||||
|
ergaenze(&bald, tag, item.productName)
|
||||||
|
}
|
||||||
|
guard let naechster = calendar.date(byAdding: .day, value: 1, to: tag) else { break }
|
||||||
|
tag = naechster
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letzte Warnung: am Ablauftag selbst, fuer alle Produkte, danach nie
|
||||||
|
// wieder (bereits abgelaufene Chargen lassen wir bewusst ruhen).
|
||||||
|
if config.lastWarningOnExpiry && mhdTag >= heute && mhdTag <= horizont {
|
||||||
|
ergaenze(&abgelaufen, mhdTag, item.productName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result: [PlannedNotification] = []
|
||||||
|
for (tag, namen) in bald {
|
||||||
|
result.append(PlannedNotification(
|
||||||
|
profileID: profileID, serverName: serverName, day: tag,
|
||||||
|
kind: .upcoming, products: namen))
|
||||||
|
}
|
||||||
|
for (tag, namen) in abgelaufen {
|
||||||
|
result.append(PlannedNotification(
|
||||||
|
profileID: profileID, serverName: serverName, day: tag,
|
||||||
|
kind: .expired, products: namen))
|
||||||
|
}
|
||||||
|
// Nach Datum, dann abgelaufen vor bald-ablaufend (dringender zuerst).
|
||||||
|
return result.sorted {
|
||||||
|
$0.day != $1.day ? $0.day < $1.day
|
||||||
|
: ($0.kind == .expired && $1.kind == .upcoming)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kategorie-Id -> alle darunterliegenden Kategorie-Ids (ohne die eigene).
|
||||||
|
static func descendantMap(_ categories: [CategoryItem]) -> [Int: Set<Int>] {
|
||||||
|
var kinder: [Int: [Int]] = [:]
|
||||||
|
for c in categories {
|
||||||
|
if let p = c.parentId { kinder[p, default: []].append(c.id) }
|
||||||
|
}
|
||||||
|
var ergebnis: [Int: Set<Int>] = [:]
|
||||||
|
for c in categories {
|
||||||
|
var alle: Set<Int> = []
|
||||||
|
var stapel = kinder[c.id] ?? []
|
||||||
|
while let n = stapel.popLast() {
|
||||||
|
if alle.insert(n).inserted { stapel.append(contentsOf: kinder[n] ?? []) }
|
||||||
|
}
|
||||||
|
ergebnis[c.id] = alle
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „yyyy-MM-dd" -> Date (Tagesbeginn). Monatsangaben liefert der Server bereits
|
||||||
|
/// als Monatsletzten, daher reicht das Tagesdatum.
|
||||||
|
static func parseTag(_ raw: String, calendar: Calendar) -> Date? {
|
||||||
|
let teile = raw.prefix(10).split(separator: "-")
|
||||||
|
guard teile.count == 3,
|
||||||
|
let y = Int(teile[0]), let m = Int(teile[1]), let d = Int(teile[2]) else { return nil }
|
||||||
|
return calendar.date(from: DateComponents(year: y, month: m, day: d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iOS-Planung
|
||||||
|
|
||||||
|
/// Plant die berechneten Meldungen als lokale Benachrichtigungen ein.
|
||||||
|
@MainActor
|
||||||
|
final class NotificationScheduler {
|
||||||
|
static let shared = NotificationScheduler()
|
||||||
|
|
||||||
|
/// Wie weit im Voraus geplant wird und wie viele Meldungen hoechstens - iOS
|
||||||
|
/// erlaubt 64 ausstehende je App, ueber alle Server zusammen.
|
||||||
|
static let horizonDays = 60
|
||||||
|
static let maxScheduled = 60
|
||||||
|
|
||||||
|
private var laeuftGerade = false
|
||||||
|
|
||||||
|
/// Fragt die Erlaubnis, falls noch nicht geschehen. Gibt zurueck, ob erlaubt.
|
||||||
|
@discardableResult
|
||||||
|
func requestAuthorization() async -> Bool {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let status = await center.notificationSettings().authorizationStatus
|
||||||
|
switch status {
|
||||||
|
case .authorized, .provisional, .ephemeral:
|
||||||
|
return true
|
||||||
|
case .notDetermined:
|
||||||
|
return (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rechnet alle Server neu durch und ersetzt den geplanten Bestand.
|
||||||
|
func rescheduleAll() async {
|
||||||
|
guard !laeuftGerade else { return }
|
||||||
|
laeuftGerade = true
|
||||||
|
defer { laeuftGerade = false }
|
||||||
|
|
||||||
|
let profile = Session.shared.profiles
|
||||||
|
let relevant = profile.filter {
|
||||||
|
NotificationStore.config(for: $0.id).hasSomethingToSchedule
|
||||||
|
}
|
||||||
|
guard !relevant.isEmpty else {
|
||||||
|
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard await requestAuthorization() else { return }
|
||||||
|
|
||||||
|
var geplant: [PlannedNotification] = []
|
||||||
|
let heute = Date()
|
||||||
|
|
||||||
|
for prof in relevant {
|
||||||
|
guard let url = prof.url,
|
||||||
|
let token = Keychain.read(account: Session.keychainAccount(for: prof.id))
|
||||||
|
else { continue }
|
||||||
|
let config = NotificationStore.config(for: prof.id)
|
||||||
|
// snapshot ist widerstandsfaehig: unerreichbare Server (Heimnetz von
|
||||||
|
// unterwegs) liefern leere Listen -> es wird nichts geplant, die
|
||||||
|
// anderen Server trotzdem.
|
||||||
|
let snapshot = await ServerFetch.snapshot(
|
||||||
|
baseURL: url, token: token, horizonDays: Self.horizonDays)
|
||||||
|
geplant += ExpiryPlanner.plan(
|
||||||
|
profileID: prof.id, serverName: prof.name, config: config,
|
||||||
|
snapshot: snapshot, today: heute, horizonDays: Self.horizonDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Datum sortieren und auf das iOS-Limit kappen - die naechsten zuerst.
|
||||||
|
geplant.sort { $0.day != $1.day ? $0.day < $1.day
|
||||||
|
: ($0.kind == .expired && $1.kind == .upcoming) }
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.removeAllPendingNotificationRequests()
|
||||||
|
for meldung in geplant.prefix(Self.maxScheduled) {
|
||||||
|
center.add(request(for: meldung), withCompletionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request(for meldung: PlannedNotification) -> UNNotificationRequest {
|
||||||
|
let inhalt = UNMutableNotificationContent()
|
||||||
|
let namen = meldung.products
|
||||||
|
let anzahl = namen.count
|
||||||
|
let liste = namen.prefix(4).joined(separator: ", ")
|
||||||
|
let mehr = anzahl > 4 ? " u. a." : ""
|
||||||
|
|
||||||
|
switch meldung.kind {
|
||||||
|
case .upcoming:
|
||||||
|
inhalt.title = meldung.serverName
|
||||||
|
inhalt.body = anzahl == 1
|
||||||
|
? "\(liste) läuft bald ab."
|
||||||
|
: "\(anzahl) Produkte laufen bald ab: \(liste)\(mehr)"
|
||||||
|
case .expired:
|
||||||
|
inhalt.title = "ACHTUNG – \(meldung.serverName)"
|
||||||
|
inhalt.body = anzahl == 1
|
||||||
|
? "\(liste) ist heute abgelaufen."
|
||||||
|
: "\(anzahl) Produkte sind abgelaufen: \(liste)\(mehr)"
|
||||||
|
}
|
||||||
|
inhalt.sound = .default
|
||||||
|
inhalt.userInfo = ["profileID": meldung.profileID.uuidString]
|
||||||
|
|
||||||
|
let config = NotificationStore.config(for: meldung.profileID)
|
||||||
|
var wann = Calendar.current.dateComponents([.year, .month, .day], from: meldung.day)
|
||||||
|
wann.hour = config.hour
|
||||||
|
wann.minute = config.minute
|
||||||
|
|
||||||
|
// Zwei Meldungen am selben Tag (bald + abgelaufen) brauchen verschiedene
|
||||||
|
// Kennungen, sonst ueberschreibt die zweite die erste.
|
||||||
|
let art = meldung.kind == .expired ? "expired" : "upcoming"
|
||||||
|
let tag = "\(wann.year ?? 0)-\(wann.month ?? 0)-\(wann.day ?? 0)"
|
||||||
|
let id = "vorrania.\(meldung.profileID.uuidString).\(tag).\(art)"
|
||||||
|
|
||||||
|
return UNNotificationRequest(
|
||||||
|
identifier: id,
|
||||||
|
content: inhalt,
|
||||||
|
trigger: UNCalendarNotificationTrigger(dateMatching: wann, repeats: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
86
ios/Sources/NotificationSettings.swift
Normal file
86
ios/Sources/NotificationSettings.swift
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Einstellungen fuer Ablauf-Benachrichtigungen - je Server eigene.
|
||||||
|
///
|
||||||
|
/// Rein auf dem Geraet gespeichert (UserDefaults), nicht am Server: Es ist eine
|
||||||
|
/// Einstellung dieser App auf diesem iPhone, kein geteilter Serverzustand.
|
||||||
|
|
||||||
|
/// Die Einheit, in der „X vor Ablauf" gemeint ist.
|
||||||
|
enum LeadUnit: String, Codable, CaseIterable {
|
||||||
|
case days, weeks
|
||||||
|
|
||||||
|
var label: String { self == .weeks ? "Wochen" : "Tage" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Erinnerungsregel: „X Tage/Wochen vor Ablauf", optional nur fuer eine
|
||||||
|
/// Kategorie, einmalig oder taeglich wiederkehrend.
|
||||||
|
struct NotificationRule: Codable, Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var amount: Int
|
||||||
|
var unit: LeadUnit
|
||||||
|
/// nil = alle Kategorien (die allgemeine Regel).
|
||||||
|
var categoryId: Int?
|
||||||
|
/// true = das Produkt taucht jeden Tag im Fenster (ab „X vorher" bis Ablauf)
|
||||||
|
/// in der Tagesmeldung auf; false = nur am Tag „X vorher".
|
||||||
|
var recurring: Bool
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), amount: Int = 3, unit: LeadUnit = .days,
|
||||||
|
categoryId: Int? = nil, recurring: Bool = false) {
|
||||||
|
self.id = id
|
||||||
|
self.amount = amount
|
||||||
|
self.unit = unit
|
||||||
|
self.categoryId = categoryId
|
||||||
|
self.recurring = recurring
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vorlaufzeit in Tagen - Wochen werden umgerechnet.
|
||||||
|
var leadDays: Int { unit == .weeks ? amount * 7 : amount }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Benachrichtigungseinstellungen eines Servers.
|
||||||
|
struct ServerNotificationConfig: Codable, Equatable {
|
||||||
|
var enabled: Bool = false
|
||||||
|
/// Uhrzeit der Tagesmeldung.
|
||||||
|
var hour: Int = 9
|
||||||
|
var minute: Int = 0
|
||||||
|
/// Letzte Warnung am Ablauftag („ACHTUNG: … abgelaufen"), fuer alle Produkte.
|
||||||
|
var lastWarningOnExpiry: Bool = true
|
||||||
|
var rules: [NotificationRule] = []
|
||||||
|
|
||||||
|
/// Ohne aktive Regel und ohne letzte Warnung gibt es nichts zu planen.
|
||||||
|
var hasSomethingToSchedule: Bool {
|
||||||
|
enabled && (!rules.isEmpty || lastWarningOnExpiry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest und schreibt die Konfigurationen. Ein Woerterbuch je Profil-UUID,
|
||||||
|
/// analog zu `ProfileStore`.
|
||||||
|
enum NotificationStore {
|
||||||
|
static let key = "notification_configs"
|
||||||
|
|
||||||
|
static func loadAll() -> [String: ServerNotificationConfig] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: key),
|
||||||
|
let dict = try? JSONDecoder().decode([String: ServerNotificationConfig].self, from: data)
|
||||||
|
else { return [:] }
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
static func config(for profileID: UUID) -> ServerNotificationConfig {
|
||||||
|
loadAll()[profileID.uuidString] ?? ServerNotificationConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ config: ServerNotificationConfig, for profileID: UUID) {
|
||||||
|
var alle = loadAll()
|
||||||
|
alle[profileID.uuidString] = config
|
||||||
|
guard let data = try? JSONEncoder().encode(alle) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Beim Loeschen eines Profils die zugehoerige Konfiguration mit entfernen.
|
||||||
|
static func remove(for profileID: UUID) {
|
||||||
|
var alle = loadAll()
|
||||||
|
alle.removeValue(forKey: profileID.uuidString)
|
||||||
|
guard let data = try? JSONEncoder().encode(alle) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
231
ios/Sources/NotificationSettingsView.swift
Normal file
231
ios/Sources/NotificationSettingsView.swift
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// Benachrichtigungs-Einstellungen eines Servers: Ein/Aus, Uhrzeit, letzte
|
||||||
|
/// Warnung und die Erinnerungsregeln.
|
||||||
|
struct NotificationSettingsView: View {
|
||||||
|
let profileID: UUID
|
||||||
|
let serverName: String
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var config = ServerNotificationConfig()
|
||||||
|
@State private var categories: [CategoryItem] = []
|
||||||
|
@State private var editingRule: NotificationRule?
|
||||||
|
@State private var addShown = false
|
||||||
|
@State private var berechtigungFehlt = false
|
||||||
|
|
||||||
|
private var uhrzeit: Binding<Date> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
Calendar.current.date(from: DateComponents(
|
||||||
|
hour: config.hour, minute: config.minute)) ?? Date()
|
||||||
|
},
|
||||||
|
set: { neu in
|
||||||
|
let teile = Calendar.current.dateComponents([.hour, .minute], from: neu)
|
||||||
|
config.hour = teile.hour ?? 9
|
||||||
|
config.minute = teile.minute ?? 0
|
||||||
|
speichern()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Toggle("Benachrichtigungen aktiv", isOn: Binding(
|
||||||
|
get: { config.enabled },
|
||||||
|
set: { an in Task { await setzeAktiv(an) } }
|
||||||
|
))
|
||||||
|
} footer: {
|
||||||
|
Text("Erinnert vor dem Ablauf von Produkten auf „\(serverName)“. Nur auf diesem iPhone, unabhängig von den anderen Servern.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if berechtigungFehlt {
|
||||||
|
Section {
|
||||||
|
Label("In den iOS-Einstellungen sind Mitteilungen für Vorrania aus. Ohne sie kommen keine Erinnerungen.",
|
||||||
|
systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.callout).foregroundStyle(.orange)
|
||||||
|
Button("iOS-Einstellungen öffnen") {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.enabled {
|
||||||
|
Section("Uhrzeit der Meldung") {
|
||||||
|
DatePicker("Täglich um", selection: uhrzeit, displayedComponents: .hourAndMinute)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Letzte Warnung am Ablauftag", isOn: Binding(
|
||||||
|
get: { config.lastWarningOnExpiry },
|
||||||
|
set: { config.lastWarningOnExpiry = $0; speichern() }
|
||||||
|
))
|
||||||
|
} footer: {
|
||||||
|
Text("Am Tag des Ablaufs eine deutliche Warnung – für alle Produkte, unabhängig von den Regeln unten.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(config.rules) { regel in
|
||||||
|
Button {
|
||||||
|
editingRule = regel
|
||||||
|
} label: {
|
||||||
|
regelZeile(regel)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
config.rules.remove(atOffsets: indexSet)
|
||||||
|
speichern()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
addShown = true
|
||||||
|
} label: {
|
||||||
|
Label("Regel hinzufügen", systemImage: "plus")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Regeln")
|
||||||
|
} footer: {
|
||||||
|
Text("Jede Regel erinnert „X vor Ablauf“. Eine Regel für eine Kategorie gilt auch für ihre Unterkategorien und ersetzt für diese Produkte die allgemeine Regel.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Benachrichtigungen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task {
|
||||||
|
config = NotificationStore.config(for: profileID)
|
||||||
|
await ladeKategorien()
|
||||||
|
await pruefeBerechtigung()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $addShown) {
|
||||||
|
RuleEditView(rule: nil, categories: categories) { neu in
|
||||||
|
config.rules.append(neu)
|
||||||
|
speichern()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $editingRule) { regel in
|
||||||
|
RuleEditView(rule: regel, categories: categories) { geaendert in
|
||||||
|
if let index = config.rules.firstIndex(where: { $0.id == geaendert.id }) {
|
||||||
|
config.rules[index] = geaendert
|
||||||
|
speichern()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func regelZeile(_ regel: NotificationRule) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("\(regel.amount) \(regel.unit.label) vorher")
|
||||||
|
Text("\(kategorieName(regel.categoryId)) · \(regel.recurring ? "täglich" : "einmalig")")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kategorieName(_ id: Int?) -> String {
|
||||||
|
guard let id else { return "Alle Kategorien" }
|
||||||
|
return categories.first { $0.id == id }?.name ?? "Kategorie \(id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Aktionen
|
||||||
|
|
||||||
|
private func setzeAktiv(_ an: Bool) async {
|
||||||
|
if an {
|
||||||
|
let erlaubt = await NotificationScheduler.shared.requestAuthorization()
|
||||||
|
berechtigungFehlt = !erlaubt
|
||||||
|
guard erlaubt else { return }
|
||||||
|
}
|
||||||
|
config.enabled = an
|
||||||
|
speichern()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func speichern() {
|
||||||
|
NotificationStore.save(config, for: profileID)
|
||||||
|
Task { await NotificationScheduler.shared.rescheduleAll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ladeKategorien() async {
|
||||||
|
// Kategorien vom Server dieses Profils - auch wenn es nicht der aktive ist.
|
||||||
|
guard let profile = Session.shared.profiles.first(where: { $0.id == profileID }),
|
||||||
|
let url = profile.url,
|
||||||
|
let token = Keychain.read(account: Session.keychainAccount(for: profileID))
|
||||||
|
else { return }
|
||||||
|
categories = (try? await ServerFetch.categories(baseURL: url, token: token)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pruefeBerechtigung() async {
|
||||||
|
guard config.enabled else { berechtigungFehlt = false; return }
|
||||||
|
let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
|
||||||
|
berechtigungFehlt = !(status == .authorized || status == .provisional || status == .ephemeral)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anlegen und Bearbeiten einer Regel.
|
||||||
|
struct RuleEditView: View {
|
||||||
|
let rule: NotificationRule?
|
||||||
|
let categories: [CategoryItem]
|
||||||
|
let onSave: (NotificationRule) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var amount: Int
|
||||||
|
@State private var unit: LeadUnit
|
||||||
|
@State private var categoryId: Int?
|
||||||
|
@State private var recurring: Bool
|
||||||
|
|
||||||
|
init(rule: NotificationRule?, categories: [CategoryItem],
|
||||||
|
onSave: @escaping (NotificationRule) -> Void) {
|
||||||
|
self.rule = rule
|
||||||
|
self.categories = categories
|
||||||
|
self.onSave = onSave
|
||||||
|
_amount = State(initialValue: rule?.amount ?? 3)
|
||||||
|
_unit = State(initialValue: rule?.unit ?? .days)
|
||||||
|
_categoryId = State(initialValue: rule?.categoryId)
|
||||||
|
_recurring = State(initialValue: rule?.recurring ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Vorlauf") {
|
||||||
|
Stepper("\(amount) \(unit.label)", value: $amount, in: 1...99)
|
||||||
|
Picker("Einheit", selection: $unit) {
|
||||||
|
ForEach(LeadUnit.allCases, id: \.self) { Text($0.label).tag($0) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
CategoryPicker(categories: categories, selection: $categoryId)
|
||||||
|
} header: {
|
||||||
|
Text("Kategorie")
|
||||||
|
} footer: {
|
||||||
|
Text("„Alle Kategorien“ ist die allgemeine Regel. Eine bestimmte Kategorie schließt ihre Unterkategorien ein.")
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Toggle("Täglich wiederholen", isOn: $recurring)
|
||||||
|
} footer: {
|
||||||
|
Text(recurring
|
||||||
|
? "Erinnert jeden Tag ab „\(amount) \(unit.label) vorher“ bis zum Ablauf."
|
||||||
|
: "Erinnert einmal, genau \(amount) \(unit.label) vor dem Ablauf.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(rule == nil ? "Regel hinzufügen" : "Regel bearbeiten")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Sichern") {
|
||||||
|
onSave(NotificationRule(
|
||||||
|
id: rule?.id ?? UUID(), amount: amount, unit: unit,
|
||||||
|
categoryId: categoryId, recurring: recurring))
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
328
ios/Sources/ObjectStockView.swift
Normal file
328
ios/Sources/ObjectStockView.swift
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Bestand eines Gegenstands je Lagerort – das Gegenstück zur Chargen-Liste der
|
||||||
|
/// Lebensmittel. Erlaubt Hinzufügen, Umlagern (ohne Grund) und Entfernen (mit
|
||||||
|
/// Pflicht-Grund) und zeigt eine kleine Entnahme-Statistik. Wird als Abschnitt
|
||||||
|
/// in die Produkt-Detailansicht eingebettet.
|
||||||
|
/// Welches Mengen-Sheet gerade offen ist. Wird auf der stabilen Form-Ebene der
|
||||||
|
/// Detailansicht präsentiert (nicht im Abschnitt selbst), sonst schloss sich das
|
||||||
|
/// Fenster beim ersten Öffnen sofort wieder (das spät fertige Laden der
|
||||||
|
/// Entnahme-Statistik hat das frische Sheet weggerissen).
|
||||||
|
enum ObjectStockSheet: Identifiable {
|
||||||
|
case add
|
||||||
|
case relocate(from: String?)
|
||||||
|
case remove(loc: String?)
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .add: return "add"
|
||||||
|
case .relocate(let f): return "relocate-\(f ?? "none")"
|
||||||
|
case .remove(let l): return "remove-\(l ?? "none")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ObjectStockSection: View {
|
||||||
|
let product: Product
|
||||||
|
let locations: [StorageLocation]
|
||||||
|
let lots: [Lot]
|
||||||
|
let removals: RemovalSummary?
|
||||||
|
@Binding var sheet: ObjectStockSheet?
|
||||||
|
|
||||||
|
private var einheit: String { product.unitName.isEmpty ? "Stück" : product.unitName }
|
||||||
|
private func ortName(_ id: String?) -> String {
|
||||||
|
guard let id else { return "Ohne Lagerort" }
|
||||||
|
let pfad = locationPath(id, in: locations)
|
||||||
|
return pfad.isEmpty ? "Ort \(id)" : pfad
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
Section("Bestand je Lagerort") {
|
||||||
|
ForEach(lots) { lot in
|
||||||
|
HStack {
|
||||||
|
Text(ortName(lot.locationId))
|
||||||
|
Spacer()
|
||||||
|
Text("\(formatAmount(lot.quantity)) \(einheit)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lots.isEmpty {
|
||||||
|
Text("Noch kein Bestand.").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button { sheet = .add } label: { Label("Hinzufügen", systemImage: "plus") }
|
||||||
|
Spacer()
|
||||||
|
Button { sheet = .relocate(from: lots.first?.locationId) } label: {
|
||||||
|
Label("Umlagern", systemImage: "arrow.left.arrow.right")
|
||||||
|
}
|
||||||
|
.disabled(lots.isEmpty)
|
||||||
|
Spacer()
|
||||||
|
Button(role: .destructive) { sheet = .remove(loc: lots.first?.locationId) } label: {
|
||||||
|
Label("Entfernen", systemImage: "trash")
|
||||||
|
}
|
||||||
|
.disabled(lots.isEmpty)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let removals, !removals.stats.isEmpty {
|
||||||
|
Section("Bereits entnommen") {
|
||||||
|
ForEach(removals.stats) { s in
|
||||||
|
LabeledContent(RemovalReasons.label(s.reason),
|
||||||
|
value: "\(formatAmount(s.quantity)) (\(s.count)×)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Eingabe für ein selbst definiertes Feld, passend zum Feldtyp.
|
||||||
|
struct ObjectFieldRow: View {
|
||||||
|
let def: FieldDefinition
|
||||||
|
@Binding var value: String
|
||||||
|
|
||||||
|
private var titel: String { def.label + (def.required ? " *" : "") }
|
||||||
|
|
||||||
|
private var dateBinding: Binding<Date> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f.date(from: value) ?? Date()
|
||||||
|
},
|
||||||
|
set: {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
|
||||||
|
value = f.string(from: $0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
private var boolBinding: Binding<Bool> {
|
||||||
|
Binding(get: { value == "true" }, set: { value = $0 ? "true" : "false" })
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch def.fieldType {
|
||||||
|
case "textarea":
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(titel).font(.caption).foregroundStyle(.secondary)
|
||||||
|
TextEditor(text: $value).frame(minHeight: 60)
|
||||||
|
}
|
||||||
|
case "number":
|
||||||
|
HStack {
|
||||||
|
Text(titel)
|
||||||
|
Spacer()
|
||||||
|
TextField("", text: $value)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(maxWidth: 120)
|
||||||
|
if let u = def.unit, !u.isEmpty { Text(u).foregroundStyle(.secondary) }
|
||||||
|
}
|
||||||
|
case "date":
|
||||||
|
HStack {
|
||||||
|
DatePicker(titel, selection: dateBinding, displayedComponents: .date)
|
||||||
|
if !value.isEmpty {
|
||||||
|
Button { value = "" } label: { Image(systemName: "xmark.circle.fill") }
|
||||||
|
.buttonStyle(.borderless).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "select":
|
||||||
|
Picker(titel, selection: $value) {
|
||||||
|
Text("– keine –").tag("")
|
||||||
|
ForEach(def.options, id: \.self) { Text($0).tag($0) }
|
||||||
|
}
|
||||||
|
case "boolean":
|
||||||
|
Toggle(titel, isOn: boolBinding)
|
||||||
|
default:
|
||||||
|
HStack {
|
||||||
|
Text(titel)
|
||||||
|
Spacer()
|
||||||
|
TextField("", text: $value).multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auswahl eines Lagerorts (oder „ohne").
|
||||||
|
private struct LocationPicker: View {
|
||||||
|
let title: String
|
||||||
|
let locations: [StorageLocation]
|
||||||
|
@Binding var selection: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Picker(title, selection: $selection) {
|
||||||
|
Text("– ohne Lagerort –").tag(String?.none)
|
||||||
|
ForEach(locations) { loc in Text(loc.path(in: locations)).tag(String?.some(loc.id)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Menge an einem Lagerort hinzufügen.
|
||||||
|
struct ObjectAddSheet: View {
|
||||||
|
let product: Product
|
||||||
|
let locations: [StorageLocation]
|
||||||
|
let einheit: String
|
||||||
|
var perform: () async -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var locationId: String?
|
||||||
|
@State private var quantity = ""
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var locScanShown = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
LocationPicker(title: "Lagerort", locations: locations, selection: $locationId)
|
||||||
|
Button { locScanShown = true } label: {
|
||||||
|
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||||||
|
}
|
||||||
|
QuantityField(label: "Menge", text: $quantity, suffix: einheit)
|
||||||
|
}
|
||||||
|
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||||||
|
Section {
|
||||||
|
Button(busy ? "Speichern…" : "Hinzufügen") { Task { await save() } }
|
||||||
|
.disabled(busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Menge hinzufügen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||||||
|
.fullScreenCover(isPresented: $locScanShown) {
|
||||||
|
LocationScannerView(locations: locations) { loc in locationId = loc.id }
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
|
||||||
|
error = "Bitte eine Menge größer 0 angeben."; return
|
||||||
|
}
|
||||||
|
busy = true; defer { busy = false }
|
||||||
|
do {
|
||||||
|
_ = try await APIClient.shared.objectCheckIn(
|
||||||
|
ObjectCheckInRequest(productId: product.id, quantity: menge,
|
||||||
|
unit: einheit, locationId: locationId))
|
||||||
|
await perform()
|
||||||
|
dismiss()
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Menge von einem Lagerort zum anderen umbuchen.
|
||||||
|
struct ObjectRelocateSheet: View {
|
||||||
|
let product: Product
|
||||||
|
let locations: [StorageLocation]
|
||||||
|
let initialFrom: String?
|
||||||
|
var perform: () async -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var fromId: String?
|
||||||
|
@State private var toId: String?
|
||||||
|
@State private var quantity = ""
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var locScanShown = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
LocationPicker(title: "Von", locations: locations, selection: $fromId)
|
||||||
|
LocationPicker(title: "Nach", locations: locations, selection: $toId)
|
||||||
|
Button { locScanShown = true } label: {
|
||||||
|
Label("Ziel-Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||||||
|
}
|
||||||
|
QuantityField(label: "Menge", text: $quantity, suffix: product.unitName)
|
||||||
|
}
|
||||||
|
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||||||
|
Section {
|
||||||
|
Button(busy ? "Umlagern…" : "Umlagern") { Task { await save() } }.disabled(busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Umlagern")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||||||
|
.fullScreenCover(isPresented: $locScanShown) {
|
||||||
|
LocationScannerView(locations: locations) { loc in toId = loc.id }
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
.onAppear { fromId = initialFrom }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
|
||||||
|
error = "Bitte eine Menge größer 0 angeben."; return
|
||||||
|
}
|
||||||
|
busy = true; defer { busy = false }
|
||||||
|
do {
|
||||||
|
_ = try await APIClient.shared.relocate(
|
||||||
|
RelocateRequest(productId: product.id, quantity: menge,
|
||||||
|
fromLocationId: fromId, toLocationId: toId))
|
||||||
|
await perform()
|
||||||
|
dismiss()
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Menge mit Pflicht-Grund aus dem Bestand entfernen.
|
||||||
|
struct ObjectRemoveSheet: View {
|
||||||
|
let product: Product
|
||||||
|
let locations: [StorageLocation]
|
||||||
|
let einheit: String
|
||||||
|
let initialLoc: String?
|
||||||
|
var perform: () async -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var locationId: String?
|
||||||
|
@State private var quantity = ""
|
||||||
|
@State private var reason = "broken"
|
||||||
|
@State private var note = ""
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var locScanShown = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
LocationPicker(title: "Lagerort", locations: locations, selection: $locationId)
|
||||||
|
Button { locScanShown = true } label: {
|
||||||
|
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||||||
|
}
|
||||||
|
QuantityField(label: "Menge", text: $quantity, suffix: einheit)
|
||||||
|
Picker("Grund", selection: $reason) {
|
||||||
|
ForEach(RemovalReasons.all, id: \.value) { Text($0.label).tag($0.value) }
|
||||||
|
}
|
||||||
|
LabeledField(label: "Notiz", text: $note)
|
||||||
|
}
|
||||||
|
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||||||
|
Section {
|
||||||
|
Button(busy ? "Entfernen…" : "Entfernen") { Task { await save() } }
|
||||||
|
.disabled(busy)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Aus Bestand entfernen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
|
||||||
|
.fullScreenCover(isPresented: $locScanShown) {
|
||||||
|
LocationScannerView(locations: locations) { loc in locationId = loc.id }
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
.onAppear { locationId = initialLoc }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
|
||||||
|
error = "Bitte eine Menge größer 0 angeben."; return
|
||||||
|
}
|
||||||
|
busy = true; defer { busy = false }
|
||||||
|
do {
|
||||||
|
_ = try await APIClient.shared.removeStock(
|
||||||
|
RemoveRequest(productId: product.id, quantity: menge, locationId: locationId,
|
||||||
|
reason: reason, note: note.isEmpty ? nil : note))
|
||||||
|
await perform()
|
||||||
|
dismiss()
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ struct ProductDetailView: View {
|
|||||||
let product: Product
|
let product: Product
|
||||||
|
|
||||||
@EnvironmentObject private var display: DisplaySettings
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var current: Product
|
@State private var current: Product
|
||||||
|
@State private var showLeaveConfirm = false
|
||||||
@State private var lots: [Lot] = []
|
@State private var lots: [Lot] = []
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@@ -18,12 +20,31 @@ 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.
|
||||||
|
@State private var objMode = "count"
|
||||||
@State private var groups: [GroupItem] = []
|
@State private var groups: [GroupItem] = []
|
||||||
@State private var categories: [CategoryItem] = []
|
@State private var categories: [CategoryItem] = []
|
||||||
|
|
||||||
|
// Gegenstände (Non-Food): Bezugsquelle, Link, Lagerorte und eigene Felder.
|
||||||
|
@State private var shopId: Int?
|
||||||
|
@State private var productUrl = ""
|
||||||
|
// Gesamt-Mindestbestand (nur Gegenstände; Menge in Stück/Artikeleinheit).
|
||||||
|
@State private var shops: [ShopItem] = []
|
||||||
|
@State private var locations: [StorageLocation] = []
|
||||||
|
@State private var fieldDefs: [FieldDefinition] = []
|
||||||
|
@State private var fieldValues: [String: String] = [:]
|
||||||
|
|
||||||
@State private var editLot: Lot?
|
@State private var editLot: Lot?
|
||||||
|
// Mengen-Bestand (Menge je Lagerort): Sheet + Entnahme-Statistik werden hier
|
||||||
|
// gehalten und auf Form-Ebene präsentiert, damit das Sheet stabil aufgeht.
|
||||||
|
@State private var objectSheet: ObjectStockSheet?
|
||||||
|
@State private var objectRemovals: RemovalSummary?
|
||||||
|
|
||||||
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||||||
|
|
||||||
@@ -32,6 +53,36 @@ struct ProductDetailView: View {
|
|||||||
_current = State(initialValue: product)
|
_current = State(initialValue: product)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ungespeicherte Änderungen an den Artikel-Stammdaten? (Foto und Lagerort-
|
||||||
|
/// Bedarf speichern sofort und zählen hier nicht.)
|
||||||
|
private var isDirty: Bool {
|
||||||
|
if name != current.name { return true }
|
||||||
|
if brand != (current.brand ?? "") { return true }
|
||||||
|
if categoryId != current.categoryId { return true }
|
||||||
|
// Gruppe gibt es für alles außer Einzelstücke.
|
||||||
|
if !current.isIndividual, groupId != current.groupId { return true }
|
||||||
|
// Packung/MHD nur bei Lebensmitteln und Verbrauchsgegenständen.
|
||||||
|
if current.foodLike {
|
||||||
|
if packageSize != (current.packageSize.map { formatAmount($0) } ?? "") { return true }
|
||||||
|
if packageLabel != (current.packageLabel ?? "") { return true }
|
||||||
|
if datePrecision != (current.datePrecision == "month" ? "month" : "day") { return true }
|
||||||
|
if zweitBasis != (current.secondaryBase ?? "") { return true }
|
||||||
|
if zweitAnzahl != (current.secondaryCount.map { formatAmount($0) } ?? "") { return true }
|
||||||
|
if zweitMenge != (current.secondaryAmount.map { formatAmount($0) } ?? "") { return true }
|
||||||
|
}
|
||||||
|
if current.isObject {
|
||||||
|
if objMode != currentObjMode { return true }
|
||||||
|
if !current.isIndividual && !current.isBulk, shopId != current.shopId { return true }
|
||||||
|
if !current.isBulk, productUrl != (current.productUrl ?? "") { return true }
|
||||||
|
if fieldValues != Self.stringValues(current.fieldValues) { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attemptLeave() {
|
||||||
|
if isDirty { showLeaveConfirm = true } else { dismiss() }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
if let status {
|
if let status {
|
||||||
@@ -41,12 +92,28 @@ struct ProductDetailView: View {
|
|||||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Bestand") {
|
ProductPhotoSection(productId: current.id)
|
||||||
LabeledContent("Vorrat",
|
|
||||||
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)")
|
if current.isObject && current.isIndividual {
|
||||||
if current.expiredCount > 0 {
|
ProductItemsSection(product: current, onChanged: { await reload() },
|
||||||
LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
|
onFinish: { dismiss() })
|
||||||
.foregroundStyle(.red)
|
} else if current.isObject && !current.isBulk {
|
||||||
|
// Menge je Lagerort (Verbrauchsgegenstände laufen als Charge, s.u.).
|
||||||
|
ObjectStockSection(product: current, locations: locations, lots: lots,
|
||||||
|
removals: objectRemovals, sheet: $objectSheet)
|
||||||
|
} else {
|
||||||
|
// Lebensmittel und Verbrauchsgegenstände: Vorrat aus den Chargen.
|
||||||
|
Section("Bestand") {
|
||||||
|
// Mit Zweiteinheit beide Lesarten: „6 Stück · 500 g".
|
||||||
|
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 {
|
||||||
|
LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,33 +122,146 @@ 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)
|
||||||
QuantityField(label: "Packungsgröße", text: $packageSize,
|
|
||||||
suffix: display.baseUnitLabel(current.baseUnit))
|
|
||||||
Picker("Bezeichnung", selection: $packageLabel) {
|
|
||||||
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
|
||||||
}
|
|
||||||
Picker("MHD-Angabe", selection: $datePrecision) {
|
|
||||||
Text("Tagesdatum").tag("day")
|
|
||||||
Text("nur Monat/Jahr").tag("month")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
// Alles zur Einheit steht zusammen und liest sich als Satz: das
|
||||||
CategoryPicker(categories: categories, selection: $categoryId)
|
// Gebinde zuerst, weil die Zeile darunter es beim Namen nennt.
|
||||||
} footer: {
|
if current.foodLike {
|
||||||
Text("Kategorie: nur für den Überblick in der Artikelliste.")
|
Section("Einheit und Menge") {
|
||||||
}
|
Picker("Gebinde", selection: $packageLabel) {
|
||||||
|
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||||||
Section {
|
}
|
||||||
Picker("Gruppe", selection: $groupId) {
|
QuantityField(label: "1 \(gebindeName) sind", text: $packageSize,
|
||||||
Text("– keine –").tag(Int?.none)
|
suffix: display.baseUnitLabel(current.baseUnit))
|
||||||
ForEach(groups) { gruppe in
|
Picker("MHD-Angabe", selection: $datePrecision) {
|
||||||
Text(gruppe.name).tag(Int?.some(gruppe.id))
|
Text("Tagesdatum").tag("day")
|
||||||
|
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 {
|
||||||
|
CategoryPicker(categories: categories, selection: $categoryId,
|
||||||
|
tracking: current.tracking)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code "
|
Text(current.isObject
|
||||||
+ "wandert bei einem Wechsel mit.")
|
? "Nur Gegenstands-Kategorien. Bestimmt die eigenen Felder."
|
||||||
|
: "Nur Lebensmittel-Kategorien.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verwaltungsart eines Gegenstands umstellen (z.B. Menge je Lagerort →
|
||||||
|
// Einzelstücke). Wirkt nach dem Speichern; der Bestand wird nicht
|
||||||
|
// automatisch umgerechnet.
|
||||||
|
if current.isObject {
|
||||||
|
Section {
|
||||||
|
Picker("Verwaltung", selection: $objMode) {
|
||||||
|
Text("Menge je Lagerort").tag("count")
|
||||||
|
Text("Einzelstücke").tag("individual")
|
||||||
|
Text("Verbrauchsgegenstand").tag("bulk")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text(objMode == currentObjMode
|
||||||
|
? "Wie der Bestand geführt wird."
|
||||||
|
: "Wird beim Speichern umgestellt – vorhandener Bestand wird nicht umgerechnet.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bezugsquelle/Link nur für Menge je Lagerort und Einzelstücke –
|
||||||
|
// Verbrauchsgegenstände laufen wie Lebensmittel und lassen das weg.
|
||||||
|
if current.isObject && !current.isBulk {
|
||||||
|
// „Gekauft bei" gehört bei Einzelstücken zum einzelnen Stück
|
||||||
|
// (jedes kann woanders gekauft sein), nicht ans Modell – wie im
|
||||||
|
// Web wird der Shop hier dann ausgeblendet. Der Produktlink
|
||||||
|
// beschreibt das Produkt allgemein und bleibt.
|
||||||
|
Section(current.isIndividual ? "Produktlink" : "Gekauft bei") {
|
||||||
|
if !current.isIndividual {
|
||||||
|
Picker("Shop", selection: $shopId) {
|
||||||
|
Text("– unbekannt / mehrere –").tag(Int?.none)
|
||||||
|
ForEach(shops) { shop in Text(shop.name).tag(Int?.some(shop.id)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LabeledField(label: "Produktlink", text: $productUrl)
|
||||||
|
if !productUrl.isEmpty, let url = URL(string: productUrl) {
|
||||||
|
Link("Im Onlineshop öffnen", destination: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Gruppe für alles außer Einzelstücke (Lebensmittel, Verbrauchs-
|
||||||
|
// gegenstand, Menge je Lagerort).
|
||||||
|
if !current.isIndividual {
|
||||||
|
Section {
|
||||||
|
Picker("Gruppe", selection: $groupId) {
|
||||||
|
Text("– keine –").tag(Int?.none)
|
||||||
|
// Als Baum, damit sichtbar ist, was unter was haengt.
|
||||||
|
ForEach(gruppenOptionen(groups)) { eintrag in
|
||||||
|
Text(eintrag.label).tag(Int?.some(eintrag.gruppe.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Gruppe: zählt Bestände mehrerer Artikel zusammen. Der EAN-Code "
|
||||||
|
+ "wandert bei einem Wechsel mit.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Eigene Felder für alle Gegenstände mit Kategorie-Feldern.
|
||||||
|
if current.isObject && !fieldDefs.isEmpty {
|
||||||
|
Section("Eigene Felder") {
|
||||||
|
ForEach(fieldDefs) { def in
|
||||||
|
ObjectFieldRow(def: def, value: fieldBinding(def))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mindestbestand bei Lebensmitteln UND Verbrauchsgegenständen (Charge).
|
||||||
|
// Menge je Lagerort und Einzelstücke haben keinen Mindestbestand.
|
||||||
|
//
|
||||||
|
// Es gibt kein separates „Gesamt"-Feld mehr: der Bedarf hängt immer
|
||||||
|
// an einem Ort, und „Überall" ist einer davon (der oberste).
|
||||||
|
if current.foodLike {
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
ProductLocationMinView(product: current) { await reload() }
|
||||||
|
} label: {
|
||||||
|
let zeilen = current.locationMinStocks ?? []
|
||||||
|
let ueberall = zeilen.first { $0.locationId == nil }
|
||||||
|
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: {
|
||||||
|
Text("„Überall“ heißt: egal wo, Hauptsache im Haus – Käufe für einen "
|
||||||
|
+ "Lagerort decken das mit ab.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Erkennung") {
|
Section("Erkennung") {
|
||||||
@@ -96,44 +276,177 @@ struct ProductDetailView: View {
|
|||||||
.disabled(busy || name.isEmpty)
|
.disabled(busy || name.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Chargen") {
|
if current.foodLike {
|
||||||
ForEach(lots) { lot in
|
Section("Chargen") {
|
||||||
Button {
|
ForEach(lots) { lot in
|
||||||
editLot = lot
|
Button {
|
||||||
} label: {
|
editLot = lot
|
||||||
HStack {
|
} label: {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
HStack {
|
||||||
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)")
|
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
// 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)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
Spacer()
|
|
||||||
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
|
||||||
}
|
}
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
Task { await deleteLots(at: indexSet) }
|
||||||
|
}
|
||||||
|
if lots.isEmpty {
|
||||||
|
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.foregroundStyle(.primary)
|
|
||||||
}
|
}
|
||||||
.onDelete { indexSet in
|
}
|
||||||
Task { await deleteLots(at: indexSet) }
|
|
||||||
}
|
Section {
|
||||||
if lots.isEmpty {
|
// Derselbe Verlauf wie im Listen-Tab, nur auf diesen Artikel
|
||||||
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
|
// gefiltert.
|
||||||
|
NavigationLink { HistoryView(productId: current.id) } label: {
|
||||||
|
Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(current.name)
|
.navigationTitle(current.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button { attemptLeave() } label: { Label("Zurück", systemImage: "chevron.left") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog("Ungespeicherte Änderungen", isPresented: $showLeaveConfirm,
|
||||||
|
titleVisibility: .visible) {
|
||||||
|
Button("Speichern") { Task { await save(); dismiss() } }
|
||||||
|
Button("Verwerfen", role: .destructive) { dismiss() }
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Änderungen an Name/Marke/Kategorie sind noch nicht gespeichert. "
|
||||||
|
+ "Foto und Lagerort-Bedarf werden sofort gespeichert.")
|
||||||
|
}
|
||||||
.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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Mengen-Sheets auf der stabilen Form-Ebene (nicht im Abschnitt), sonst
|
||||||
|
// schloss sich das Fenster beim ersten Öffnen sofort wieder.
|
||||||
|
.sheet(item: $objectSheet) { welche in
|
||||||
|
NavigationStack {
|
||||||
|
switch welche {
|
||||||
|
case .add:
|
||||||
|
ObjectAddSheet(product: current, locations: locations,
|
||||||
|
einheit: current.unitName.isEmpty ? "Stück" : current.unitName,
|
||||||
|
perform: { await reload() })
|
||||||
|
case .relocate(let from):
|
||||||
|
ObjectRelocateSheet(product: current, locations: locations, initialFrom: from,
|
||||||
|
perform: { await reload() })
|
||||||
|
case .remove(let loc):
|
||||||
|
ObjectRemoveSheet(product: current, locations: locations,
|
||||||
|
einheit: current.unitName.isEmpty ? "Stück" : current.unitName,
|
||||||
|
initialLoc: loc, perform: { await reload() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
fillForm()
|
fillForm()
|
||||||
await reload()
|
await reload()
|
||||||
}
|
}
|
||||||
|
.onChange(of: categoryId) { newId in
|
||||||
|
// Bei Wechsel auf eine Gegenstands-Kategorie deren Felder nachladen.
|
||||||
|
Task {
|
||||||
|
if let cid = newId, categories.first(where: { $0.id == cid })?.isObject == true {
|
||||||
|
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
|
||||||
|
} else {
|
||||||
|
fieldDefs = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kurzfassung der hinterlegten Mindestbestände für die Übersichtszeile.
|
||||||
|
private func minStockUntertitel(zeilen: [LocationMinStock], ueberall: LocationMinStock?) -> String {
|
||||||
|
if zeilen.isEmpty { return "keiner hinterlegt" }
|
||||||
|
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.
|
||||||
|
private var currentObjMode: String {
|
||||||
|
current.isIndividual ? "individual" : (current.isBulk ? "bulk" : "count")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fillForm() {
|
private func fillForm() {
|
||||||
@@ -142,17 +455,65 @@ 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
|
||||||
|
productUrl = current.productUrl ?? ""
|
||||||
|
objMode = currentObjMode
|
||||||
|
fieldValues = Self.stringValues(current.fieldValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stringValues(_ dict: [String: String?]?) -> [String: String] {
|
||||||
|
guard let dict else { return [:] }
|
||||||
|
var out: [String: String] = [:]
|
||||||
|
for (schluessel, wert) in dict { if let wert { out[schluessel] = wert } }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fieldBinding(_ def: FieldDefinition) -> Binding<String> {
|
||||||
|
let key = String(def.id)
|
||||||
|
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()) ?? []
|
||||||
lots = (try? await APIClient.shared.lots(productId: current.id)) ?? []
|
lots = (try? await APIClient.shared.lots(productId: current.id)) ?? []
|
||||||
|
shops = (try? await APIClient.shared.shops()) ?? []
|
||||||
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
if let frisch = try? await APIClient.shared.product(id: current.id) {
|
if let frisch = try? await APIClient.shared.product(id: current.id) {
|
||||||
current = frisch
|
current = frisch
|
||||||
}
|
}
|
||||||
|
if current.isObject, let cid = current.categoryId {
|
||||||
|
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
|
||||||
|
} else {
|
||||||
|
fieldDefs = []
|
||||||
|
}
|
||||||
|
// Entnahme-Statistik nur für „Menge je Lagerort"-Gegenstände.
|
||||||
|
if current.isObject && !current.isBulk && !current.isIndividual {
|
||||||
|
objectRemovals = try? await APIClient.shared.productRemovals(id: current.id)
|
||||||
|
} else {
|
||||||
|
objectRemovals = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verwaltungsart (individual/bulk) mitschicken, wenn sie umgestellt wurde.
|
||||||
|
private func applyModeChange(_ req: inout ProductUpdateRequest) {
|
||||||
|
guard current.isObject, objMode != currentObjMode else { return }
|
||||||
|
req.individual = objMode == "individual"
|
||||||
|
req.bulk = objMode == "bulk"
|
||||||
|
req.sendMode = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func save() async {
|
private func save() async {
|
||||||
@@ -161,9 +522,11 @@ struct ProductDetailView: View {
|
|||||||
busy = true
|
busy = true
|
||||||
defer { busy = false }
|
defer { busy = false }
|
||||||
do {
|
do {
|
||||||
current = try await APIClient.shared.updateProduct(
|
if current.foodLike {
|
||||||
id: current.id,
|
// Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/
|
||||||
ProductUpdateRequest(
|
// Packung/MHD/Gruppe. Verbrauchsgegenstände (isObject) tragen
|
||||||
|
// zusätzlich eigene Felder und einen Mindestbestand.
|
||||||
|
var req = ProductUpdateRequest(
|
||||||
name: name,
|
name: name,
|
||||||
brand: brand.isEmpty ? nil : brand,
|
brand: brand.isEmpty ? nil : brand,
|
||||||
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
||||||
@@ -172,7 +535,52 @@ struct ProductDetailView: View {
|
|||||||
groupId: groupId,
|
groupId: groupId,
|
||||||
categoryId: categoryId
|
categoryId: categoryId
|
||||||
)
|
)
|
||||||
)
|
// Der Mindestbestand wird in ProductLocationMinView gepflegt und
|
||||||
|
// hier bewusst NICHT mitgeschickt – sonst überschriebe das
|
||||||
|
// Stammdaten-Speichern die dort gesetzte „Überall"-Zeile.
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
var fv: [String: String?] = [:]
|
||||||
|
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
|
||||||
|
req.fieldValues = fv
|
||||||
|
}
|
||||||
|
applyModeChange(&req)
|
||||||
|
current = try await APIClient.shared.updateProduct(id: current.id, req)
|
||||||
|
if current.isObject { fieldValues = Self.stringValues(current.fieldValues) }
|
||||||
|
} else {
|
||||||
|
// Gegenstand: Menge je Lagerort oder Einzelstücke. Kein
|
||||||
|
// Mindestbestand (Alt-Werte werden geleert); Menge-Gegenstände
|
||||||
|
// dürfen weiter in eine Gruppe.
|
||||||
|
var fv: [String: String?] = [:]
|
||||||
|
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
|
||||||
|
var req = ProductUpdateRequest(
|
||||||
|
name: name,
|
||||||
|
brand: brand.isEmpty ? nil : brand,
|
||||||
|
packageSize: nil, packageLabel: nil, datePrecision: nil,
|
||||||
|
groupId: current.isIndividual ? nil : groupId, categoryId: categoryId,
|
||||||
|
// Einzelstücke tragen den Shop je Stück – nichts ans Modell.
|
||||||
|
shopId: current.isIndividual ? nil : shopId,
|
||||||
|
productUrl: productUrl.isEmpty ? nil : productUrl,
|
||||||
|
fieldValues: fv,
|
||||||
|
minStock: nil,
|
||||||
|
minStockInPackages: false,
|
||||||
|
minStockUnitId: nil,
|
||||||
|
sendMinStock: true
|
||||||
|
)
|
||||||
|
applyModeChange(&req)
|
||||||
|
current = try await APIClient.shared.updateProduct(id: current.id, req)
|
||||||
|
fieldValues = Self.stringValues(current.fieldValues)
|
||||||
|
}
|
||||||
status = "Gespeichert."
|
status = "Gespeichert."
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
@@ -192,10 +600,47 @@ struct ProductDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Menge und MHD einer Charge korrigieren.
|
/// Lädt ein Produkt anhand seiner ID nach und zeigt dann die Detailansicht –
|
||||||
|
/// z.B. beim Antippen einer Bewegung im Verlauf oder auf der Startseite.
|
||||||
|
struct ProductByIdView: View {
|
||||||
|
let productId: Int
|
||||||
|
|
||||||
|
@State private var product: Product?
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let product {
|
||||||
|
ProductDetailView(product: product)
|
||||||
|
} else if let error {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(error).foregroundStyle(.red).font(.callout)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Erneut versuchen") { Task { await load() } }
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { if product == nil { await load() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
do {
|
||||||
|
product = try await APIClient.shared.product(id: productId)
|
||||||
|
error = nil
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
@@ -204,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?
|
||||||
|
|
||||||
@@ -229,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) }
|
||||||
}
|
}
|
||||||
@@ -243,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()
|
||||||
@@ -279,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()
|
||||||
@@ -289,3 +755,108 @@ struct LotEditView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Mindestbestand je Lagerort
|
||||||
|
|
||||||
|
/// Bedarf eines Produkts je Lagerort bearbeiten (zusätzlich zum globalen
|
||||||
|
/// Mindestbestand). Menge in Artikeleinheiten.
|
||||||
|
struct ProductLocationMinView: View {
|
||||||
|
let product: Product
|
||||||
|
var onChanged: (() async -> Void)? = nil
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private struct MinRow: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var locationId: String?
|
||||||
|
var amount: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var locations: [StorageLocation] = []
|
||||||
|
@State private var rows: [MinRow] = []
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
// Gespeichert wird in Basiseinheiten, erfasst in der Anzeigeeinheit (g/ml/Stück).
|
||||||
|
private var dispFaktor: Double { product.unitFactor == 0 ? 1 : product.unitFactor }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
ForEach($rows) { $row in
|
||||||
|
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) {
|
||||||
|
rows.removeAll { $0.id == row.id }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
rows.append(MinRow(locationId: UEBERALL_ID, amount: ""))
|
||||||
|
} label: {
|
||||||
|
Label("Mindestbestand hinzufügen", systemImage: "plus")
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Mindestbestand")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
|
||||||
|
.disabled(busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
|
// „Überall" ist der Ort nil – im Picker braucht es einen Platzhalter.
|
||||||
|
rows = (product.locationMinStocks ?? []).map {
|
||||||
|
MinRow(locationId: $0.locationId ?? UEBERALL_ID,
|
||||||
|
amount: formatAmount($0.minStock / dispFaktor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
var list: [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 Anzeigeeinheit → Basiseinheiten (so gespeichert).
|
||||||
|
list.append(LocationMinStockIn(
|
||||||
|
locationId: loc == UEBERALL_ID ? nil : loc,
|
||||||
|
minStock: wert * dispFaktor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try await APIClient.shared.setProductLocationMinStock(id: product.id, list)
|
||||||
|
await onChanged?()
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
280
ios/Sources/ProductPhotoView.swift
Normal file
280
ios/Sources/ProductPhotoView.swift
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
|
/// Zwischenspeicher für Artikelbilder – im Speicher **und auf der Platte**, damit
|
||||||
|
/// die Bilder einen App-Neustart überleben und in Listen sofort da sind. Zu jedem
|
||||||
|
/// Bild wird die Version (`image_version` des Produkts) gemerkt; so lädt `preload`
|
||||||
|
/// nur fehlende oder geänderte Bilder neu. „Kein Bild" wird ebenfalls gemerkt.
|
||||||
|
final class ProductImageCache {
|
||||||
|
static let shared = ProductImageCache()
|
||||||
|
|
||||||
|
private struct Index: Codable { var versions: [Int: Int] = [:]; var empty: [Int] = [] }
|
||||||
|
|
||||||
|
private let memory = NSCache<NSNumber, UIImage>()
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let dir: URL
|
||||||
|
private var index = Index()
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||||
|
dir = base.appendingPathComponent("product-images", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
if let data = try? Data(contentsOf: indexURL),
|
||||||
|
let decoded = try? JSONDecoder().decode(Index.self, from: data) {
|
||||||
|
index = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var indexURL: URL { dir.appendingPathComponent("index.json") }
|
||||||
|
private func fileURL(_ id: Int) -> URL { dir.appendingPathComponent("\(id).img") }
|
||||||
|
private func saveIndex() {
|
||||||
|
if let data = try? JSONEncoder().encode(index) {
|
||||||
|
try? data.write(to: indexURL, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bild aus dem Speicher, sonst von der Platte (überlebt Neustart).
|
||||||
|
func image(for id: Int) -> UIImage? {
|
||||||
|
if let ui = memory.object(forKey: NSNumber(value: id)) { return ui }
|
||||||
|
guard let data = try? Data(contentsOf: fileURL(id)), let ui = UIImage(data: data) else { return nil }
|
||||||
|
memory.setObject(ui, forKey: NSNumber(value: id))
|
||||||
|
return ui
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKnownEmpty(_ id: Int) -> Bool {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return index.empty.contains(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bild speichern. `version` = `image_version` des Produkts (nil, wenn unbekannt,
|
||||||
|
/// z.B. beim Lazy-Nachladen einzelner Thumbnails).
|
||||||
|
func store(_ image: UIImage, data: Data, version: Int?, for id: Int) {
|
||||||
|
memory.setObject(image, forKey: NSNumber(value: id))
|
||||||
|
try? data.write(to: fileURL(id), options: .atomic)
|
||||||
|
lock.lock()
|
||||||
|
index.empty.removeAll { $0 == id }
|
||||||
|
if let version { index.versions[id] = version }
|
||||||
|
saveIndex()
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markEmpty(_ id: Int) {
|
||||||
|
memory.removeObject(forKey: NSNumber(value: id))
|
||||||
|
try? FileManager.default.removeItem(at: fileURL(id))
|
||||||
|
lock.lock()
|
||||||
|
index.versions[id] = nil
|
||||||
|
if !index.empty.contains(id) { index.empty.append(id) }
|
||||||
|
saveIndex()
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isFresh(_ id: Int, _ serverVersion: Int) -> Bool {
|
||||||
|
lock.lock(); let known = index.versions[id]; lock.unlock()
|
||||||
|
return known == serverVersion && FileManager.default.fileExists(atPath: fileURL(id).path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Bilder einer Produktliste sicherstellen – nur fehlende/geänderte werden geladen.
|
||||||
|
/// Beim Öffnen der Produkt-/Einzelstück-Liste aufrufen.
|
||||||
|
func preload(_ products: [Product]) async {
|
||||||
|
for p in products {
|
||||||
|
guard let sv = p.imageVersion else { markEmpty(p.id); continue }
|
||||||
|
if isFresh(p.id, sv) { continue }
|
||||||
|
if let data = try? await APIClient.shared.productImage(id: p.id),
|
||||||
|
let ui = UIImage(data: data) {
|
||||||
|
store(ui, data: data, version: sv, for: p.id)
|
||||||
|
} else {
|
||||||
|
markEmpty(p.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kleines Vorschaubild für Listen: zeigt das Artikelbild, sonst das neutrale
|
||||||
|
/// Kästchen-Symbol wie bisher. Ergebnisse kommen aus dem gemeinsamen Cache.
|
||||||
|
struct ProductThumb: View {
|
||||||
|
let productId: Int
|
||||||
|
@State private var image: UIImage?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.task(id: productId) { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var content: some View {
|
||||||
|
if let image {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable().scaledToFill()
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "shippingbox")
|
||||||
|
.font(.body).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
if let cached = ProductImageCache.shared.image(for: productId) {
|
||||||
|
image = cached
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ProductImageCache.shared.isKnownEmpty(productId) { return }
|
||||||
|
guard let data = try? await APIClient.shared.productImage(id: productId),
|
||||||
|
let ui = UIImage(data: data) else {
|
||||||
|
ProductImageCache.shared.markEmpty(productId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ProductImageCache.shared.store(ui, data: data, version: nil, for: productId)
|
||||||
|
image = ui
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artikelfoto ansehen, per Kamera aufnehmen oder aus der Galerie wählen und
|
||||||
|
/// hochladen. Wird als Abschnitt in die Produkt-Detailansicht eingebettet.
|
||||||
|
struct ProductPhotoSection: View {
|
||||||
|
let productId: Int
|
||||||
|
|
||||||
|
@State private var imageData: Data?
|
||||||
|
@State private var loaded = false
|
||||||
|
@State private var busy = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var showCamera = false
|
||||||
|
@State private var pickerItem: PhotosPickerItem?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section("Foto") {
|
||||||
|
if let imageData, let ui = UIImage(data: imageData) {
|
||||||
|
Image(uiImage: ui)
|
||||||
|
.resizable().scaledToFit()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(maxHeight: 240)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
} else if loaded {
|
||||||
|
Text("Noch kein Foto.").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button { showCamera = true } label: {
|
||||||
|
Label("Foto machen", systemImage: "camera")
|
||||||
|
}
|
||||||
|
PhotosPicker(selection: $pickerItem, matching: .images) {
|
||||||
|
Label("Aus Galerie", systemImage: "photo.on.rectangle")
|
||||||
|
}
|
||||||
|
if imageData != nil {
|
||||||
|
Button(role: .destructive) { Task { await removePhoto() } } label: {
|
||||||
|
Label("Foto entfernen", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if busy { ProgressView() }
|
||||||
|
if let error { Text(error).foregroundStyle(.red).font(.callout) }
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $showCamera) {
|
||||||
|
CameraPicker(isPresented: $showCamera) { image in Task { await upload(image) } }
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
.onChange(of: pickerItem) { item in
|
||||||
|
guard let item else { return }
|
||||||
|
Task {
|
||||||
|
if let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
let ui = UIImage(data: data) {
|
||||||
|
await upload(ui)
|
||||||
|
}
|
||||||
|
pickerItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
imageData = try? await APIClient.shared.productImage(id: productId)
|
||||||
|
// Listen-Cache mit der Wahrheit abgleichen.
|
||||||
|
if let imageData, let ui = UIImage(data: imageData) {
|
||||||
|
ProductImageCache.shared.store(ui, data: imageData, version: nil, for: productId)
|
||||||
|
} else {
|
||||||
|
ProductImageCache.shared.markEmpty(productId)
|
||||||
|
}
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func upload(_ image: UIImage) async {
|
||||||
|
guard let data = productImageJPEG(image) else { return }
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
_ = try await APIClient.shared.uploadProductImage(
|
||||||
|
id: productId, data: data, contentType: "image/jpeg")
|
||||||
|
imageData = data
|
||||||
|
if let ui = UIImage(data: data) { ProductImageCache.shared.store(ui, data: data, version: nil, for: productId) }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removePhoto() async {
|
||||||
|
busy = true
|
||||||
|
defer { busy = false }
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
try await APIClient.shared.deleteProductImage(id: productId)
|
||||||
|
imageData = nil
|
||||||
|
ProductImageCache.shared.markEmpty(productId)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Foto verkleinern und als JPEG unter die Server-Grenze (2 MB) drücken.
|
||||||
|
/// Frei (nicht an eine View gebunden), damit auch das Anlege-Formular es nutzt.
|
||||||
|
func productImageJPEG(_ image: UIImage, maxDim: CGFloat = 1600) -> Data? {
|
||||||
|
let size = image.size
|
||||||
|
let scale = min(1, maxDim / max(size.width, size.height))
|
||||||
|
let target = CGSize(width: size.width * scale, height: size.height * scale)
|
||||||
|
let scaled = UIGraphicsImageRenderer(size: target).image { _ in
|
||||||
|
image.draw(in: CGRect(origin: .zero, size: target))
|
||||||
|
}
|
||||||
|
var quality: CGFloat = 0.8
|
||||||
|
var data = scaled.jpegData(compressionQuality: quality)
|
||||||
|
while let d = data, d.count > 1_800_000, quality > 0.3 {
|
||||||
|
quality -= 0.15
|
||||||
|
data = scaled.jpegData(compressionQuality: quality)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kamera über UIImagePickerController – SwiftUI hat keine eigene Kamera-Ansicht.
|
||||||
|
/// Ohne Kamera (Simulator) fällt es auf die Fotoauswahl zurück.
|
||||||
|
struct CameraPicker: UIViewControllerRepresentable {
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
var onImage: (UIImage) -> Void
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
|
let picker = UIImagePickerController()
|
||||||
|
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera)
|
||||||
|
? .camera : .photoLibrary
|
||||||
|
picker.delegate = context.coordinator
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ picker: UIImagePickerController, context: Context) {}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||||
|
|
||||||
|
// Schließen deterministisch über das Binding statt über die Umgebung –
|
||||||
|
// das behebt das Flackern (Kamera geht direkt wieder zu), wenn die Ansicht
|
||||||
|
// während der Präsentation neu ausgewertet wird.
|
||||||
|
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
|
||||||
|
let parent: CameraPicker
|
||||||
|
init(_ parent: CameraPicker) { self.parent = parent }
|
||||||
|
|
||||||
|
func imagePickerController(_ picker: UIImagePickerController,
|
||||||
|
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||||
|
if let image = info[.originalImage] as? UIImage { parent.onImage(image) }
|
||||||
|
parent.isPresented = false
|
||||||
|
}
|
||||||
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
|
parent.isPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
/// Artikel per Namenssuche auswählen (Alternative zum Scannen).
|
/// Artikel per Namenssuche auswählen (Alternative zum Scannen).
|
||||||
struct ProductSearchView: View {
|
struct ProductSearchView: View {
|
||||||
@@ -53,7 +54,12 @@ struct ProductSearchView: View {
|
|||||||
struct ProductFormView: View {
|
struct ProductFormView: View {
|
||||||
let prefillBarcode: String?
|
let prefillBarcode: String?
|
||||||
let groupId: Int?
|
let groupId: Int?
|
||||||
|
/// Aus der Open-Food-Facts-Einordnung vorgeschlagene Kategorie.
|
||||||
|
var categoryId: Int?
|
||||||
var suggestion: LookupResult.Suggestion?
|
var suggestion: LookupResult.Suggestion?
|
||||||
|
/// Vorbelegter Typ ("food"/"object"), z.B. aus der Lebensmittel- oder
|
||||||
|
/// Gegenstände-Liste heraus. Ein Kategorie-Vorschlag hat weiter Vorrang.
|
||||||
|
var initialType: String?
|
||||||
var onCreated: (Product) -> Void
|
var onCreated: (Product) -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -68,61 +74,185 @@ struct ProductFormView: View {
|
|||||||
@State private var datePrecision = "day"
|
@State private var datePrecision = "day"
|
||||||
@State private var selectedGroupId: Int?
|
@State private var selectedGroupId: Int?
|
||||||
@State private var selectedCategoryId: Int?
|
@State private var selectedCategoryId: Int?
|
||||||
|
// Umschalter Lebensmittel/Gegenstand: filtert die Kategorieauswahl.
|
||||||
|
@State private var modus = "object"
|
||||||
|
// Verwaltungsart eines Gegenstands: "count" (Menge je Lagerort),
|
||||||
|
// "individual" (Einzelstücke) oder "bulk" (Verbrauchsgegenstand wie Lebensmittel).
|
||||||
|
@State private var objMode = "count"
|
||||||
@State private var groups: [GroupItem] = []
|
@State private var groups: [GroupItem] = []
|
||||||
@State private var categories: [CategoryItem] = []
|
@State private var categories: [CategoryItem] = []
|
||||||
@State private var units: [Unit] = []
|
@State private var units: [Unit] = []
|
||||||
@State private var newGroupShown = false
|
@State private var newGroupShown = false
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
// Foto schon beim Anlegen: erst nach dem Anlegen hochladen (ID nötig).
|
||||||
|
@State private var pendingImage: UIImage?
|
||||||
|
@State private var showCamera = false
|
||||||
|
@State private var pickerItem: PhotosPickerItem?
|
||||||
|
// Eigene Felder der (Gegenstands-)Kategorie – schon beim Anlegen ausfüllbar.
|
||||||
|
@State private var fieldDefs: [FieldDefinition] = []
|
||||||
|
@State private var fieldValues: [String: String] = [:]
|
||||||
|
// Text-Scan für Name/Marke (statt abtippen), wenn ein Produkt neu ist.
|
||||||
|
@State private var scanTarget: TextScanTarget?
|
||||||
|
|
||||||
|
enum TextScanTarget: Identifiable {
|
||||||
|
case name, brand
|
||||||
|
var id: Int { self == .name ? 0 : 1 }
|
||||||
|
var titel: String { self == .name ? "Name scannen" : "Marke scannen" }
|
||||||
|
}
|
||||||
|
|
||||||
private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")]
|
private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")]
|
||||||
// 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
|
||||||
|
/// Verbrauchsgegenstand.
|
||||||
|
private var foodLike: Bool { modus == "food" || objMode == "bulk" }
|
||||||
|
/// Gruppe anbieten: Lebensmittel, Verbrauchsgegenstand oder Menge je Lagerort
|
||||||
|
/// (nicht bei Einzelstücken).
|
||||||
|
private var showGroup: Bool { modus == "food" || (modus == "object" && objMode != "individual") }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section("Artikel") {
|
Section("Artikel") {
|
||||||
LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad)
|
LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad)
|
||||||
LabeledField(label: "Name", text: $name)
|
LabeledField(label: "Name", text: $name)
|
||||||
|
if TextScanView.isSupported {
|
||||||
|
Button { scanTarget = .name } label: { Label("Name scannen", systemImage: "text.viewfinder") }
|
||||||
|
}
|
||||||
LabeledField(label: "Marke", text: $brand)
|
LabeledField(label: "Marke", text: $brand)
|
||||||
}
|
if TextScanView.isSupported {
|
||||||
Section {
|
Button { scanTarget = .brand } label: { Label("Marke scannen", systemImage: "text.viewfinder") }
|
||||||
CategoryPicker(categories: categories, selection: $selectedCategoryId)
|
|
||||||
} footer: {
|
|
||||||
Text("Kategorie: nur für den Überblick in der Artikelliste.")
|
|
||||||
}
|
|
||||||
Section {
|
|
||||||
Picker("Gruppe", selection: $selectedGroupId) {
|
|
||||||
Text("– keine –").tag(Int?.none)
|
|
||||||
ForEach(groups) { gruppe in
|
|
||||||
Text(gruppe.name).tag(Int?.some(gruppe.id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if session.isAdmin {
|
}
|
||||||
Button {
|
Section {
|
||||||
newGroupShown = true
|
Picker("Typ", selection: $modus) {
|
||||||
} label: {
|
Text("Lebensmittel").tag("food")
|
||||||
Label("Neue Gruppe anlegen", systemImage: "plus")
|
Text("Gegenstand").tag("object")
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
CategoryPicker(categories: categories, selection: $selectedCategoryId, tracking: modus)
|
||||||
|
if modus == "object" {
|
||||||
|
Picker("Verwaltung", selection: $objMode) {
|
||||||
|
Text("Menge je Lagerort").tag("count")
|
||||||
|
Text("Einzelstücke").tag("individual")
|
||||||
|
Text("Verbrauchsgegenstand").tag("bulk")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code "
|
Text(modus == "object"
|
||||||
+ "dieses Artikels erscheint danach automatisch bei der Gruppe.")
|
? (objMode == "individual"
|
||||||
|
? "Einzelstücke: jedes Stück mit eigener UID/QR, Kaufdatum, Garantie."
|
||||||
|
: objMode == "bulk"
|
||||||
|
? "Verbrauchsgegenstand: wie ein Lebensmittel als Charge mit Menge und Einheit (z.B. Sonnencreme in ml) – nur ohne eindeutigen Code."
|
||||||
|
: "Gegenstand: Menge je Lagerort und eigene Felder.")
|
||||||
|
: "Lebensmittel: Chargen mit Mindesthaltbarkeit.")
|
||||||
}
|
}
|
||||||
Section("Einheit") {
|
.onChange(of: modus) { neu in
|
||||||
Picker("Basiseinheit", selection: $baseUnit) {
|
// Passt die gewählte Kategorie nicht mehr zum Typ, Auswahl leeren.
|
||||||
ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) }
|
if let cid = selectedCategoryId,
|
||||||
}
|
categories.first(where: { $0.id == cid })?.tracking != neu {
|
||||||
QuantityField(label: "Packungsgröße", text: $packageSize,
|
selectedCategoryId = nil
|
||||||
suffix: DisplaySettings.baseUnitLabel(baseUnit))
|
|
||||||
Picker("Bezeichnung", selection: $packageLabel) {
|
|
||||||
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("MHD") {
|
// Gruppe: Lebensmittel, Verbrauchsgegenstand und Menge je Lagerort
|
||||||
Picker("Angabe", selection: $datePrecision) {
|
// (nur Einzelstücke bleiben außen vor).
|
||||||
Text("Tagesdatum").tag("day")
|
if showGroup {
|
||||||
Text("nur Monat/Jahr").tag("month")
|
Section {
|
||||||
|
Picker("Gruppe", selection: $selectedGroupId) {
|
||||||
|
Text("– keine –").tag(Int?.none)
|
||||||
|
// Als Baum, damit sichtbar ist, was unter was haengt.
|
||||||
|
ForEach(gruppenOptionen(groups)) { eintrag in
|
||||||
|
Text(eintrag.label).tag(Int?.some(eintrag.gruppe.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session.isAdmin {
|
||||||
|
Button {
|
||||||
|
newGroupShown = true
|
||||||
|
} label: {
|
||||||
|
Label("Neue Gruppe anlegen", systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Gruppe: zählt Bestände mehrerer Artikel zusammen. Der EAN-Code "
|
||||||
|
+ "dieses Artikels erscheint danach automatisch bei der Gruppe.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Einheit + MHD nur für Lebensmittel und Verbrauchsgegenstände (Charge).
|
||||||
|
if foodLike {
|
||||||
|
Section("Einheit") {
|
||||||
|
Picker("Basiseinheit", selection: $baseUnit) {
|
||||||
|
ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) }
|
||||||
|
}
|
||||||
|
QuantityField(label: "Packungsgröße", text: $packageSize,
|
||||||
|
suffix: DisplaySettings.baseUnitLabel(baseUnit))
|
||||||
|
Picker("Bezeichnung", selection: $packageLabel) {
|
||||||
|
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section("MHD") {
|
||||||
|
Picker("Angabe", selection: $datePrecision) {
|
||||||
|
Text("Tagesdatum").tag("day")
|
||||||
|
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
|
||||||
|
// T-Shirt) – schon beim Anlegen ausfüllbar, nicht erst danach.
|
||||||
|
if !fieldDefs.isEmpty {
|
||||||
|
Section("Eigene Felder") {
|
||||||
|
ForEach(fieldDefs) { def in
|
||||||
|
ObjectFieldRow(def: def, value: fieldBinding(def))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Foto direkt beim Anlegen – z.B. wenn Open Food/Products Facts keins
|
||||||
|
// liefert. Wird nach dem Anlegen hochgeladen (dann gibt es eine ID).
|
||||||
|
Section("Foto") {
|
||||||
|
if let pendingImage {
|
||||||
|
Image(uiImage: pendingImage)
|
||||||
|
.resizable().scaledToFit()
|
||||||
|
.frame(maxWidth: .infinity).frame(maxHeight: 200)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
Button { showCamera = true } label: { Label("Foto machen", systemImage: "camera") }
|
||||||
|
PhotosPicker(selection: $pickerItem, matching: .images) {
|
||||||
|
Label("Aus Galerie", systemImage: "photo.on.rectangle")
|
||||||
|
}
|
||||||
|
if pendingImage != nil {
|
||||||
|
Button(role: .destructive) { pendingImage = nil } label: {
|
||||||
|
Label("Foto entfernen", systemImage: "trash")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let error {
|
if let error {
|
||||||
@@ -135,11 +265,47 @@ struct ProductFormView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Neuer Artikel")
|
.navigationTitle("Neuer Artikel")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.fullScreenCover(isPresented: $showCamera) {
|
||||||
|
CameraPicker(isPresented: $showCamera) { image in pendingImage = image }
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
.fullScreenCover(item: $scanTarget) { target in
|
||||||
|
NavigationStack {
|
||||||
|
TextScanView(titel: target.titel) { text in
|
||||||
|
if target == .name { name = text } else { brand = text }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: pickerItem) { item in
|
||||||
|
guard let item else { return }
|
||||||
|
Task {
|
||||||
|
if let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
let ui = UIImage(data: data) {
|
||||||
|
pendingImage = ui
|
||||||
|
}
|
||||||
|
pickerItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: selectedCategoryId) { cid in
|
||||||
|
// Bei einer Gegenstands-Kategorie deren (vererbte) Felder nachladen.
|
||||||
|
Task {
|
||||||
|
if let cid, categories.first(where: { $0.id == cid })?.isObject == true {
|
||||||
|
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
|
||||||
|
} else {
|
||||||
|
fieldDefs = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.onAppear(perform: prefill)
|
.onAppear(perform: prefill)
|
||||||
.task {
|
.task {
|
||||||
groups = (try? await APIClient.shared.groups()) ?? []
|
groups = (try? await APIClient.shared.groups()) ?? []
|
||||||
categories = (try? await APIClient.shared.categories()) ?? []
|
categories = (try? await APIClient.shared.categories()) ?? []
|
||||||
units = (try? await APIClient.shared.units()) ?? []
|
units = (try? await APIClient.shared.units()) ?? []
|
||||||
|
// Kam eine Kategorie aus dem Barcode-Vorschlag, den Typ dazu setzen.
|
||||||
|
if let cid = selectedCategoryId,
|
||||||
|
let t = categories.first(where: { $0.id == cid })?.tracking {
|
||||||
|
modus = t
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $newGroupShown) {
|
.sheet(isPresented: $newGroupShown) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -151,15 +317,82 @@ struct ProductFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fieldBinding(_ def: FieldDefinition) -> Binding<String> {
|
||||||
|
let key = String(def.id)
|
||||||
|
return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Werte der geltenden eigenen Felder als {feld_id: Wert} – nil, wenn keine.
|
||||||
|
private func eigeneFeldwerte() -> [String: String?]? {
|
||||||
|
guard !fieldDefs.isEmpty else { return nil }
|
||||||
|
var fv: [String: String?] = [:]
|
||||||
|
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
|
||||||
|
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 }
|
||||||
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
||||||
// Aus dem gescannten Code vorgeschlagene Gruppe uebernehmen.
|
// Aus dem gescannten Code vorgeschlagene Gruppe und Kategorie
|
||||||
|
// uebernehmen. Beides bleibt aenderbar - gespeichert wird erst mit
|
||||||
|
// "Anlegen & weiter".
|
||||||
selectedGroupId = groupId
|
selectedGroupId = groupId
|
||||||
|
selectedCategoryId = categoryId
|
||||||
if let suggestion {
|
if let suggestion {
|
||||||
name = suggestion.name
|
name = suggestion.name
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,9 +412,23 @@ struct ProductFormView: View {
|
|||||||
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
||||||
datePrecision: datePrecision,
|
datePrecision: datePrecision,
|
||||||
groupId: selectedGroupId,
|
groupId: selectedGroupId,
|
||||||
categoryId: selectedCategoryId
|
categoryId: selectedCategoryId,
|
||||||
|
individual: modus == "object" ? (objMode == "individual") : nil,
|
||||||
|
bulk: modus == "object" ? (objMode == "bulk") : nil,
|
||||||
|
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
|
||||||
|
// zunichtemachen, der Artikel existiert bereits.
|
||||||
|
if let pendingImage, let data = productImageJPEG(pendingImage) {
|
||||||
|
_ = try? await APIClient.shared.uploadProductImage(
|
||||||
|
id: product.id, data: data, contentType: "image/jpeg")
|
||||||
|
}
|
||||||
onCreated(product)
|
onCreated(product)
|
||||||
dismiss()
|
dismiss()
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ struct RootView: View {
|
|||||||
@EnvironmentObject private var session: Session
|
@EnvironmentObject private var session: Session
|
||||||
@EnvironmentObject private var router: Router
|
@EnvironmentObject private var router: Router
|
||||||
@EnvironmentObject private var display: DisplaySettings
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
/// Über einen gescannten Universal Link geöffnetes Einzelstück.
|
||||||
|
@State private var linkedItem: Item?
|
||||||
|
@State private var linkError: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -13,19 +16,61 @@ struct RootView: View {
|
|||||||
LoginView()
|
LoginView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Anzeigeeinstellungen (Datumsformat) gehoeren dem Server.
|
// Anzeigeeinstellungen (Datumsformat) gehoeren dem Server - bei einem
|
||||||
.task(id: session.isLoggedIn) {
|
// Serverwechsel muessen sie deshalb neu geladen werden, sonst zeigt
|
||||||
if session.isLoggedIn { await display.load() }
|
// Server B die Formate von Server A.
|
||||||
|
.task(id: session.activeProfileID) {
|
||||||
|
if session.isLoggedIn {
|
||||||
|
await display.load()
|
||||||
|
await session.refreshMe()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt.
|
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt; eine
|
||||||
|
// getippte Benachrichtigung öffnet „Bald ablaufend".
|
||||||
.fullScreenCover(item: $router.route) { route in
|
.fullScreenCover(item: $router.route) { route in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
switch route {
|
switch route {
|
||||||
case .checkin: CheckInView()
|
case .checkin: CheckInView()
|
||||||
case .checkout: CheckOutView()
|
case .checkout: CheckOutView()
|
||||||
|
case .lookup: LookupScanView()
|
||||||
|
case .assign: AssignScanView()
|
||||||
|
case .expiring:
|
||||||
|
ExpiringView()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Fertig") { router.route = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task(id: router.openItemUid) {
|
||||||
|
guard let uid = router.openItemUid else { return }
|
||||||
|
// openItemUid NICHT vor dem Abruf zurücksetzen: das ändert die
|
||||||
|
// .task-id und SwiftUI bricht dadurch die laufende Anfrage ab
|
||||||
|
// (URLError.cancelled). Erst nach dem Laden auf nil setzen.
|
||||||
|
let host = session.activeProfile?.url?.host ?? "?"
|
||||||
|
if session.isLoggedIn {
|
||||||
|
do {
|
||||||
|
linkedItem = try await APIClient.shared.itemByUid(uid: uid)
|
||||||
|
} catch {
|
||||||
|
linkError = "Einzelstück \(uid) auf Server \(host) nicht gefunden. "
|
||||||
|
+ "Zeigt der QR-Code auf denselben Server wie der oben gewählte? "
|
||||||
|
+ "(\(error.localizedDescription))"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
linkError = "Bitte zuerst am Server \(host) anmelden, dann erneut scannen."
|
||||||
|
}
|
||||||
|
router.openItemUid = nil
|
||||||
|
}
|
||||||
|
.sheet(item: $linkedItem) { it in
|
||||||
|
NavigationStack { ItemEditView(item: it) }
|
||||||
|
}
|
||||||
|
.alert("Einzelstück öffnen", isPresented: Binding(
|
||||||
|
get: { linkError != nil }, set: { if !$0 { linkError = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK", role: .cancel) { linkError = nil }
|
||||||
|
} message: { Text(linkError ?? "") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +80,44 @@ extension Route: Identifiable {
|
|||||||
|
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@EnvironmentObject private var session: Session
|
@EnvironmentObject private var session: Session
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
DashboardView()
|
||||||
|
.tabItem { Label("Start", systemImage: "house") }
|
||||||
|
|
||||||
|
ScanTabView()
|
||||||
|
.tabItem { Label("Scannen", systemImage: "barcode.viewfinder") }
|
||||||
|
|
||||||
|
ListsTabView()
|
||||||
|
.tabItem { Label("Listen", systemImage: "list.bullet") }
|
||||||
|
|
||||||
|
// Stammdaten gehoeren Administratoren - dieselbe Regel wie in der
|
||||||
|
// Web-Oberflaeche (web/src/App.jsx).
|
||||||
|
if session.isAdmin {
|
||||||
|
AdminTabView()
|
||||||
|
.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.
|
||||||
|
struct ScanTabView: View {
|
||||||
@EnvironmentObject private var router: Router
|
@EnvironmentObject private var router: Router
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
@State private var showNew = false
|
||||||
|
@State private var showKassenzettel = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -54,12 +136,91 @@ struct HomeView: View {
|
|||||||
ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen",
|
ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen",
|
||||||
systemImage: "arrow.up.to.line")
|
systemImage: "arrow.up.to.line")
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
router.route = .lookup
|
||||||
|
} label: {
|
||||||
|
ActionTile(title: "Nachschlagen",
|
||||||
|
subtitle: "Barcode/QR scannen und nur ansehen",
|
||||||
|
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
|
||||||
|
// es keinen Code zum Scannen. Deshalb hier ein offensichtlicher Weg.
|
||||||
|
if session.isAdmin {
|
||||||
|
Button {
|
||||||
|
showNew = true
|
||||||
|
} label: {
|
||||||
|
ActionTile(title: "Artikel anlegen",
|
||||||
|
subtitle: "Ohne Barcode – Name, Kategorie, eigene Felder",
|
||||||
|
systemImage: "plus")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
router.route = .assign
|
||||||
|
} label: {
|
||||||
|
ActionTile(title: "Ort zuordnen",
|
||||||
|
subtitle: "Einzelstück-QR scannen, dann Lagerort-QR",
|
||||||
|
systemImage: "mappin.and.ellipse")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding()
|
||||||
|
.navigationTitle("Scannen")
|
||||||
|
.fullScreenCover(isPresented: $showKassenzettel) { KassenzettelView() }
|
||||||
|
.sheet(isPresented: $showNew) {
|
||||||
|
NavigationStack {
|
||||||
|
ProductFormView(prefillBarcode: nil, groupId: nil) { _ in
|
||||||
|
showNew = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ListsTabView: View {
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
// Getrennte Produktlisten wie im Web: Lebensmittel und Gegenstände
|
||||||
|
// nebeneinander als halbbreite Kacheln.
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
NavigationLink {
|
||||||
|
ProductListView(fixedType: "food")
|
||||||
|
} label: {
|
||||||
|
CompactTile(title: "Lebensmittel", systemImage: "fork.knife")
|
||||||
|
}
|
||||||
|
NavigationLink {
|
||||||
|
ProductListView(fixedType: "object")
|
||||||
|
} label: {
|
||||||
|
CompactTile(title: "Gegenstände", systemImage: "shippingbox")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NavigationLink {
|
||||||
|
ItemListView()
|
||||||
|
} label: {
|
||||||
|
ActionTile(title: "Einzelstücke", subtitle: "Alle Exemplare – filterbar nach Lagerort",
|
||||||
|
systemImage: "tag")
|
||||||
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ShoppingListView()
|
ShoppingListView()
|
||||||
} label: {
|
} label: {
|
||||||
ActionTile(title: "Einkaufsliste", subtitle: "Was unter dem Mindestbestand liegt",
|
ActionTile(title: "Einkaufsliste", subtitle: "Was unter dem Mindestbestand liegt",
|
||||||
systemImage: "cart")
|
systemImage: "cart")
|
||||||
}
|
}
|
||||||
|
NavigationLink {
|
||||||
|
MinStockView()
|
||||||
|
} label: {
|
||||||
|
ActionTile(title: "Mindestbestände", subtitle: "Soll je Artikel ansehen und pflegen",
|
||||||
|
systemImage: "checklist")
|
||||||
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ExpiringView()
|
ExpiringView()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -67,29 +228,43 @@ struct HomeView: View {
|
|||||||
systemImage: "clock")
|
systemImage: "clock")
|
||||||
}
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ProductListView()
|
HistoryView()
|
||||||
} label: {
|
} label: {
|
||||||
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten",
|
ActionTile(title: "Verlauf", subtitle: "Wer hat wann was ein- und ausgelagert",
|
||||||
systemImage: "list.bullet")
|
systemImage: "clock.arrow.circlepath")
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
.padding()
|
.padding()
|
||||||
.navigationTitle("Vorrania")
|
.navigationTitle("Listen")
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
Menu {
|
|
||||||
Text("Angemeldet als \(session.username)")
|
|
||||||
Button("Abmelden", role: .destructive) { session.logout() }
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "person.circle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kompakte, halbbreite Kachel (Symbol oben, Titel darunter) für zwei
|
||||||
|
/// nebeneinanderstehende Einträge – z.B. Lebensmittel/Gegenstände.
|
||||||
|
struct CompactTile: View {
|
||||||
|
let title: String
|
||||||
|
let systemImage: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.title2)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(Color.marke.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
Text(title).font(.headline)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ActionTile: View {
|
struct ActionTile: View {
|
||||||
let title: String
|
let title: String
|
||||||
let subtitle: String
|
let subtitle: String
|
||||||
@@ -100,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)
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ struct ScannerView: UIViewControllerRepresentable {
|
|||||||
var onCode: (String) -> Void
|
var onCode: (String) -> Void
|
||||||
@Binding var isPaused: Bool
|
@Binding var isPaused: Bool
|
||||||
@Binding var torchOn: Bool
|
@Binding var torchOn: Bool
|
||||||
|
/// Per Langdruck gesperrter Blitz bleibt auch nach einem Scan an. Standard:
|
||||||
|
/// aus nach dem ersten Treffer.
|
||||||
|
@Binding var torchLocked: Bool
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) }
|
func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) }
|
||||||
|
|
||||||
@@ -18,12 +21,16 @@ struct ScannerView: UIViewControllerRepresentable {
|
|||||||
|
|
||||||
func updateUIViewController(_ controller: ScannerViewController, context: Context) {
|
func updateUIViewController(_ controller: ScannerViewController, context: Context) {
|
||||||
context.coordinator.isPaused = isPaused
|
context.coordinator.isPaused = isPaused
|
||||||
|
context.coordinator.torchLocked = torchLocked
|
||||||
|
context.coordinator.torchBinding = $torchOn
|
||||||
controller.setTorch(on: torchOn)
|
controller.setTorch(on: torchOn)
|
||||||
}
|
}
|
||||||
|
|
||||||
final class Coordinator: NSObject, ScannerViewControllerDelegate {
|
final class Coordinator: NSObject, ScannerViewControllerDelegate {
|
||||||
let onCode: (String) -> Void
|
let onCode: (String) -> Void
|
||||||
var isPaused: Bool = false
|
var isPaused: Bool = false
|
||||||
|
var torchLocked: Bool = false
|
||||||
|
var torchBinding: Binding<Bool>?
|
||||||
private var lastCode: String?
|
private var lastCode: String?
|
||||||
private var lastTime: Date = .distantPast
|
private var lastTime: Date = .distantPast
|
||||||
|
|
||||||
@@ -37,21 +44,42 @@ struct ScannerView: UIViewControllerRepresentable {
|
|||||||
lastTime = Date()
|
lastTime = Date()
|
||||||
AudioServicesPlaySystemSound(1057)
|
AudioServicesPlaySystemSound(1057)
|
||||||
onCode(code)
|
onCode(code)
|
||||||
|
// Blitz nach dem Scan ausschalten – ausser er ist per Langdruck gesperrt.
|
||||||
|
if !torchLocked, torchBinding?.wrappedValue == true {
|
||||||
|
DispatchQueue.main.async { [weak self] in self?.torchBinding?.wrappedValue = false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Licht-Schalter fuer die Scan-Bildschirme.
|
/// Licht-Schalter fuer die Scan-Bildschirme.
|
||||||
|
///
|
||||||
|
/// Tippen: an/aus – angeschaltet geht der Blitz nach dem naechsten Scan wieder
|
||||||
|
/// aus. Langdruck: dauerhaft an (gesperrt, gelb), bleibt auch nach Scans an;
|
||||||
|
/// erneutes Tippen schaltet aus und hebt die Sperre auf.
|
||||||
struct TorchButton: View {
|
struct TorchButton: View {
|
||||||
@Binding var isOn: Bool
|
@Binding var isOn: Bool
|
||||||
|
@Binding var locked: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
|
locked = false
|
||||||
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.marke)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(isOn ? "Licht ausschalten" : "Licht einschalten")
|
.simultaneousGesture(
|
||||||
|
LongPressGesture(minimumDuration: 0.4).onEnded { _ in
|
||||||
|
isOn = true
|
||||||
|
locked = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.accessibilityLabel(
|
||||||
|
isOn
|
||||||
|
? (locked ? "Licht dauerhaft an, zum Ausschalten tippen" : "Licht aus")
|
||||||
|
: "Licht ein (bleibt bis zum nächsten Scan)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +93,8 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
|
|||||||
private let session = AVCaptureSession()
|
private let session = AVCaptureSession()
|
||||||
private var preview: AVCaptureVideoPreviewLayer?
|
private var preview: AVCaptureVideoPreviewLayer?
|
||||||
private var device: AVCaptureDevice?
|
private var device: AVCaptureDevice?
|
||||||
|
/// Zoomfaktor beim Beginn einer Pinch-Geste (Ausgangspunkt fuers Skalieren).
|
||||||
|
private var zoomBasis: CGFloat = 1.0
|
||||||
|
|
||||||
/// Auch krummere Symbologien mitnehmen: ITF-14 kommt auf Umkartons vor,
|
/// Auch krummere Symbologien mitnehmen: ITF-14 kommt auf Umkartons vor,
|
||||||
/// DataMatrix auf kleinen Aufklebern.
|
/// DataMatrix auf kleinen Aufklebern.
|
||||||
@@ -77,6 +107,7 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .black
|
view.backgroundColor = .black
|
||||||
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(focusOnTap)))
|
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(focusOnTap)))
|
||||||
|
view.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(zoomPinch)))
|
||||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||||
guard granted else { return }
|
guard granted else { return }
|
||||||
DispatchQueue.main.async { self?.configure() }
|
DispatchQueue.main.async { self?.configure() }
|
||||||
@@ -145,10 +176,13 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Antippen stellt gezielt auf den Code scharf - Rettungsanker fuer Codes,
|
/// Antippen stellt gezielt auf den Code scharf - Rettungsanker fuer Codes,
|
||||||
/// die der Autofokus nicht von selbst findet.
|
/// die der Autofokus nicht von selbst findet. Ein kurz aufblitzender Rahmen
|
||||||
|
/// zeigt, wohin fokussiert wird (wie in der System-Kamera).
|
||||||
@objc private func focusOnTap(_ gesture: UITapGestureRecognizer) {
|
@objc private func focusOnTap(_ gesture: UITapGestureRecognizer) {
|
||||||
|
let location = gesture.location(in: view)
|
||||||
|
showFocusIndicator(at: location)
|
||||||
guard let device, let preview else { return }
|
guard let device, let preview else { return }
|
||||||
let point = preview.captureDevicePointConverted(fromLayerPoint: gesture.location(in: view))
|
let point = preview.captureDevicePointConverted(fromLayerPoint: location)
|
||||||
guard (try? device.lockForConfiguration()) != nil else { return }
|
guard (try? device.lockForConfiguration()) != nil else { return }
|
||||||
if device.isFocusPointOfInterestSupported, device.isFocusModeSupported(.autoFocus) {
|
if device.isFocusPointOfInterestSupported, device.isFocusModeSupported(.autoFocus) {
|
||||||
device.focusPointOfInterest = point
|
device.focusPointOfInterest = point
|
||||||
@@ -161,6 +195,44 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
|
|||||||
device.unlockForConfiguration()
|
device.unlockForConfiguration()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zwei-Finger-Pinch zoomt naeher an einen Code heran (rein digital, auf ein
|
||||||
|
/// sinnvolles Maximum begrenzt – zu viel Zoom macht den Code unscharf).
|
||||||
|
@objc private func zoomPinch(_ gesture: UIPinchGestureRecognizer) {
|
||||||
|
guard let device else { return }
|
||||||
|
if gesture.state == .began {
|
||||||
|
zoomBasis = device.videoZoomFactor
|
||||||
|
}
|
||||||
|
let maxZoom = min(device.activeFormat.videoMaxZoomFactor, 6.0)
|
||||||
|
let neu = max(1.0, min(zoomBasis * gesture.scale, maxZoom))
|
||||||
|
guard (try? device.lockForConfiguration()) != nil else { return }
|
||||||
|
device.videoZoomFactor = neu
|
||||||
|
device.unlockForConfiguration()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kurzer gelber Fokus-Rahmen an der getippten Stelle als Rueckmeldung.
|
||||||
|
private func showFocusIndicator(at point: CGPoint) {
|
||||||
|
let seite: CGFloat = 72
|
||||||
|
let rahmen = UIView(frame: CGRect(x: 0, y: 0, width: seite, height: seite))
|
||||||
|
rahmen.center = point
|
||||||
|
rahmen.backgroundColor = .clear
|
||||||
|
rahmen.isUserInteractionEnabled = false
|
||||||
|
rahmen.layer.borderColor = UIColor.systemYellow.cgColor
|
||||||
|
rahmen.layer.borderWidth = 1.5
|
||||||
|
rahmen.layer.cornerRadius = 6
|
||||||
|
rahmen.alpha = 0
|
||||||
|
rahmen.transform = CGAffineTransform(scaleX: 1.4, y: 1.4)
|
||||||
|
view.addSubview(rahmen)
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.2, animations: {
|
||||||
|
rahmen.alpha = 1
|
||||||
|
rahmen.transform = .identity
|
||||||
|
}, completion: { _ in
|
||||||
|
UIView.animate(withDuration: 0.35, delay: 0.6, options: [], animations: {
|
||||||
|
rahmen.alpha = 0
|
||||||
|
}, completion: { _ in rahmen.removeFromSuperview() })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Licht an: hebt den Kontrast bei matten oder farbigen Codes deutlich.
|
/// Licht an: hebt den Kontrast bei matten oder farbigen Codes deutlich.
|
||||||
func setTorch(on: Bool) {
|
func setTorch(on: Bool) {
|
||||||
guard let device, device.hasTorch, device.isTorchAvailable else { return }
|
guard let device, device.hasTorch, device.isTorchAvailable else { return }
|
||||||
|
|||||||
58
ios/Sources/ServerFetch.swift
Normal file
58
ios/Sources/ServerFetch.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Liest Daten von einem **bestimmten** Server, ohne den aktiven zu wechseln.
|
||||||
|
///
|
||||||
|
/// `APIClient` ist fest auf das aktive Profil verdrahtet (Session.shared).
|
||||||
|
/// Fuer die Benachrichtigungen muss die App aber jeden eingerichteten Server
|
||||||
|
/// abfragen - deshalb dieser parallele Pfad, der baseURL und Token ausdruecklich
|
||||||
|
/// bekommt. Modelle und Fehlerbedeutung sind dieselben wie im `APIClient`.
|
||||||
|
enum ServerFetch {
|
||||||
|
/// Alles, was der Planer je Server braucht: ablaufende Chargen im Horizont,
|
||||||
|
/// die Produkte (fuer die Kategoriezuordnung) und der Kategorie-Baum.
|
||||||
|
struct Snapshot {
|
||||||
|
let expiring: [ExpiringItem]
|
||||||
|
let products: [Product]
|
||||||
|
let categories: [CategoryItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func snapshot(baseURL: URL, token: String, horizonDays: Int) async -> Snapshot {
|
||||||
|
async let expiring: [ExpiringItem] = get(
|
||||||
|
"/expiring?days=\(horizonDays)", baseURL: baseURL, token: token)
|
||||||
|
async let products: [Product] = get("/products", baseURL: baseURL, token: token)
|
||||||
|
async let categories: [CategoryItem] = get("/categories", baseURL: baseURL, token: token)
|
||||||
|
// Einzelne Ausfaelle sollen nicht alles kippen (sonst fehlten z.B. die
|
||||||
|
// Kategorien nur, weil die Produktliste langsam war).
|
||||||
|
return await Snapshot(
|
||||||
|
expiring: (try? await expiring) ?? [],
|
||||||
|
products: (try? await products) ?? [],
|
||||||
|
categories: (try? await categories) ?? []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nur den Kategorie-Baum eines bestimmten Servers holen (fuer die
|
||||||
|
/// Regel-Bearbeitung in den Benachrichtigungen).
|
||||||
|
static func categories(baseURL: URL, token: String) async throws -> [CategoryItem] {
|
||||||
|
try await get("/categories", baseURL: baseURL, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func get<T: Decodable>(
|
||||||
|
_ path: String, baseURL: URL, token: String
|
||||||
|
) async throws -> T {
|
||||||
|
guard let url = URL(string: "api" + path, relativeTo: baseURL) else {
|
||||||
|
throw APIError.notConfigured
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
// Etwas grosszuegiger als der interaktive Client: Der Abruf laeuft im
|
||||||
|
// Hintergrund, ein langsamer Heimserver soll nicht sofort scheitern.
|
||||||
|
request.timeoutInterval = 20
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||||||
|
if http.statusCode == 401 { throw APIError.unauthorized }
|
||||||
|
throw APIError.server("Serverfehler (\(http.statusCode))")
|
||||||
|
}
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
211
ios/Sources/ServerListView.swift
Normal file
211
ios/Sources/ServerListView.swift
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Wechselt den Server aus der Werkzeugleiste heraus - der schnelle Weg.
|
||||||
|
/// Die vollstaendige Verwaltung liegt in `ServerListView`.
|
||||||
|
struct ServerMenu: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
@Binding var manageShown: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
if session.profiles.count > 1 {
|
||||||
|
Section("Server") {
|
||||||
|
ForEach(session.profiles) { profile in
|
||||||
|
Button {
|
||||||
|
guard profile.id != session.activeProfileID else { return }
|
||||||
|
session.switchTo(profile.id)
|
||||||
|
} label: {
|
||||||
|
// Der Haken zeigt, wo man gerade ist. Ein leerer
|
||||||
|
// Symbolname wuerde eine kaputte Lucke hinterlassen,
|
||||||
|
// deshalb zwei getrennte Beschriftungen.
|
||||||
|
if profile.id == session.activeProfileID {
|
||||||
|
Label(profile.name, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(profile.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Server verwalten…", systemImage: "server.rack") { manageShown = true }
|
||||||
|
if session.isLoggedIn {
|
||||||
|
Divider()
|
||||||
|
Text("Angemeldet als \(session.username)")
|
||||||
|
Button("Abmelden", role: .destructive) { session.logout() }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "person.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ServerListView: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var editing: ServerProfile?
|
||||||
|
@State private var addShown = false
|
||||||
|
@State private var pendingDeletion: ServerProfile?
|
||||||
|
@State private var notifyFor: ServerProfile?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(session.profiles) { profile in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
// Zeile wechselt den Server, die Glocke oeffnet die
|
||||||
|
// Benachrichtigungen - zwei getrennte Ziele in einer Zeile.
|
||||||
|
Button {
|
||||||
|
session.switchTo(profile.id)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
row(for: profile)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Button {
|
||||||
|
notifyFor = profile
|
||||||
|
} label: {
|
||||||
|
Image(systemName: NotificationStore.config(for: profile.id).enabled
|
||||||
|
? "bell.fill" : "bell")
|
||||||
|
.foregroundStyle(Color.marke)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
Button("Löschen", role: .destructive) { pendingDeletion = profile }
|
||||||
|
Button("Bearbeiten") { editing = profile }.tint(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Jeder Server merkt sich seine eigene Anmeldung. Ein Wechsel meldet dich nicht ab. Die Glocke stellt Ablauf-Erinnerungen ein.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.profiles.isEmpty {
|
||||||
|
Text("Noch kein Server hinterlegt.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Server")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Hinzufügen", systemImage: "plus") { addShown = true }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Fertig") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $addShown) {
|
||||||
|
ServerEditView(profile: nil)
|
||||||
|
}
|
||||||
|
.sheet(item: $editing) { profile in
|
||||||
|
ServerEditView(profile: profile)
|
||||||
|
}
|
||||||
|
.sheet(item: $notifyFor) { profile in
|
||||||
|
NavigationStack {
|
||||||
|
NotificationSettingsView(profileID: profile.id, serverName: profile.name)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Fertig") { notifyFor = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
pendingDeletion.map { "„\($0.name)“ löschen?" } ?? "",
|
||||||
|
isPresented: Binding(get: { pendingDeletion != nil },
|
||||||
|
set: { if !$0 { pendingDeletion = nil } }),
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Löschen", role: .destructive) {
|
||||||
|
if let profile = pendingDeletion { session.removeProfile(id: profile.id) }
|
||||||
|
pendingDeletion = nil
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) { pendingDeletion = nil }
|
||||||
|
} message: {
|
||||||
|
Text("Die gespeicherte Anmeldung für diesen Server wird mit entfernt. Auf dem Server selbst ändert sich nichts.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(for profile: ServerProfile) -> some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(profile.name).font(.body)
|
||||||
|
Text(profile.urlString).font(.caption).foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1).truncationMode(.middle)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if profile.id == session.activeProfileID {
|
||||||
|
Image(systemName: "checkmark").foregroundStyle(Color.marke)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anlegen und Bearbeiten in einem - der Unterschied ist nur, ob schon ein
|
||||||
|
/// Profil hereingereicht wurde.
|
||||||
|
struct ServerEditView: View {
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let profile: ServerProfile?
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var urlString: String
|
||||||
|
|
||||||
|
init(profile: ServerProfile?) {
|
||||||
|
self.profile = profile
|
||||||
|
_name = State(initialValue: profile?.name ?? "")
|
||||||
|
_urlString = State(initialValue: profile?.urlString ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSave: Bool {
|
||||||
|
Session.normalize(urlString) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Der Name steht bewusst oben: Er ist das, was in der App zu
|
||||||
|
// sehen ist ("Zuhause"), die Adresse braucht man nur einmal.
|
||||||
|
Section {
|
||||||
|
TextField("z. B. Zuhause", text: $name)
|
||||||
|
} header: {
|
||||||
|
Text("Name")
|
||||||
|
} footer: {
|
||||||
|
Text("So heißt der Server in der App. Ohne Angabe wird der Rechnername verwendet – meist die IP-Adresse.")
|
||||||
|
}
|
||||||
|
Section("Adresse") {
|
||||||
|
TextField("http://192.168.1.10:8080", text: $urlString)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.keyboardType(.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(profile == nil ? "Server hinzufügen" : "Server bearbeiten")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Sichern") { save() }.disabled(!canSave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
if let profile {
|
||||||
|
session.updateProfile(id: profile.id, name: name, urlString: urlString)
|
||||||
|
} else {
|
||||||
|
// Nach dem Anlegen ist der neue Server der aktive; fehlt dort eine
|
||||||
|
// Anmeldung, erscheint sie von selbst.
|
||||||
|
session.addProfile(name: name, urlString: urlString)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
72
ios/Sources/ServerProfiles.swift
Normal file
72
ios/Sources/ServerProfiles.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Ein hinterlegter Server. Die App kann beliebig viele davon kennen und
|
||||||
|
/// zwischen ihnen umschalten, ohne sich jedes Mal neu anzumelden.
|
||||||
|
///
|
||||||
|
/// Das Token liegt **nicht** hier, sondern im Keychain unter einem Konto, das
|
||||||
|
/// aus der `id` gebildet wird (siehe `Session.keychainAccount`). Diese Struktur
|
||||||
|
/// landet als JSON in den UserDefaults und darf deshalb nichts Geheimes
|
||||||
|
/// enthalten.
|
||||||
|
struct ServerProfile: Codable, Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var name: String
|
||||||
|
var urlString: String
|
||||||
|
/// Zuletzt angemeldeter Benutzer - nur fuer die Anzeige und damit das
|
||||||
|
/// Anmeldefeld beim Wechsel schon ausgefuellt ist.
|
||||||
|
var username: String
|
||||||
|
/// Zuletzt bekannte Rolle. Entscheidet, ob der Verwaltungs-Tab erscheint,
|
||||||
|
/// bevor `me()` den Wert frisch bestaetigt hat.
|
||||||
|
var isAdmin: Bool
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), name: String, urlString: String,
|
||||||
|
username: String = "", isAdmin: Bool = false) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.urlString = urlString
|
||||||
|
self.username = username
|
||||||
|
self.isAdmin = isAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
var url: URL? { Session.normalize(urlString) }
|
||||||
|
|
||||||
|
/// Ohne eigenen Namen ist der Rechnername die brauchbarste Bezeichnung:
|
||||||
|
/// aus "http://192.168.1.50:8080/" wird "192.168.1.50".
|
||||||
|
static func suggestedName(for raw: String) -> String {
|
||||||
|
guard let url = Session.normalize(raw), let host = url.host, !host.isEmpty else {
|
||||||
|
return "Server"
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liest und schreibt die Profilliste. Bewusst getrennt von `Session`, damit
|
||||||
|
/// die Ablage fuer sich verstaendlich bleibt.
|
||||||
|
enum ProfileStore {
|
||||||
|
static let profilesKey = "server_profiles"
|
||||||
|
static let activeKey = "active_profile_id"
|
||||||
|
|
||||||
|
static func load() -> [ServerProfile] {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: profilesKey),
|
||||||
|
let profiles = try? JSONDecoder().decode([ServerProfile].self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ profiles: [ServerProfile]) {
|
||||||
|
guard let data = try? JSONEncoder().encode(profiles) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: profilesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadActiveID() -> UUID? {
|
||||||
|
guard let raw = UserDefaults.standard.string(forKey: activeKey) else { return nil }
|
||||||
|
return UUID(uuidString: raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveActiveID(_ id: UUID?) {
|
||||||
|
if let id {
|
||||||
|
UserDefaults.standard.set(id.uuidString, forKey: activeKey)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: activeKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,81 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
/// Hält Server-Adresse und Anmeldedaten. Das Token liegt im Keychain,
|
/// Haelt die bekannten Server und die Anmeldung am gerade aktiven.
|
||||||
/// die Server-URL in den UserDefaults (nicht geheim).
|
///
|
||||||
|
/// Jedes Profil hat ein eigenes Token im Keychain. Dadurch bleibt man an
|
||||||
|
/// mehreren Servern gleichzeitig angemeldet und der Wechsel ist ein Tipp.
|
||||||
|
/// Die Adressen liegen in den UserDefaults (nicht geheim), die Token im
|
||||||
|
/// Keychain.
|
||||||
final class Session: ObservableObject {
|
final class Session: ObservableObject {
|
||||||
static let shared = Session()
|
static let shared = Session()
|
||||||
|
|
||||||
private let urlKey = "server_url"
|
|
||||||
private let usernameKey = "username"
|
|
||||||
private let stayKey = "stay_logged_in"
|
private let stayKey = "stay_logged_in"
|
||||||
private let keychainAccount = "vorrania-token"
|
|
||||||
|
|
||||||
@Published private(set) var baseURL: URL?
|
// Schluessel aus der Zeit mit genau einem Server. Werden beim ersten Start
|
||||||
|
// nach dem Update in ein Profil ueberfuehrt und danach entfernt.
|
||||||
|
private let legacyURLKey = "server_url"
|
||||||
|
private let legacyUsernameKey = "username"
|
||||||
|
private let legacyAccount = "vorrania-token"
|
||||||
|
|
||||||
|
@Published private(set) var profiles: [ServerProfile] = []
|
||||||
|
@Published private(set) var activeProfileID: UUID?
|
||||||
@Published private(set) var token: String?
|
@Published private(set) var token: String?
|
||||||
@Published var username: String = ""
|
|
||||||
@Published var isAdmin: Bool = false
|
|
||||||
/// Merkt die letzte Wahl, damit der Haken im Login richtig steht.
|
/// Merkt die letzte Wahl, damit der Haken im Login richtig steht.
|
||||||
@Published private(set) var stayLoggedIn: Bool = true
|
@Published private(set) var stayLoggedIn: Bool = true
|
||||||
|
|
||||||
|
var activeProfile: ServerProfile? {
|
||||||
|
profiles.first { $0.id == activeProfileID }
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseURL: URL? { activeProfile?.url }
|
||||||
|
var username: String { activeProfile?.username ?? "" }
|
||||||
|
var isAdmin: Bool { activeProfile?.isAdmin ?? false }
|
||||||
var isLoggedIn: Bool { token != nil && baseURL != nil }
|
var isLoggedIn: Bool { token != nil && baseURL != nil }
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
if let stored = UserDefaults.standard.string(forKey: urlKey) {
|
|
||||||
baseURL = Session.normalize(stored)
|
|
||||||
}
|
|
||||||
// Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall.
|
// Ohne bisherige Wahl bleibt man angemeldet - das ist der Alltagsfall.
|
||||||
stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true
|
stayLoggedIn = UserDefaults.standard.object(forKey: stayKey) as? Bool ?? true
|
||||||
token = Keychain.read(account: keychainAccount)
|
profiles = ProfileStore.load()
|
||||||
username = UserDefaults.standard.string(forKey: usernameKey) ?? ""
|
migrateSingleServerIfNeeded()
|
||||||
|
activeProfileID = ProfileStore.loadActiveID() ?? profiles.first?.id
|
||||||
|
loadTokenForActiveProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uebernimmt eine Anmeldung aus der Zeit vor den Profilen. Ohne das waere
|
||||||
|
/// man nach dem Update abgemeldet und muesste die Serveradresse neu tippen.
|
||||||
|
private func migrateSingleServerIfNeeded() {
|
||||||
|
guard profiles.isEmpty,
|
||||||
|
let stored = UserDefaults.standard.string(forKey: legacyURLKey),
|
||||||
|
!stored.isEmpty
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let name = ServerProfile.suggestedName(for: stored)
|
||||||
|
let profile = ServerProfile(
|
||||||
|
name: name,
|
||||||
|
urlString: stored,
|
||||||
|
username: UserDefaults.standard.string(forKey: legacyUsernameKey) ?? ""
|
||||||
|
)
|
||||||
|
profiles = [profile]
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
ProfileStore.saveActiveID(profile.id)
|
||||||
|
|
||||||
|
// Token auf das profilbezogene Konto umziehen.
|
||||||
|
if let old = Keychain.read(account: legacyAccount) {
|
||||||
|
Keychain.write(old, account: Session.keychainAccount(for: profile.id))
|
||||||
|
Keychain.delete(account: legacyAccount)
|
||||||
|
}
|
||||||
|
UserDefaults.standard.removeObject(forKey: legacyURLKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: legacyUsernameKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func keychainAccount(for id: UUID) -> String {
|
||||||
|
"vorrania-token-\(id.uuidString)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadTokenForActiveProfile() {
|
||||||
|
guard let id = activeProfileID else { token = nil; return }
|
||||||
|
token = Keychain.read(account: Session.keychainAccount(for: id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sorgt für eine URL mit Schema und abschließendem "/", damit relative
|
/// Sorgt für eine URL mit Schema und abschließendem "/", damit relative
|
||||||
@@ -40,36 +88,131 @@ final class Session: ObservableObject {
|
|||||||
return URL(string: text)
|
return URL(string: text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Profile
|
||||||
|
|
||||||
|
/// Legt ein Profil an und macht es zum aktiven. Ohne Namen wird der
|
||||||
|
/// Rechnername genommen.
|
||||||
|
@discardableResult
|
||||||
|
func addProfile(name: String = "", urlString: String) -> ServerProfile? {
|
||||||
|
guard Session.normalize(urlString) != nil else { return nil }
|
||||||
|
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let profile = ServerProfile(
|
||||||
|
name: title.isEmpty ? ServerProfile.suggestedName(for: urlString) : title,
|
||||||
|
urlString: urlString
|
||||||
|
)
|
||||||
|
profiles.append(profile)
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
switchTo(profile.id)
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProfile(id: UUID, name: String? = nil, urlString: String? = nil) {
|
||||||
|
guard let index = profiles.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
if let name {
|
||||||
|
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !title.isEmpty { profiles[index].name = title }
|
||||||
|
}
|
||||||
|
if let urlString, Session.normalize(urlString) != nil {
|
||||||
|
profiles[index].urlString = urlString
|
||||||
|
}
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entfernt das Profil samt Token. Das Geheimnis darf nicht zurueckbleiben,
|
||||||
|
/// wenn der Server aus der Liste verschwindet.
|
||||||
|
func removeProfile(id: UUID) {
|
||||||
|
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||||
|
NotificationStore.remove(for: id)
|
||||||
|
AppDashboardStore.remove(for: id)
|
||||||
|
profiles.removeAll { $0.id == id }
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
|
||||||
|
if activeProfileID == id {
|
||||||
|
switchTo(profiles.first?.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wechselt den Server. Ist fuer das Ziel ein Token hinterlegt, ist man
|
||||||
|
/// sofort angemeldet, sonst erscheint die Anmeldung fuer dieses Profil.
|
||||||
|
func switchTo(_ id: UUID?) {
|
||||||
|
activeProfileID = id
|
||||||
|
ProfileStore.saveActiveID(id)
|
||||||
|
loadTokenForActiveProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wechselt (falls vorhanden und noetig) auf das Profil, dessen Adresse zum
|
||||||
|
/// Host eines Links passt – damit ein gescannter QR den richtigen Server
|
||||||
|
/// abfragt, auch wenn gerade ein anderer aktiv ist.
|
||||||
|
func switchToProfile(matching url: URL) {
|
||||||
|
guard let host = url.host?.lowercased() else { return }
|
||||||
|
if activeProfile?.url?.host?.lowercased() == host { return }
|
||||||
|
if let match = profiles.first(where: { $0.url?.host?.lowercased() == host }) {
|
||||||
|
switchTo(match.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Anmeldung
|
||||||
|
|
||||||
|
/// Legt die Adresse fuer die Anmeldung fest: aktualisiert das aktive Profil
|
||||||
|
/// oder legt das erste an, wenn die App noch keinen Server kennt.
|
||||||
func setServer(_ raw: String) {
|
func setServer(_ raw: String) {
|
||||||
guard let url = Session.normalize(raw) else { return }
|
guard Session.normalize(raw) != nil else { return }
|
||||||
baseURL = url
|
if let id = activeProfileID, profiles.contains(where: { $0.id == id }) {
|
||||||
UserDefaults.standard.set(url.absoluteString, forKey: urlKey)
|
updateProfile(id: id, urlString: raw)
|
||||||
|
} else {
|
||||||
|
addProfile(urlString: raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft.
|
/// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft.
|
||||||
/// Auf dem Geraet bleibt dann nichts zurueck.
|
/// Auf dem Geraet bleibt dann nichts zurueck.
|
||||||
func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) {
|
func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) {
|
||||||
token = newToken
|
token = newToken
|
||||||
username = name
|
|
||||||
isAdmin = admin
|
|
||||||
stayLoggedIn = persist
|
stayLoggedIn = persist
|
||||||
UserDefaults.standard.set(persist, forKey: stayKey)
|
UserDefaults.standard.set(persist, forKey: stayKey)
|
||||||
|
|
||||||
|
guard let id = activeProfileID,
|
||||||
|
let index = profiles.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
profiles[index].isAdmin = admin
|
||||||
|
profiles[index].username = persist ? name : ""
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
|
||||||
if persist {
|
if persist {
|
||||||
Keychain.write(newToken, account: keychainAccount)
|
Keychain.write(newToken, account: Session.keychainAccount(for: id))
|
||||||
UserDefaults.standard.set(name, forKey: usernameKey)
|
|
||||||
} else {
|
} else {
|
||||||
Keychain.delete(account: keychainAccount)
|
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||||
UserDefaults.standard.removeObject(forKey: usernameKey)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Meldet nur vom aktiven Server ab. Andere Profile behalten ihr Token.
|
||||||
func logout() {
|
func logout() {
|
||||||
token = nil
|
token = nil
|
||||||
username = ""
|
guard let id = activeProfileID else { return }
|
||||||
isAdmin = false
|
Keychain.delete(account: Session.keychainAccount(for: id))
|
||||||
Keychain.delete(account: keychainAccount)
|
if let index = profiles.firstIndex(where: { $0.id == id }) {
|
||||||
UserDefaults.standard.removeObject(forKey: usernameKey)
|
profiles[index].username = ""
|
||||||
|
profiles[index].isAdmin = false
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prueft nach einem Wechsel, ob das hinterlegte Token noch gilt, und holt
|
||||||
|
/// die aktuelle Rolle. Ein abgelaufenes Token fuehrt zur Anmeldung.
|
||||||
|
@MainActor
|
||||||
|
func refreshMe() async {
|
||||||
|
guard isLoggedIn, let id = activeProfileID else { return }
|
||||||
|
do {
|
||||||
|
let me = try await APIClient.shared.me()
|
||||||
|
guard let index = profiles.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
profiles[index].username = me.username
|
||||||
|
profiles[index].isAdmin = me.role == "admin"
|
||||||
|
ProfileStore.save(profiles)
|
||||||
|
} catch APIError.unauthorized {
|
||||||
|
logout()
|
||||||
|
} catch {
|
||||||
|
// Server gerade nicht erreichbar: angemeldet bleiben, damit ein
|
||||||
|
// kurzer Netzausfall einen nicht aus der App wirft.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
ios/Sources/Vorrania.entitlements
Normal file
10
ios/Sources/Vorrania.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>applinks:vorrania-ch.scarriffle.com</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,31 +1,70 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import BackgroundTasks
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
/// Zielbildschirm, der über Home-Screen-Shortcut oder URL angesprungen wird.
|
/// Zielbildschirm, der über Home-Screen-Shortcut, URL oder eine getippte
|
||||||
|
/// Benachrichtigung angesprungen wird.
|
||||||
enum Route: String {
|
enum Route: String {
|
||||||
case checkin
|
case checkin
|
||||||
case checkout
|
case checkout
|
||||||
|
case expiring
|
||||||
|
case lookup
|
||||||
|
case assign
|
||||||
}
|
}
|
||||||
|
|
||||||
final class Router: ObservableObject {
|
final class Router: ObservableObject {
|
||||||
@Published var route: Route?
|
@Published var route: Route?
|
||||||
|
/// Über einen Universal Link (…/i/<UID>) zu öffnendes Einzelstück.
|
||||||
|
@Published var openItemUid: String?
|
||||||
|
|
||||||
func handle(shortcut type: String) {
|
func handle(shortcut type: String) {
|
||||||
if type.hasSuffix("checkin") { route = .checkin }
|
if type.hasSuffix("checkin") { route = .checkin }
|
||||||
if type.hasSuffix("checkout") { route = .checkout }
|
if type.hasSuffix("checkout") { route = .checkout }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// vorrania://checkin bzw. vorrania://checkout
|
/// Getippte Ablauf-Benachrichtigung: auf den betroffenen Server wechseln und
|
||||||
|
/// „Bald ablaufend" oeffnen.
|
||||||
|
func handle(expiryProfile raw: String) {
|
||||||
|
if let id = UUID(uuidString: raw) { Session.shared.switchTo(id) }
|
||||||
|
route = .expiring
|
||||||
|
}
|
||||||
|
|
||||||
|
/// vorrania://checkin | checkout … (Aktionen) oder vorrania://i/<UID>
|
||||||
|
/// (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) {
|
||||||
guard url.scheme == "vorrania" else { return }
|
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
|
||||||
|
}
|
||||||
|
// 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 {
|
||||||
|
Session.shared.switchToProfile(matching: url)
|
||||||
|
openItemUid = parts[idx + 1].uppercased()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct VorraniaApp: App {
|
struct VorraniaApp: App {
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@StateObject private var session = Session.shared
|
@StateObject private var session = Session.shared
|
||||||
@StateObject private var router = Router()
|
@StateObject private var router = Router()
|
||||||
@StateObject private var display = DisplaySettings.shared
|
@StateObject private var display = DisplaySettings.shared
|
||||||
@@ -37,29 +76,75 @@ struct VorraniaApp: App {
|
|||||||
.environmentObject(router)
|
.environmentObject(router)
|
||||||
.environmentObject(display)
|
.environmentObject(display)
|
||||||
.onOpenURL { router.handle(url: $0) }
|
.onOpenURL { router.handle(url: $0) }
|
||||||
|
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
||||||
|
if let url = activity.webpageURL { router.handle(url: url) }
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if let type = AppDelegate.pendingShortcut {
|
if let type = AppDelegate.pendingShortcut {
|
||||||
router.handle(shortcut: type)
|
router.handle(shortcut: type)
|
||||||
AppDelegate.pendingShortcut = nil
|
AppDelegate.pendingShortcut = nil
|
||||||
}
|
}
|
||||||
|
// Kaltstart aus einer getippten Benachrichtigung.
|
||||||
|
if let profile = AppDelegate.pendingExpiryProfile {
|
||||||
|
router.handle(expiryProfile: profile)
|
||||||
|
AppDelegate.pendingExpiryProfile = nil
|
||||||
|
}
|
||||||
|
// Kaltstart aus einem gescannten Universal Link.
|
||||||
|
if let url = AppDelegate.pendingURL {
|
||||||
|
router.handle(url: url)
|
||||||
|
AppDelegate.pendingURL = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in
|
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in
|
||||||
if let type = note.object as? String { router.handle(shortcut: type) }
|
if let type = note.object as? String { router.handle(shortcut: type) }
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.expiryTapNotification)) { note in
|
||||||
|
if let profile = note.object as? String { router.handle(expiryProfile: profile) }
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.urlNotification)) { note in
|
||||||
|
if let url = note.object as? URL { router.handle(url: url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Im Vordergrund den Meldungsplan auffrischen und den naechsten
|
||||||
|
// Hintergrundlauf einplanen.
|
||||||
|
.onChange(of: scenePhase) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .active:
|
||||||
|
Task { await NotificationScheduler.shared.rescheduleAll() }
|
||||||
|
case .background:
|
||||||
|
AppDelegate.scheduleBackgroundRefresh()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nimmt Home-Screen-Quick-Actions entgegen (auch beim Kaltstart).
|
/// Nimmt Home-Screen-Quick-Actions und getippte Benachrichtigungen entgegen
|
||||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
/// (auch beim Kaltstart) und treibt die Hintergrund-Aktualisierung.
|
||||||
|
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||||
static let shortcutNotification = Notification.Name("VorraniaShortcut")
|
static let shortcutNotification = Notification.Name("VorraniaShortcut")
|
||||||
|
static let expiryTapNotification = Notification.Name("VorraniaExpiryTap")
|
||||||
|
static let urlNotification = Notification.Name("VorraniaOpenURL")
|
||||||
static var pendingShortcut: String?
|
static var pendingShortcut: String?
|
||||||
|
static var pendingExpiryProfile: String?
|
||||||
|
static var pendingURL: URL?
|
||||||
|
|
||||||
|
static let refreshTaskID = "com.scarriffle.vorrania.refresh"
|
||||||
|
|
||||||
func application(_ application: UIApplication,
|
func application(_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||||
if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
|
if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
|
||||||
AppDelegate.pendingShortcut = item.type
|
AppDelegate.pendingShortcut = item.type
|
||||||
}
|
}
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
|
||||||
|
// Der Handler muss registriert sein, bevor das Starten abgeschlossen ist.
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: AppDelegate.refreshTaskID, using: nil
|
||||||
|
) { task in
|
||||||
|
AppDelegate.handleBackgroundRefresh(task as? BGAppRefreshTask)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,4 +157,55 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type)
|
NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type)
|
||||||
completionHandler(true)
|
completionHandler(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Universal Link (…/i/<UID>) – auch beim Kaltstart, falls die SwiftUI-Hooks
|
||||||
|
// ihn nicht mitbekommen.
|
||||||
|
func application(_ application: UIApplication, continue userActivity: NSUserActivity,
|
||||||
|
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
|
||||||
|
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
|
||||||
|
let url = userActivity.webpageURL else { return false }
|
||||||
|
AppDelegate.pendingURL = url
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.urlNotification, object: url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Benachrichtigungen
|
||||||
|
|
||||||
|
/// Auch im Vordergrund als Banner zeigen - sonst bliebe eine faellige
|
||||||
|
/// Erinnerung unbemerkt, solange die App offen ist.
|
||||||
|
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification) async
|
||||||
|
-> UNNotificationPresentationOptions {
|
||||||
|
[.banner, .sound]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Antippen: auf den betroffenen Server wechseln und „Bald ablaufend" oeffnen.
|
||||||
|
func userNotificationCenter(_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse) async {
|
||||||
|
guard let profile = response.notification.request.content.userInfo["profileID"] as? String
|
||||||
|
else { return }
|
||||||
|
AppDelegate.pendingExpiryProfile = profile
|
||||||
|
NotificationCenter.default.post(name: AppDelegate.expiryTapNotification, object: profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hintergrund-Aktualisierung
|
||||||
|
|
||||||
|
static func scheduleBackgroundRefresh() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: refreshTaskID)
|
||||||
|
// Fruehestens in ein paar Stunden - iOS entscheidet den genauen Zeitpunkt.
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 3600)
|
||||||
|
try? BGTaskScheduler.shared.submit(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleBackgroundRefresh(_ task: BGAppRefreshTask?) {
|
||||||
|
guard let task else { return }
|
||||||
|
// Gleich den naechsten Lauf einplanen, sonst gibt es nur einen.
|
||||||
|
scheduleBackgroundRefresh()
|
||||||
|
|
||||||
|
let arbeit = Task { @MainActor in
|
||||||
|
await NotificationScheduler.shared.rescheduleAll()
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
}
|
||||||
|
task.expirationHandler = { arbeit.cancel() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,35 +9,71 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; };
|
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; };
|
||||||
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 */; };
|
||||||
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 */; };
|
||||||
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 */; };
|
||||||
|
4AC141151264AF22701079B6 /* ProductPhotoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */; };
|
||||||
|
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3731608B8D48960DC98912BC /* NotificationSettings.swift */; };
|
||||||
|
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; };
|
||||||
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; };
|
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; };
|
||||||
|
56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B59B9F69BC30451D45A3C7 /* ItemViews.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 */; };
|
||||||
|
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 */; };
|
||||||
|
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */; };
|
||||||
|
82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */; };
|
||||||
|
83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090485CC54558EB4433376A9 /* DashboardCards.swift */; };
|
||||||
|
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; };
|
||||||
|
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */; };
|
||||||
|
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */; };
|
||||||
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; };
|
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; };
|
||||||
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
|
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
|
||||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; };
|
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; };
|
||||||
|
B531EEDAFAF442B1E86A9931 /* LookupScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F22598772AD8B5EB6394F8 /* LookupScanView.swift */; };
|
||||||
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */; };
|
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */; };
|
||||||
|
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */; };
|
||||||
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; };
|
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; };
|
||||||
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.swift */; };
|
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.swift */; };
|
||||||
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717C8EB336170526F5F3E695 /* DateScanView.swift */; };
|
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717C8EB336170526F5F3E695 /* DateScanView.swift */; };
|
||||||
|
F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */; };
|
||||||
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0DDD06332E567E70CA842F /* CheckOutView.swift */; };
|
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0DDD06332E567E70CA842F /* CheckOutView.swift */; };
|
||||||
|
FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = E580BA2815BB8F6341D64B47 /* ChartCards.swift */; };
|
||||||
/* 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>"; };
|
||||||
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>"; };
|
||||||
|
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>"; };
|
||||||
|
18F22598772AD8B5EB6394F8 /* LookupScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupScanView.swift; sourceTree = "<group>"; };
|
||||||
|
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = "<group>"; };
|
||||||
314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
|
||||||
369B9841E43E727ACA2E2A2A /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = "<group>"; };
|
369B9841E43E727ACA2E2A2A /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = "<group>"; };
|
||||||
|
3731608B8D48960DC98912BC /* NotificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettings.swift; sourceTree = "<group>"; };
|
||||||
378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||||
4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
|
||||||
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPhotoView.swift; sourceTree = "<group>"; };
|
||||||
|
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = "<group>"; };
|
||||||
|
69B59B9F69BC30451D45A3C7 /* ItemViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViews.swift; sourceTree = "<group>"; };
|
||||||
6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; };
|
6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; };
|
||||||
717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
|
717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
|
||||||
|
76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignScanView.swift; sourceTree = "<group>"; };
|
||||||
|
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectStockView.swift; sourceTree = "<group>"; };
|
||||||
|
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||||
8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; };
|
8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; };
|
||||||
|
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; };
|
||||||
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
|
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
|
||||||
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; };
|
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; };
|
||||||
A6785CD9174067EFBB2B9043 /* ListViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViews.swift; sourceTree = "<group>"; };
|
A6785CD9174067EFBB2B9043 /* ListViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViews.swift; sourceTree = "<group>"; };
|
||||||
@@ -45,8 +81,13 @@
|
|||||||
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = "<group>"; };
|
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = "<group>"; };
|
||||||
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySettings.swift; sourceTree = "<group>"; };
|
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySettings.swift; sourceTree = "<group>"; };
|
||||||
AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
|
AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
|
||||||
|
C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOverview.swift; sourceTree = "<group>"; };
|
||||||
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = "<group>"; };
|
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = "<group>"; };
|
||||||
DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; };
|
DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; };
|
||||||
|
DE4C9D922F5AC05270B1CF9B /* Vorrania.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Vorrania.entitlements; sourceTree = "<group>"; };
|
||||||
|
E580BA2815BB8F6341D64B47 /* ChartCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartCards.swift; sourceTree = "<group>"; };
|
||||||
|
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
||||||
|
EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -54,21 +95,42 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
378F1B2DE567B62169E84426 /* APIClient.swift */,
|
378F1B2DE567B62169E84426 /* APIClient.swift */,
|
||||||
|
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */,
|
||||||
|
76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */,
|
||||||
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */,
|
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */,
|
||||||
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */,
|
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */,
|
||||||
|
E580BA2815BB8F6341D64B47 /* ChartCards.swift */,
|
||||||
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */,
|
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */,
|
||||||
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */,
|
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */,
|
||||||
DC0DDD06332E567E70CA842F /* CheckOutView.swift */,
|
DC0DDD06332E567E70CA842F /* CheckOutView.swift */,
|
||||||
|
090485CC54558EB4433376A9 /* DashboardCards.swift */,
|
||||||
|
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */,
|
||||||
717C8EB336170526F5F3E695 /* DateScanView.swift */,
|
717C8EB336170526F5F3E695 /* DateScanView.swift */,
|
||||||
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
|
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
|
||||||
|
0ED41226394E7F31F37EC3B8 /* GroupViews.swift */,
|
||||||
|
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
|
||||||
|
00288DFA409BAD753AA4CA5D /* ItemListView.swift */,
|
||||||
|
69B59B9F69BC30451D45A3C7 /* ItemViews.swift */,
|
||||||
A6785CD9174067EFBB2B9043 /* ListViews.swift */,
|
A6785CD9174067EFBB2B9043 /* ListViews.swift */,
|
||||||
|
C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */,
|
||||||
A75926511854BB8EE316ED3A /* LoginView.swift */,
|
A75926511854BB8EE316ED3A /* LoginView.swift */,
|
||||||
|
18F22598772AD8B5EB6394F8 /* LookupScanView.swift */,
|
||||||
|
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */,
|
||||||
314B1BB7220691A7423E8929 /* Models.swift */,
|
314B1BB7220691A7423E8929 /* Models.swift */,
|
||||||
|
163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */,
|
||||||
|
3731608B8D48960DC98912BC /* NotificationSettings.swift */,
|
||||||
|
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */,
|
||||||
|
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */,
|
||||||
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
|
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
|
||||||
|
5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */,
|
||||||
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
|
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
|
||||||
4741D0E95875919C921945CF /* RootView.swift */,
|
4741D0E95875919C921945CF /* RootView.swift */,
|
||||||
8AF19A735AC17451B57BF5BB /* ScannerView.swift */,
|
8AF19A735AC17451B57BF5BB /* ScannerView.swift */,
|
||||||
|
1847E65D41ACEDD01CD108BC /* ServerFetch.swift */,
|
||||||
|
EA706060734632DE78FA1073 /* ServerListView.swift */,
|
||||||
|
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */,
|
||||||
AD2F9406BD0D4F0D30FED345 /* Session.swift */,
|
AD2F9406BD0D4F0D30FED345 /* Session.swift */,
|
||||||
|
DE4C9D922F5AC05270B1CF9B /* Vorrania.entitlements */,
|
||||||
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */,
|
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */,
|
||||||
);
|
);
|
||||||
path = Sources;
|
path = Sources;
|
||||||
@@ -163,20 +225,40 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */,
|
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */,
|
||||||
|
F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */,
|
||||||
|
5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */,
|
||||||
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */,
|
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */,
|
||||||
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */,
|
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */,
|
||||||
|
FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */,
|
||||||
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */,
|
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */,
|
||||||
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */,
|
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */,
|
||||||
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */,
|
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */,
|
||||||
|
83F9AD06177F3BC95421F128 /* DashboardCards.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 */,
|
||||||
|
749139BEFF35F9DAFFE87C3B /* ItemListView.swift in Sources */,
|
||||||
|
56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */,
|
||||||
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
|
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
|
||||||
|
82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */,
|
||||||
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
|
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
|
||||||
|
B531EEDAFAF442B1E86A9931 /* LookupScanView.swift in Sources */,
|
||||||
|
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */,
|
||||||
6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
|
6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
|
||||||
|
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */,
|
||||||
|
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */,
|
||||||
|
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */,
|
||||||
|
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */,
|
||||||
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
|
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
|
||||||
|
4AC141151264AF22701079B6 /* ProductPhotoView.swift in Sources */,
|
||||||
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
|
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
|
||||||
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
|
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
|
||||||
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */,
|
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */,
|
||||||
|
728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */,
|
||||||
|
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */,
|
||||||
|
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */,
|
||||||
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */,
|
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */,
|
||||||
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */,
|
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */,
|
||||||
);
|
);
|
||||||
@@ -245,6 +327,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Sources/Vorrania.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
@@ -266,6 +349,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Sources/Vorrania.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
|
|||||||
@@ -39,3 +39,11 @@ targets:
|
|||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
SWIFT_VERSION: "5.9"
|
SWIFT_VERSION: "5.9"
|
||||||
GENERATE_INFOPLIST_FILE: NO
|
GENERATE_INFOPLIST_FILE: NO
|
||||||
|
# Universal Links: …/i/<UID> öffnet direkt die App. Dafür muss im
|
||||||
|
# Apple-Developer-Portal die Capability "Associated Domains" für die App-ID
|
||||||
|
# aktiv sein (einmalig), sonst schlägt das Signieren fehl.
|
||||||
|
entitlements:
|
||||||
|
path: Sources/Vorrania.entitlements
|
||||||
|
properties:
|
||||||
|
com.apple.developer.associated-domains:
|
||||||
|
- applinks:vorrania-ch.scarriffle.com
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user