869 lines
40 KiB
Markdown
869 lines
40 KiB
Markdown
# Vision / Inventory Feature
|
||
|
||
## Übersicht
|
||
|
||
Hausinventar verwalten: Gegenstände per Kamera oder Barcode erfassen,
|
||
KI erkennt Titel + Kategorie, Raum-Zuordnung, vollständige Asset-Pflege.
|
||
|
||
---
|
||
|
||
## Datenmodell
|
||
|
||
### `rooms`
|
||
```
|
||
id, userId, name, groupType ('living'|'functional'|'outdoor'),
|
||
icon (string, lucide icon name, z.B. 'sofa'|'monitor'|'bath'|...),
|
||
sortOrder, updatedAt, deletedAt, purgedAt, version
|
||
```
|
||
|
||
### `assets`
|
||
```
|
||
id, userId, roomId, title, category,
|
||
status ('draft'|'complete'),
|
||
condition ('new'|'good'|'fair'|'poor'|null),
|
||
brand, model, serialNumber,
|
||
purchasePrice (real|null), purchaseYear (integer|null),
|
||
notes (markdown|null), coverImageId (→ imageBlobs.id|null),
|
||
updatedAt, deletedAt, purgedAt, version
|
||
```
|
||
`coverImageId` = Primärbild (erstes aufgenommenes Foto). Weitere Bilder in `assetImages`.
|
||
Cover kann auf Detailseite durch ein `assetImages`-Bild ersetzt werden (→ `updateAsset({ coverImageId })`).
|
||
|
||
### `assetImages`
|
||
```
|
||
id, userId, assetId, imageId (→ imageBlobs.id),
|
||
sortOrder, updatedAt, deletedAt, purgedAt, version
|
||
```
|
||
Bilder werden **nicht** lokal gecacht — `imageId` referenziert Server-seitigen Blob.
|
||
|
||
### `assetPersons` — Zuordnung Asset ↔ Person
|
||
```
|
||
id, userId, assetId, personId (→ contexts.id, wo type='person'),
|
||
updatedAt, deletedAt, purgedAt, version
|
||
```
|
||
Personen des Typs `family` (PersonSubType aus `contexts.meta.personSubType`).
|
||
Mehrere Personen pro Asset möglich (z.B. "gehört Mama und Papa").
|
||
|
||
`imageBlobs` — bereits vorhanden, wird wiederverwendet.
|
||
|
||
### `user_settings` (für Vision-API-Keys)
|
||
|
||
```
|
||
userId, key, value (JSON), updatedAt
|
||
PRIMARY KEY (userId, key)
|
||
```
|
||
|
||
### `vision_usage` (Rate Limiting)
|
||
```
|
||
userId, date (YYYY-MM-DD), count
|
||
PRIMARY KEY (userId, date)
|
||
```
|
||
|
||
---
|
||
|
||
## Erfassungs-Modi
|
||
|
||
### Foto-Modus (Loop)
|
||
```
|
||
[📷 Foto-Modus] → Raum wählen (einmalig)
|
||
→ Loop: Foto → KI-Erkennung → LabelConfirm → createAsset(draft) → "Nächstes"
|
||
→ [Fertig] → Dashboard
|
||
```
|
||
|
||
### Barcode-Modus (Loop)
|
||
```
|
||
[🔍 Barcode-Modus] → Raum wählen (einmalig)
|
||
→ Loop: Barcode scannen → Open Food Facts → LabelConfirm → createAsset(draft) → "Nächster"
|
||
→ [Fertig] → Dashboard
|
||
```
|
||
|
||
### Manuell (Einzel)
|
||
```
|
||
[+ Manuell] → Raum wählen → Titel eingeben → createAsset(draft) → Detailseite
|
||
```
|
||
|
||
---
|
||
|
||
## UI-Struktur
|
||
|
||
```
|
||
/inventory Dashboard (Stats + Quick Actions)
|
||
/inventory/rooms Raum-Übersicht (3-Spalten-Grid, gruppiert)
|
||
/inventory/items Alle Gegenstände (gefiltert, Grid/Liste)
|
||
/inventory/capture/photo?roomId= Foto-Loop
|
||
/inventory/capture/barcode?roomId= Barcode-Loop
|
||
/inventory/[id] Asset-Detailseite
|
||
```
|
||
|
||
### Asset-Liste `/inventory/items` (Mockup)
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────┐
|
||
│ Gegenstände [📷 Fotografieren][+ Manuell]│
|
||
│ 1 Gegenstand erfasst │
|
||
├──────────────────────────────────────────────────────┤
|
||
│ [🔍 Suchen nach Bezeichnung, Marke...] │
|
||
│ [📍 Alle Räume ▾] [🏷 Alle Kategorien ▾] [⊙ Alle Status ▾] [⊞][☰]│
|
||
├──────────────────────────────────────────────────────┤
|
||
│ ▼ 🟢 Ankleidezimmer · Eltern (1) │ ← collapsible Section
|
||
│ ┌─────────────┐ │
|
||
│ │ [Foto] │ │
|
||
│ │ [🛡] 17,00€│ ← Status-Icon links, Preis rechts │
|
||
│ └─────────────┘ │
|
||
│ Flauschige Decke │
|
||
│ 📍 Ankleidezimmer · Eltern │
|
||
└──────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- **Suchfeld**: filtert nach Titel, Marke, Modell (client-seitig, Dexie)
|
||
- **Filter-Dropdowns**: Raum, Kategorie, Status (draft/complete) — kombinierbar
|
||
- **Grid/Liste-Toggle**: Grid = Foto-Cards; Liste = kompakte Zeilen
|
||
- **Sections**: ein collapsible Block pro Raum (leer = ausgeblendet wenn gefiltert)
|
||
- **Card**: Cover-Bild, Preis-Badge unten rechts, Status-Icon (Schild) unten links
|
||
- **Status-Icon**: 🟢 complete, 🟡 draft (Badge-Farbe an Section-Header)
|
||
- Kein eigener Raum → Section "Ohne Raum"
|
||
|
||
### Räume-Übersicht `/inventory/rooms` (Mockup)
|
||
|
||
```
|
||
┌──────────────────────────────────────────┐
|
||
│ Räume [+ Raum anlegen]│
|
||
│ Verwalte deine Räume │
|
||
├──────────────────────────────────────────┤
|
||
│ Wohnbereiche │ ← Gruppen-Header
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||
│ │[👕] Ankl.│ │[🖥] Arb. │ │[🍴] Ess. │ │ ← 3-Spalten-Grid
|
||
│ │1 Gegenst.│ │0 Gegenst.│ │0 Gegenst.│ │
|
||
│ └────── ···┘ └────── ···┘ └────── ···┘ │ ← ··· Context-Menu
|
||
├──────────────────────────────────────────┤
|
||
│ Funktionsräume │
|
||
│ ... │
|
||
└──────────────────────────────────────────┘
|
||
```
|
||
|
||
- Click auf Karte → `/inventory/rooms/[roomId]` (Asset-Liste)
|
||
- `···` Menu: Umbenennen, Löschen (Soft-Delete)
|
||
- Icons: raumspezifisch (lucide), hardcodierte Mapping-Tabelle Name→Icon
|
||
- Asset-Zähler: liveQuery auf `assets.roomId`, nicht-deleted
|
||
|
||
### Dashboard-Layout (Mockup)
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ Willkommen! │
|
||
│ Übersicht deines Hausinventars │
|
||
├─────────────────────────────────────┤
|
||
│ 📷 Einfach fotografieren │ ← Primär-CTA (Vollbreite, dunkel)
|
||
├──────────────────┬──────────────────┤
|
||
│ + Gegenstand │ + Raum │ ← Sekundär (je halbe Breite)
|
||
├──────────────────┴──────────────────┤
|
||
│ Gegenstände [📦] │ Räume [🏠] │ ← Stats-Row (2-spaltig)
|
||
│ 1 │ 24 │
|
||
├─────────────────────────────────────┤
|
||
│ Gesamtwert [€] │ ← Breite Stats-Card
|
||
│ 17,00 € │
|
||
│ Der erfasste Gesamtwert gibt dir │
|
||
│ eine gute Orientierung für deine │
|
||
│ Versicherungssumme. │
|
||
├─────────────────────────────────────┤
|
||
│ → Räume & Gegenstände anzeigen │ ← Link zu /inventory/rooms
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
- "Einfach fotografieren" → RoomSelectSheet → Foto-Loop
|
||
- "+ Gegenstand" → RoomSelectSheet → manuell anlegen → Detailseite
|
||
- "+ Raum" → inline Modal (Name + Gruppe)
|
||
- Stats: `liveQuery` auf `assets` + `rooms` (nicht-deleted, Summe `purchasePrice`)
|
||
- Gesamtwert: Summe aller `assets.purchasePrice` (null = nicht eingerechnet)
|
||
|
||
---
|
||
|
||
## Architektur-Entscheidung: Sync vs. Online-only
|
||
|
||
**Metadaten** (`rooms`, `assets`, `assetPersons`, `assetImages`-Referenzen):
|
||
→ normaler Push/Pull, lokal in Dexie. Datenmenge trivial (~500 KB bei 500 Gegenständen).
|
||
→ Offline-Lesezugriff sinnvoll (Liste/Suche ohne WLAN).
|
||
|
||
**Bilder — gewählter Ansatz: Server-Upload, kein lokaler Blob**
|
||
`storeImage()` schreibt immer in Dexie (kein Bypass). 500 × 5 MB = 2,5 GB lokal — nicht akzeptabel.
|
||
|
||
→ Neues `uploadInventoryImage(blob): Promise<string>`:
|
||
1. Bild client-seitig komprimieren: `canvas.toBlob('image/jpeg', 0.75)` + max-width 1920px → ~300 KB
|
||
2. Direkt POST zu `/api/inventory/images` (neuer Endpoint, kein Sync-Push)
|
||
3. Server speichert in `imageBlobs`, gibt `imageId` zurück
|
||
4. Client speichert nur `imageId` im Asset — kein Dexie-Blob
|
||
5. `getImageUrl(id)` fetcht via `/api/sync/blob/{id}` — immer online
|
||
|
||
→ **Folge**: Kamera-Erfassung erfordert Online-Verbindung (akzeptabel für PWA mit WLAN).
|
||
→ `storeImage()` bleibt unverändert für alle anderen Bild-Contexts (Editor-Paste etc.).
|
||
→ Neuer Server-Endpoint: `POST /api/inventory/images` (multipart, authMiddleware)
|
||
|
||
**Kein Online-only-Controller für Metadaten nötig** — bisherige Architektur skaliert gut.
|
||
|
||
---
|
||
|
||
## Schlüssel-Entscheidungen
|
||
|
||
- **API-Key pro User** — in Settings-Seite eingeben, AES-256-GCM verschlüsselt in `user_settings`
|
||
- **`SETTINGS_ENCRYPTION_KEY`** in `.env` (plain, self-hosted)
|
||
- **Rate Limit** — 100 Vision-Calls/Tag pro User, DB-basiert via `vision_usage`
|
||
- **Provider** — OpenAI `gpt-4o-mini` (detail:low, ~$0.0003/Bild) oder Gemini `gemini-1.5-flash`
|
||
- **Kategorien** — hardcoded Liste, kein eigenes Table:
|
||
`null (Keine Kategorie), Elektronik, Möbel, Kleidung, Schmuck, Kunstwerke,
|
||
Sportgeräte, Haushaltsgeräte, Haustechnik, Werkzeuge, Musikinstrumente,
|
||
Bücher & Medien, Spielzeug, Sammlerstücke, Gartengeräte, Fahrzeugzubehör, Sonstiges`
|
||
- **Räume** — eigene Tabelle (kein Kontext-Typ), Soft-Delete, Default-Seed beim ersten Start
|
||
- **Status** — `draft` nach Schnellerfassung, `complete` nach Nachbearbeitung
|
||
- **Löschung** — Soft-Delete überall, wie Wiki-Pages
|
||
- **Personen** — nur `family`-Subtype auswählbar (filter: `contexts.type='person'` + `meta.personSubType='family'`); Zuordnung via `assetPersons`-Tabelle (n:m)
|
||
|
||
---
|
||
|
||
## Phasen
|
||
|
||
### Dependency-Graph
|
||
|
||
```
|
||
Phase 1 (Datenfundament)
|
||
├─→ Phase 2 (Vision-Backend) [nur Server, unabhängig von Client-Schema]
|
||
├─→ Phase 5 (Settings UI) [benötigt Phase 2 + Phase 1 Client]
|
||
└─→ Phase 4 (Inventar-UI) [benötigt Phase 1 vollständig]
|
||
└─→ Phase 6 (CommandBar) [benötigt Phase 4 Repositories]
|
||
Phase 2 + Phase 5 → Phase 3 (Erfassungs-Flow) [braucht API + UI-Key-Config]
|
||
Phase 4 → Phase 7 (Docs) [nach stabilem UI]
|
||
```
|
||
|
||
**Parallelisierung:**
|
||
- Phase 2 Schritte 2.1–2.4: vollständig parallel (je eigene neue Datei)
|
||
- Phase 5 + Phase 2.5: parallel nach Phase 2.1–2.4
|
||
- Phase 4 Schritte 4.1–4.4: parallel (je eigene neue Datei)
|
||
- Phase 3 + Phase 4 können nach Phase 1 parallel beginnen (3 benötigt noch Phase 2+5)
|
||
|
||
---
|
||
|
||
### Phase 1 — Datenfundament
|
||
|
||
**Requires:** nichts (Startpunkt)
|
||
**Produces:**
|
||
- `shared/src/types.ts` — `Room`, `Asset`, `AssetImage`, `AssetPerson` Types exportiert
|
||
- `shared/src/sync.ts` — `SyncChanges` enthält 4 neue Tabellen
|
||
- `server/drizzle/0009_inventory.sql` — Migration ausführbar
|
||
- `client/src/lib/db/schema.ts` v15 — Dexie öffnet ohne Fehler
|
||
- `client/src/lib/db/repositories.ts` — alle CRUD-Funktionen + `seedDefaultRoomsIfNeeded` exportiert
|
||
- Server `sync-service.ts` + `routes/sync.ts` — Push/Pull für 4 neue Entitäten
|
||
|
||
**Ziel:** Shared Types, Server- und Client-Schema, Repositories, Sync.
|
||
|
||
#### Schritt 1.1 — Shared Types
|
||
- Datei: `shared/src/types.ts`
|
||
- `+ Room extends SyncEntity`
|
||
- `+ Asset extends SyncEntity` (alle Felder inkl. `status`, `condition`, etc.)
|
||
- `+ AssetImage extends SyncEntity`
|
||
- `+ AssetPerson extends SyncEntity` (`assetId`, `personId`)
|
||
- Datei: `shared/src/sync.ts`
|
||
- `SyncChanges`: `+ rooms, assets, assetImages, assetPersons`
|
||
- `SYNC_PULL_TABLE_ORDER`: `[..., 'rooms', 'assets', 'assetImages', 'assetPersons']`
|
||
- `SYNC_PUSH_TABLE_ORDER`: gleich
|
||
|
||
#### Schritt 1.2 — Server DB-Schema + Migration
|
||
- Datei: `server/src/db/schema.ts`
|
||
- + `rooms`, `assets`, `assetImages`, `assetPersons`, `user_settings`, `vision_usage`
|
||
- `assetPersons`: FK `(assetId, userId)` → assets + FK `(personId, userId)` → contexts
|
||
- Muster: Composite PK `(id, userId)`, Drizzle `sqliteTable`
|
||
- Datei: `server/drizzle/0009_inventory.sql` (auto-generiert via `drizzle-kit generate`)
|
||
|
||
#### Schritt 1.3 — Client Dexie Schema v15
|
||
- Datei: `client/src/lib/db/schema.ts`
|
||
- Version 15: + `rooms: '&id, groupType, deletedAt'`
|
||
- + `assets: '&id, roomId, status, deletedAt'`
|
||
- + `assetImages: '&id, assetId, deletedAt'`
|
||
- + `assetPersons: '&id, assetId, personId, deletedAt'`
|
||
|
||
#### Schritt 1.4 — Client Repositories
|
||
- Datei: `client/src/lib/db/repositories.ts`
|
||
- Room: `createRoom`, `getRooms`, `updateRoom`, `softDeleteRoom`
|
||
- Asset: `createAsset`, `getAsset`, `getAssetsByRoom`, `getAssetsAll`, `getAssetsByStatus`, `updateAsset`, `softDeleteAsset`
|
||
- `getAssetsAll()` — alle nicht-deleted Assets (für CommandBar + `/inventory/items`)
|
||
- `getAssetsByStatus('draft'|'complete')` — für Status-Filter
|
||
- AssetImage: `addAssetImage`, `getAssetImages`, `softDeleteAssetImage`
|
||
- AssetPerson: `addAssetPerson`, `getAssetPersons`, `removeAssetPerson` (Soft-Delete)
|
||
- Helper: `getFamilyPersons()` — filtert `db.contexts` nach `type='person'` + `meta.personSubType='family'`
|
||
- Seed-Funktion: `seedDefaultRoomsIfNeeded(userId)` — prüft `settings`-Flag `'inventory.seeded'`
|
||
|
||
#### Schritt 1.5 — Sync-Erweiterung
|
||
- Datei: `server/src/lib/sync-service.ts`
|
||
- + `mapRoom`, `mapAsset`, `mapAssetImage`, `mapAssetPerson`
|
||
- `pushChanges` + `pullChanges` um 4 Entitäten erweitern
|
||
- Datei: `server/src/routes/sync.ts`
|
||
- `VALID_TABLES` + `'rooms'`, `'assets'`, `'assetImages'`, `'assetPersons'`
|
||
- Datei: `client/src/lib/sync/syncService.ts`
|
||
- Push/Pull-Mapping für 4 neue Entitäten
|
||
|
||
**Verifikation 1:**
|
||
- `npm run dev` startet ohne Fehler
|
||
- Browser-Console: kein Dexie-Schema-Fehler beim Öffnen der App
|
||
- `GET /api/sync/pull` mit gültigem Token → Response enthält Keys `rooms`, `assets`, `assetImages`, `assetPersons` (leere Arrays OK)
|
||
- `POST /api/sync/push` mit `{ rooms: [{ id, name, groupType, ... }] }` → HTTP 200
|
||
- `db.rooms.count()` in Browser-Console → 0 (kein Fehler)
|
||
|
||
---
|
||
|
||
### Phase 2 — Vision-Backend
|
||
|
||
**Requires:** Phase 1 abgeschlossen (Server-Schema mit `user_settings`, `vision_usage` vorhanden)
|
||
**Produces:**
|
||
- `server/src/lib/user-settings-service.ts` — exportiert `getSetting`, `setSetting`, `getVisionConfig`
|
||
- `server/src/lib/rate-limiter.ts` — exportiert `checkAndIncrement`
|
||
- `server/src/lib/vision-service.ts` — exportiert `getProvider(name, apiKey): VisionProvider`
|
||
- `server/src/lib/barcode-service.ts` — exportiert `lookupBarcode(ean)`
|
||
- `server/src/routes/vision.ts` — Router mit 4 Endpoints
|
||
- `server/src/routes/inventory.ts` — Router mit `POST /api/inventory/images`
|
||
- `server/.env.example` — `SETTINGS_ENCRYPTION_KEY` + `VISION_RATE_LIMIT` dokumentiert
|
||
|
||
**Ziel:** API-Key-Verwaltung, Rate Limiting, Vision + Barcode-Proxy auf dem Server.
|
||
|
||
#### Schritt 2.1 — User Settings Service [parallel]
|
||
- Datei: `server/src/lib/user-settings-service.ts` (NEU)
|
||
- `getSetting(userId, key): string | null`
|
||
- `setSetting(userId, key, value): void`
|
||
- `getVisionConfig(userId): { provider, apiKey } | null`
|
||
- AES-256-GCM Verschlüsselung mit `SETTINGS_ENCRYPTION_KEY` aus `.env`
|
||
- Datei: `server/.env.example`: + `SETTINGS_ENCRYPTION_KEY=`, `VISION_RATE_LIMIT=100`
|
||
|
||
#### Schritt 2.2 — Rate Limiter [parallel]
|
||
- Datei: `server/src/lib/rate-limiter.ts` (NEU)
|
||
- `checkAndIncrement(userId, limit): { allowed, remaining }`
|
||
- `INSERT OR IGNORE` + `UPDATE` auf `vision_usage` (date = heute)
|
||
|
||
#### Schritt 2.3 — Vision Service [parallel]
|
||
- Datei: `server/src/lib/vision-service.ts` (NEU)
|
||
```
|
||
interface VisionProvider { recognize(base64, mime): Promise<{label, category, candidates}> }
|
||
class OpenAIVisionProvider // gpt-4o-mini, detail:low
|
||
class GeminiVisionProvider // gemini-1.5-flash
|
||
export function getProvider(name, apiKey): VisionProvider
|
||
```
|
||
Prompt (beide Provider):
|
||
```
|
||
Identify the main object. Return JSON:
|
||
{ "label": "<German, max 60 chars>",
|
||
"category": "<Elektronik|Möbel|Kleidung|Schmuck|Kunstwerke|Sportgeräte|
|
||
Haushaltsgeräte|Haustechnik|Werkzeuge|Musikinstrumente|
|
||
Bücher & Medien|Spielzeug|Sammlerstücke|Gartengeräte|
|
||
Fahrzeugzubehör|Sonstiges>",
|
||
"candidates": ["<alt1>", "<alt2>"] }
|
||
```
|
||
|
||
#### Schritt 2.4 — Barcode Service [parallel]
|
||
- Datei: `server/src/lib/barcode-service.ts` (NEU)
|
||
- `lookupBarcode(ean): Promise<{label, category?} | null>`
|
||
- **Primär**: UPCitemdb `https://api.upcitemdb.com/prod/trial/lookup?upc={ean}` (breite Produktdatenbank, 100/Tag frei)
|
||
- **Fallback**: Open Food Facts `https://world.openfoodfacts.org/api/v0/product/{ean}.json` (nur Lebensmittel)
|
||
- **Letzter Fallback**: `{ label: ean }` (roher EAN-String)
|
||
- Kategorie-Mapping: UPCitemdb `category` → Inventar-Kategorieliste
|
||
|
||
#### Schritt 2.5 — Vision Route + Image-Upload Endpoint [nach 2.1–2.4]
|
||
- Datei: `server/src/routes/vision.ts` (NEU)
|
||
```
|
||
POST /api/vision/recognize — multipart { image: File }
|
||
POST /api/vision/barcode — JSON { ean: string }
|
||
GET /api/vision/settings — { provider, apiKeySet, dailyUsage, dailyLimit }
|
||
PUT /api/vision/settings — { provider?, apiKey? }
|
||
```
|
||
- Datei: `server/src/routes/inventory.ts` (NEU, separater Router)
|
||
```
|
||
POST /api/inventory/images — multipart { image: File } → speichert in imageBlobs, gibt { id } zurück
|
||
```
|
||
Alle Routen: `authMiddleware`. `/api/vision/*` zusätzlich Rate-Limit-Check.
|
||
- Datei: `server/src/index.ts`: + 4 Zeilen (beide Routes registrieren)
|
||
|
||
**Verifikation 2:**
|
||
- `PUT /api/vision/settings` mit gültigem `{ provider: 'openai', apiKey: '...' }` → HTTP 200
|
||
- `GET /api/vision/settings` → `{ provider: 'openai', apiKeySet: true, dailyUsage: 0, dailyLimit: 100 }`
|
||
- `POST /api/vision/recognize` mit JPEG-Testbild → HTTP 200, Body enthält `label` (string), `category` (string aus Kategorienliste), `candidates` (Array)
|
||
- `POST /api/vision/barcode` mit `{ ean: '4006381333931' }` (Stabilo-Stift) → HTTP 200, `label` nicht leer
|
||
- `POST /api/inventory/images` mit JPEG multipart → HTTP 200, `{ id: '...' }`
|
||
- Zweiter Recognize-Call → `dailyUsage: 1` in GET settings
|
||
- 101. Call (simuliert via DB-Insert) → HTTP 429
|
||
|
||
---
|
||
|
||
### Phase 3 — Erfassungs-Flow (Client)
|
||
|
||
**Requires:** Phase 1 + Phase 2 + Phase 5 abgeschlossen (API-Key konfigurierbar, Endpoints live)
|
||
**Produces:**
|
||
- `client/src/lib/services/visionService.ts` — `compressImage`, `uploadInventoryImage`, `recognizePhoto`, `recognizeBarcode` exportiert
|
||
- `client/src/lib/services/visionSettingsService.ts` — `getVisionSettings`, `saveVisionSettings` exportiert
|
||
- `client/src/lib/components/LabelConfirm.svelte` — rendert ohne Fehler
|
||
- `client/src/lib/components/PhotoCapture.svelte` — rendert ohne Fehler
|
||
- `client/src/lib/components/BarcodeScan.svelte` — rendert ohne Fehler
|
||
- `client/src/lib/components/RoomSelectSheet.svelte` — rendert ohne Fehler
|
||
- Zwei neue Routen: `/inventory/capture/photo` + `/inventory/capture/barcode`
|
||
|
||
**Ziel:** Kamera-Flow, Barcode-Scan, Raum-Auswahl, Loop-Seiten.
|
||
|
||
#### Schritt 3.1 — Vision Service Client
|
||
- Datei: `client/src/lib/services/visionService.ts` (NEU)
|
||
- `compressImage(blob): Promise<Blob>` — HEIC→JPEG + max-width 1920px + quality 0.75
|
||
- `uploadInventoryImage(blob): Promise<string>` — POST `/api/inventory/images`, gibt imageId zurück
|
||
- `recognizePhoto(blob): Promise<RecognizeResult>` — POST `/api/vision/recognize`
|
||
- `recognizeBarcode(ean: string): Promise<RecognizeResult>` — POST `/api/vision/barcode`
|
||
- **Kein ZXing client-seitig** — Barcode-Erkennung läuft server-seitig (via UPCitemdb-Lookup)
|
||
- Barcode-Modus: User gibt EAN manuell ein ODER fotografiert Barcode → Server erkennt via KI+Lookup
|
||
- Datei: `client/src/lib/services/visionSettingsService.ts` (NEU)
|
||
- `getVisionSettings()`, `saveVisionSettings()`
|
||
- `@zxing/browser` **entfällt** — kein client-seitiger Barcode-Scan
|
||
|
||
#### Schritt 3.2 — LabelConfirm Komponente (geteilt)
|
||
- Datei: `client/src/lib/components/LabelConfirm.svelte` (NEU)
|
||
- Props: `label, category, candidates, onconfirm, oncancel`
|
||
- Label-Input + Kategorie-Dropdown + Kandidaten-Chips
|
||
|
||
#### Schritt 3.3 — RoomSelectSheet
|
||
- Datei: `client/src/lib/components/RoomSelectSheet.svelte` (NEU)
|
||
- Räume gruppiert nach `groupType` anzeigen
|
||
- "Neuen Raum anlegen" inline
|
||
- Props: `onselect(roomId), oncancel`
|
||
|
||
#### Schritt 3.4 — PhotoCapture Komponente
|
||
- Datei: `client/src/lib/components/PhotoCapture.svelte` (NEU)
|
||
- States: `idle → analyzing → confirm | error`
|
||
- `<input type="file" accept="image/*" capture="environment">`
|
||
- HEIC→JPEG Normalisierung vor API-Call
|
||
- Props: `onconfirm(label, category, imageId), oncancel`
|
||
|
||
#### Schritt 3.5 — BarcodeScan Komponente
|
||
- Datei: `client/src/lib/components/BarcodeScan.svelte` (NEU)
|
||
- **Zwei Eingabepfade:**
|
||
1. EAN manuell eingeben (Nummernpad, für Nutzer mit Barcode-Scanner-Gerät)
|
||
2. Foto aufnehmen → Server-Lookup (wie Foto-Modus, aber `/api/vision/barcode` mit Bild)
|
||
- States: `idle → looking_up → confirm | error`
|
||
- Props: `onconfirm(label, category, ean?), oncancel`
|
||
- **Kein ZXing** — Barcode-Erkennung im Foto via Server (KI-Modell kann Barcodes lesen)
|
||
|
||
#### Schritt 3.6 — Loop-Seiten
|
||
- Datei: `client/src/routes/inventory/capture/photo/+page.svelte` (NEU)
|
||
- Query: `?roomId=`
|
||
- Loop: PhotoCapture → createAsset(draft) → Counter "X erfasst" → weiter / fertig
|
||
- Datei: `client/src/routes/inventory/capture/barcode/+page.svelte` (NEU)
|
||
- Query: `?roomId=`
|
||
- Loop: BarcodeScan → createAsset(draft) → Counter → weiter / fertig
|
||
|
||
**Verifikation 3:**
|
||
- `/inventory/capture/photo?roomId=X` lädt ohne JS-Fehler
|
||
- Foto aufnehmen → `recognizePhoto` liefert Label → LabelConfirm zeigt es an
|
||
- Bestätigen → `db.assets.count()` steigt um 1, neues Asset hat `status='draft'`, `roomId=X`, `coverImageId` nicht null
|
||
- 3 Fotos in Serie → Counter zeigt "3 erfasst"
|
||
- "Fertig" → Redirect auf `/inventory`
|
||
- Barcode-Loop: EAN `4006381333931` eingeben → Label erscheint → Asset angelegt ohne `coverImageId`
|
||
|
||
---
|
||
|
||
### Phase 4 — Inventar-UI
|
||
|
||
**Requires:** Phase 1 abgeschlossen (Repositories vorhanden)
|
||
**Produces:**
|
||
- `client/src/lib/components/AssetCard.svelte`
|
||
- `client/src/routes/inventory/+page.svelte`
|
||
- `client/src/routes/inventory/rooms/+page.svelte`
|
||
- `client/src/routes/inventory/rooms/[id]/+page.svelte`
|
||
- `client/src/routes/inventory/items/+page.svelte`
|
||
- `client/src/routes/inventory/[id]/+page.svelte`
|
||
- `client/src/lib/components/Sidebar.svelte` — Inventar-Eintrag ergänzt
|
||
|
||
**Ziel:** Dashboard, Asset-Detailseite, Sidebar.
|
||
|
||
#### Schritt 4.1 — AssetCard Komponente [parallel]
|
||
- Datei: `client/src/lib/components/AssetCard.svelte` (NEU)
|
||
- Cover-Bild (via `getImageUrl`) oder Platzhalter-Icon
|
||
- Titel + Kategorie-Badge + Draft-Badge (wenn `status='draft'`)
|
||
- Click → `goto('/inventory/' + id)`
|
||
|
||
#### Schritt 4.2 — Inventar-Dashboard [parallel]
|
||
- Datei: `client/src/routes/inventory/+page.svelte` (NEU)
|
||
- Seed-Aufruf: `seedDefaultRoomsIfNeeded()`
|
||
- Stats-Cards: Gegenstände-Zähler, Räume-Zähler, Gesamtwert (Summe `purchasePrice`)
|
||
- Versicherungshinweis unter Gesamtwert
|
||
- Primär-CTA: "📷 Einfach fotografieren" → RoomSelectSheet → Foto-Loop
|
||
- Sekundär: "+ Gegenstand" (manuell) | "+ Raum" (inline-Modal)
|
||
- Link zu `/inventory/rooms` + `/inventory/items`
|
||
|
||
#### Schritt 4.3 — Räume-Übersicht [parallel]
|
||
- Datei: `client/src/routes/inventory/rooms/+page.svelte` (NEU)
|
||
- 3-Spalten-Grid, gruppiert nach `groupType`
|
||
- Raumkarte: Icon (hardcodiertes Name→Icon Mapping) + Name + Asset-Zähler + `···` Menu
|
||
- `···`: Umbenennen (inline), Löschen (Soft-Delete + ConfirmDialog)
|
||
- Click → `/inventory/rooms/[id]` (gefilterte Asset-Liste)
|
||
- Datei: `client/src/routes/inventory/rooms/[id]/+page.svelte` (NEU)
|
||
- Asset-Liste gefiltert auf diesen Raum (wie `/inventory/items` aber ohne Raum-Filter)
|
||
|
||
#### Schritt 4.4 — Globale Asset-Liste [parallel]
|
||
- Datei: `client/src/routes/inventory/items/+page.svelte` (NEU)
|
||
- Header: "Gegenstände" + Zähler + `[📷 Fotografieren]` + `[+ Manuell]`
|
||
- Suchfeld (client-seitig, filtert Titel/Marke/Modell)
|
||
- Filter-Dropdowns: Raum | Kategorie | Status
|
||
- Grid/Liste-Toggle (Svelte store, persistiert in `settings`)
|
||
- Sections pro Raum (collapsible), Status-Icon (grün/gelb)
|
||
- Asset-Card: Cover-Bild, Preis-Badge, Status-Icon, Titel, Raum-Badge
|
||
|
||
#### Schritt 4.6 — Asset-Detailseite
|
||
- Datei: `client/src/routes/inventory/[id]/+page.svelte` (NEU)
|
||
- Muster: analog `wiki/[id]/+page.svelte` (`$derived(page.params.id)`, `$effect`)
|
||
- Cover-Bild groß + Galerie (weitere Bilder horizontal scrollbar)
|
||
- Inline-Edit alle Felder (sofort gespeichert via `updateAsset`)
|
||
- Status-Toggle: Draft → Complete
|
||
- Galerie: `+ Foto hinzufügen` (ohne KI) + Löschen (Soft-Delete `assetImages`)
|
||
- Notizen: `MarkdownEditor.svelte` (bestehende Komponente)
|
||
- **Personen-Sektion**: "Gehört / zugeordnet" — Chips für zugeordnete Familienmitglieder
|
||
- `getFamilyPersons()` → Dropdown/Picker mit allen `family`-Personen
|
||
- Hinzufügen → `addAssetPerson`, Entfernen → `removeAssetPerson`
|
||
- Anzeige: Avatar-Chip mit `PersonMeta.abbreviation` oder `fullName`
|
||
- Löschen-Button → `ConfirmDialog.svelte` → `softDeleteAsset` → `goto('/inventory')`
|
||
|
||
#### Schritt 4.7 — Sidebar
|
||
- Datei: `client/src/lib/components/Sidebar.svelte`
|
||
- Neuer Abschnitt "Inventar" mit Link `/inventory`, Icon: `Package` (lucide)
|
||
- `BottomTabBar` bleibt unverändert — Navigation über Sidebar + CommandBar ausreichend
|
||
|
||
**Verifikation 4:**
|
||
- `/inventory` lädt: Seed-Räume sichtbar (15 Default-Räume), Gesamtwert 0 €
|
||
- `seedDefaultRoomsIfNeeded` ein zweites Mal aufrufen → `db.rooms.count()` bleibt gleich (kein Doppel-Seed)
|
||
- `/inventory/rooms` zeigt 3 Gruppen-Header, alle 15 Default-Räume als Cards
|
||
- Raum umbenennen → Name ändert sich ohne Reload
|
||
- Raum löschen → `deletedAt` gesetzt, Card verschwindet
|
||
- `/inventory/items` mit Filter Raum=X → nur Assets dieses Raums sichtbar
|
||
- Asset-Detailseite: Titel ändern → `updateAsset` aufgerufen, Seite zeigt neuen Titel
|
||
- Status-Toggle Draft→Complete → `status='complete'` in Dexie, Badge ändert Farbe
|
||
- Foto hinzufügen (ohne KI) → `assetImages` Eintrag + neues Bild in Galerie
|
||
- Löschen-Button → ConfirmDialog → `softDeleteAsset` → Redirect auf `/inventory`
|
||
- Sidebar zeigt "Inventar"-Link → navigiert zu `/inventory`
|
||
|
||
---
|
||
|
||
### Phase 5 — Settings (Vision-API-Key)
|
||
|
||
**Requires:** Phase 2 (Vision-Backend) abgeschlossen — insbesondere `GET/PUT /api/vision/settings`
|
||
**Produces:**
|
||
- `client/src/routes/settings/+page.svelte` — Abschnitt "Bild-Erkennung" ergänzt
|
||
- `client/src/lib/services/visionSettingsService.ts` (kann parallel zu Phase 3.1 entstehen)
|
||
|
||
**Ziel:** Vision-API-Key pro User konfigurierbar. Muss vor Phase 3 implementiert sein.
|
||
|
||
#### Schritt 5.1 — Settings-Sektion
|
||
- Datei: `client/src/routes/settings/+page.svelte`
|
||
- Neuer Abschnitt "Bild-Erkennung"
|
||
- Provider-Wahl: Radio OpenAI / Gemini
|
||
- API-Key Input (type=password)
|
||
- Anzeige wenn gesetzt: `●●●●●●...XXXX` (letzten 4 Zeichen aus Server-Response)
|
||
- Tageslimit + Verbrauch (aus `GET /api/vision/settings`)
|
||
- Speichern → `saveVisionSettings()`
|
||
|
||
**Verifikation 5:**
|
||
- Settings-Seite öffnen → Abschnitt "Bild-Erkennung" vorhanden
|
||
- Provider wählen + Key eingeben + Speichern → `PUT /api/vision/settings` HTTP 200
|
||
- Seite neu laden → Key-Feld zeigt `●●●●●●...XXXX` (letzten 4 Zeichen), Provider korrekt vorgewählt
|
||
- `dailyUsage` + `dailyLimit` angezeigt
|
||
|
||
> ⚠️ **Implementierungsreihenfolge**: Phase 5 (Settings) sollte vor oder parallel zu Phase 3 (Erfassungs-Flow) umgesetzt werden — ohne konfigurierten API-Key ist Phase 3 nicht testbar.
|
||
|
||
---
|
||
|
||
### Phase 6 — CommandBar-Integration
|
||
|
||
**Requires:** Phase 4 abgeschlossen (Repositories + `/inventory/[id]` Route vorhanden)
|
||
**Produces:**
|
||
- `client/src/lib/components/CommandBar.svelte` — Assets in Suchergebnissen + 2 neue Slash-Commands
|
||
|
||
**Ziel:** Assets über CommandBar findbar, `/asset`-Command für schnelle Erfassung.
|
||
|
||
#### Schritt 6.1 — Asset-Suche in CommandBar
|
||
- Datei: `client/src/lib/components/CommandBar.svelte`
|
||
- Datenquelle: `liveQuery(() => db.assets.filter(a => !a.deletedAt).toArray())` — kein neuer Endpoint
|
||
- Filterung: `asset.title + asset.brand + asset.model` (analog bestehende Context-Suche)
|
||
- Anzeige-Format:
|
||
```
|
||
│ 📦 Flauschige Decke INVENTAR │
|
||
│ Ankleidezimmer · Eltern │
|
||
```
|
||
- Ergebnis-Limit: max. 3 Assets (in bestehende 3+3-Logik einpassen)
|
||
- `Enter` → `goto('/inventory/' + asset.id)`
|
||
|
||
#### Schritt 6.2 — Slash-Commands
|
||
- Datei: `client/src/lib/components/CommandBar.svelte`
|
||
- 2 neue Einträge im `COMMANDS`-Array:
|
||
|
||
| Command | Verhalten |
|
||
|---|---|
|
||
| `/inventar` | `goto('/inventory')` |
|
||
| `/asset [Titel]` | Mit Text: `createAsset(title, roomId=null)` → `goto('/inventory/' + id)`; ohne Text: `goto('/inventory')` mit Manuell-Flag |
|
||
|
||
**Verifikation 6:**
|
||
- Asset "Flauschige Decke" in Dexie vorhanden
|
||
- `Ctrl+K` → "decke" tippen → Eintrag mit Badge `INVENTAR` erscheint in Ergebnissen
|
||
- Enter → Redirect auf `/inventory/{id}`
|
||
- `Ctrl+K` → `/inventar` Enter → Redirect auf `/inventory`
|
||
- `Ctrl+K` → `/asset Laptop` Enter → neues Asset mit `title='Laptop'`, `roomId=null`, `status='draft'` in Dexie, Detailseite öffnet
|
||
- `Ctrl+K` → `/asset` ohne Text Enter → Redirect auf `/inventory` (kein Fehler)
|
||
|
||
---
|
||
|
||
### Phase 7 — Feature-Dokumentation
|
||
|
||
**Requires:** Phase 4 abgeschlossen (UI + Datenmodell stabil), Phase 6 abgeschlossen (CommandBar-Befehle final)
|
||
**Produces:** `docs/feature-inventory.md` — versioniert im Repo, Inhalt stimmt mit implementiertem Stand überein
|
||
|
||
**Ziel:** `docs/feature-inventory.md` anlegen (analog `feature-tasks.md`, `feature-commandbar.md`).
|
||
Wird nach Abschluss von Phase 4+6 geschrieben — wenn UI + Datenmodell stabil sind.
|
||
|
||
#### Inhalt `docs/feature-inventory.md` (Entwurf)
|
||
|
||
```markdown
|
||
# Feature: Inventar
|
||
|
||
## Zweck
|
||
|
||
Hausinventar erfassen und verwalten: Gegenstände per Kamera oder Barcode aufnehmen,
|
||
KI erkennt Titel + Kategorie automatisch, Zuordnung zu Räumen und Familienmitgliedern.
|
||
Gesamtwert als Grundlage für Versicherungssumme.
|
||
|
||
---
|
||
|
||
## Räume
|
||
|
||
Räume werden in drei Gruppen eingeteilt (hardcodiert, nicht erweiterbar):
|
||
|
||
| `groupType` | Label | Beispiel-Default-Räume |
|
||
|---|---|---|
|
||
| `living` | Wohnbereiche | Wohnzimmer, Schlafzimmer, Kinderzimmer, Esszimmer, Flur |
|
||
| `functional` | Funktionsräume | Küche, Bad, WC, Arbeitszimmer, Hauswirtschaft |
|
||
| `outdoor` | Außen & Nebenbereiche | Keller, Dachboden, Garage, Garten, Abstellraum |
|
||
|
||
Default-Räume werden beim ersten Aufruf von `/inventory` automatisch angelegt
|
||
(`seedDefaultRoomsIfNeeded()`, Flag `'inventory.seeded'` in `settings`-Tabelle).
|
||
Räume sind löschbar (Soft-Delete). Jeder Raum hat ein `icon`-Feld (lucide icon name).
|
||
|
||
---
|
||
|
||
## Asset-Felder
|
||
|
||
| Feld | Typ | Beschreibung |
|
||
|---|---|---|
|
||
| `title` | string | Name des Gegenstands (KI-Vorschlag oder manuell) |
|
||
| `roomId` | string\|null | Zugeordneter Raum (null = ohne Raum) |
|
||
| `category` | string\|null | Aus hardcodierter Liste (s.u.) oder null |
|
||
| `status` | `draft`\|`complete` | Draft = Schnellerfassung, noch unvollständig |
|
||
| `condition` | `new`\|`good`\|`fair`\|`poor`\|null | Zustand |
|
||
| `brand` | string\|null | Marke |
|
||
| `model` | string\|null | Modell |
|
||
| `serialNumber` | string\|null | Seriennummer |
|
||
| `purchasePrice` | real\|null | Kaufpreis (€) |
|
||
| `purchaseYear` | integer\|null | Anschaffungsjahr |
|
||
| `notes` | string\|null | Freies Markdown-Notizfeld |
|
||
| `coverImageId` | string\|null | Primärbild (→ imageBlobs.id, Server-seitig) |
|
||
|
||
---
|
||
|
||
## Kategorien (hardcoded)
|
||
|
||
`null` (Keine Kategorie), Elektronik, Möbel, Kleidung, Schmuck, Kunstwerke,
|
||
Sportgeräte, Haushaltsgeräte, Haustechnik, Werkzeuge, Musikinstrumente,
|
||
Bücher & Medien, Spielzeug, Sammlerstücke, Gartengeräte, Fahrzeugzubehör, Sonstiges
|
||
|
||
Kein eigenes DB-Table — als TypeScript-Konstante in `client/src/lib/utils/inventory.ts`.
|
||
|
||
---
|
||
|
||
## Erfassungs-Modi
|
||
|
||
### Foto-Modus (Schnellerfassung)
|
||
1. Raum wählen (einmalig für die Session)
|
||
2. Loop: Foto aufnehmen → KI-Erkennung → Label + Kategorie bestätigen → Asset(draft) anlegen
|
||
3. [Fertig] → Dashboard
|
||
|
||
Route: `/inventory/capture/photo?roomId=...`
|
||
KI: POST `/api/vision/recognize` (OpenAI gpt-4o-mini oder Gemini gemini-1.5-flash)
|
||
|
||
### Barcode-Modus (Schnellerfassung)
|
||
1. Raum wählen (einmalig)
|
||
2. Loop: EAN eingeben ODER Barcode fotografieren → UPCitemdb-Lookup → bestätigen → Asset(draft)
|
||
3. [Fertig] → Dashboard
|
||
|
||
Route: `/inventory/capture/barcode?roomId=...`
|
||
Lookup: POST `/api/vision/barcode` → UPCitemdb → Open Food Facts → EAN-Fallback
|
||
|
||
### Manuell (Einzel)
|
||
Raum wählen → Titel eingeben → Asset(draft) → Detailseite
|
||
Auch via CommandBar: `/asset Titel`
|
||
|
||
---
|
||
|
||
## Bilder-Architektur
|
||
|
||
**Inventory-Fotos werden NICHT lokal gecacht** (Grund: 500 Assets × ~300 KB = 150 MB).
|
||
|
||
Statt `storeImage()` (schreibt in Dexie): `uploadInventoryImage(blob)`:
|
||
1. Client komprimiert: JPEG, max-width 1920px, quality 0.75 → ~300 KB
|
||
2. POST `/api/inventory/images` → Server speichert in `imageBlobs`-Tabelle
|
||
3. Client erhält `imageId` zurück, speichert nur die ID im Asset
|
||
4. Anzeige: `getImageUrl(id)` fetcht live via `/api/sync/blob/{id}`
|
||
|
||
`storeImage()` aus `imageStore.ts` wird weiterhin für Editor-Paste-Bilder verwendet —
|
||
**nicht** für Inventory.
|
||
|
||
---
|
||
|
||
## Personen-Zuordnung
|
||
|
||
Assets können Familienmitgliedern zugeordnet werden (`assetPersons`-Tabelle, n:m).
|
||
Auswählbar sind nur Personen mit `contexts.type='person'` + `meta.personSubType='family'`.
|
||
Anzeige auf Detailseite als Avatar-Chips (`PersonMeta.abbreviation` oder `fullName`).
|
||
|
||
---
|
||
|
||
## Vision-API-Key
|
||
|
||
Kein globaler API-Key — jeder User hinterlegt seinen eigenen Key in den Einstellungen
|
||
(Abschnitt "Bild-Erkennung"). Keys werden AES-256-GCM verschlüsselt in `user_settings`
|
||
gespeichert. Server-seitiges Verschlüsselungs-Secret: `SETTINGS_ENCRYPTION_KEY` in `.env`.
|
||
|
||
Rate-Limit: 100 Vision-Calls/Tag pro User (DB-basiert via `vision_usage`-Tabelle).
|
||
|
||
---
|
||
|
||
## Routen-Übersicht
|
||
|
||
| Route | Beschreibung |
|
||
|---|---|
|
||
| `/inventory` | Dashboard: Stats, Gesamtwert, Quick-Actions |
|
||
| `/inventory/rooms` | Räume-Grid (3-spaltig, gruppiert) |
|
||
| `/inventory/rooms/[id]` | Asset-Liste eines Raums |
|
||
| `/inventory/items` | Alle Assets (Suche, Filter, Grid/Liste) |
|
||
| `/inventory/capture/photo` | Foto-Schnellerfassungs-Loop |
|
||
| `/inventory/capture/barcode` | Barcode-Schnellerfassungs-Loop |
|
||
| `/inventory/[id]` | Asset-Detailseite |
|
||
|
||
---
|
||
|
||
## CommandBar-Integration
|
||
|
||
- Suche: Assets erscheinen in Ergebnissen (Badge `INVENTAR`, Icon 📦)
|
||
- `/inventar` → Dashboard
|
||
- `/asset [Titel]` → Asset anlegen (roomId=null) → Detailseite
|
||
|
||
---
|
||
|
||
## Server-Endpoints (Inventory-spezifisch)
|
||
|
||
```
|
||
POST /api/inventory/images Bild hochladen → { id }
|
||
POST /api/vision/recognize Foto → { label, category, candidates }
|
||
POST /api/vision/barcode EAN oder Barcode-Foto → { label, category }
|
||
GET /api/vision/settings { provider, apiKeySet, dailyUsage, dailyLimit }
|
||
PUT /api/vision/settings { provider?, apiKey? }
|
||
```
|
||
|
||
Sync läuft über den bestehenden `/api/sync/push` + `/api/sync/pull` Endpunkt.
|
||
Neue Tabellen im Sync: `rooms`, `assets`, `assetImages`, `assetPersons`.
|
||
|
||
---
|
||
|
||
## Wichtige Implementierungshinweise
|
||
|
||
### Bild-Upload vs. storeImage()
|
||
`storeImage()` und `uploadInventoryImage()` sind NICHT austauschbar:
|
||
- `storeImage()` → Dexie lokal + Sync (für Editor-Bilder)
|
||
- `uploadInventoryImage()` → direkt Server, kein Dexie (für Inventory-Fotos)
|
||
Verwechslung führt zu 75+ MB lokalem Storage.
|
||
|
||
### coverImageId und assetImages
|
||
`assets.coverImageId` = das Hauptbild (aus `imageBlobs.id`).
|
||
`assetImages` = weitere Bilder (ebenfalls `imageBlobs`-Referenzen).
|
||
Das Cover kann auf der Detailseite durch ein `assetImages`-Bild ersetzt werden
|
||
(`updateAsset({ coverImageId: assetImage.imageId })`).
|
||
Es gibt keine automatische Verknüpfung: Cover-Änderung aktualisiert NICHT `assetImages`.
|
||
|
||
### Draft vs. Complete
|
||
Assets beginnen immer als `draft`. Status `complete` muss manuell gesetzt werden.
|
||
Dashboard zeigt Gesamtzahl unabhängig vom Status.
|
||
`/inventory/items` filtert nach Status (Dropdown "Alle Status").
|
||
|
||
### Raum-Seed
|
||
`seedDefaultRoomsIfNeeded()` prüft `db.settings.get('inventory.seeded')`.
|
||
Wird nur einmal ausgeführt. Danach kann der User Räume löschen/umbenennen ohne Re-Seed.
|
||
|
||
### Barcode-Erkennung
|
||
Kein client-seitiger Barcode-Scanner (ZXing entfällt — kein live scan auf iOS).
|
||
Stattdessen: EAN manuell eingeben ODER Foto → POST `/api/vision/barcode`.
|
||
Server versucht UPCitemdb → Open Food Facts → EAN als Label.
|
||
|
||
---
|
||
|
||
## Bekannte Einschränkungen
|
||
|
||
- Kamera-Erfassung erfordert Online-Verbindung (kein offline capture)
|
||
- Barcode-Lookup max. 100/Tag (UPCitemdb free tier)
|
||
- Räume können nicht per Drag-Drop sortiert werden (`sortOrder` vorhanden, aber kein UI)
|
||
- Asset-Export (PDF/CSV) nicht implementiert (geplant als spätere Phase)
|
||
- Preisrecherche nicht implementiert (geplant: eBay Browse API)
|
||
```
|
||
|
||
**Verifikation 7:**
|
||
- `docs/feature-inventory.md` existiert (nicht leer)
|
||
- Alle Routen aus "Routen-Übersicht" im Dokument entsprechen tatsächlich implementierten SvelteKit-Routen
|
||
- Alle Felder in "Asset-Felder"-Tabelle stimmen mit `shared/src/types.ts` überein
|
||
- Alle Server-Endpoints im Dokument sind in `routes/vision.ts` + `routes/inventory.ts` implementiert
|
||
- CommandBar-Commands `/inventar` und `/asset` im Dokument = exakte Command-Namen in `CommandBar.svelte`
|
||
|
||
---
|
||
|
||
## Spätere Phase: Automatische Preisrecherche
|
||
|
||
Auf Basis von Marke + Modell (+ optional Seriennummer):
|
||
|
||
| Dienst | Art | Bewertung |
|
||
|---|---|---|
|
||
| **eBay Browse API** | Offizielle REST-API | Gebrauchtpreise, kostenlos mit App-Key → **Primärziel** |
|
||
| **Open EAN / UPCitemdb** | Barcode→Produktinfo | Ergänzend für Basisdaten |
|
||
| **Google Shopping** (Custom Search) | Preisvergleich | Kostenpflichtig ab Volumen |
|
||
| **idealo** | Scraping | Fragil + rechtlich grenzwertig → nicht empfohlen |
|
||
| **Versicherungswert-Formel** | Offline | `Kaufpreis × Altersfaktor` als schnelle Schätzung |
|
||
|
||
Umsetzung: Button "Preis recherchieren" auf Detailseite → `GET /api/inventory/[id]/price-lookup` → eBay API Query mit `brand + model` → Median-Preis + Link.
|
||
|
||
---
|
||
|
||
## Wiederverwendete bestehende Dateien
|
||
|
||
| Datei | Verwendung |
|
||
|---|---|
|
||
| `lib/db/imageStore.ts: storeImage()` | Weiterhin für Editor-Bilder — **nicht** für Inventory-Fotos |
|
||
| `lib/db/imageStore.ts: getImageUrl()` | Inventory: direkt `/api/sync/blob/{id}` fetchen (kein Dexie-Write) |
|
||
| `lib/db/helpers.ts: newId(), now()` | IDs + Timestamps |
|
||
| `lib/auth/authStore.ts: getAccessToken()` | Bearer Token für Vision-API |
|
||
| `lib/components/MarkdownEditor.svelte` | Notizfeld Detailseite |
|
||
| `lib/components/ConfirmDialog.svelte` | Lösch-Bestätigung |
|
||
| `server/src/lib/route-utils.ts: handle()` | Route-Wrapper |
|
||
|
||
---
|
||
|
||
## Risiken
|
||
|
||
| Risiko | Mitigation |
|
||
|---|---|
|
||
| HEIC von iOS-Kamera | `compressImage()`: Canvas `toBlob('image/jpeg', 0.75)` + max-width 1920px |
|
||
| Kamera-Erfassung erfordert Online | PWA mit WLAN — akzeptabel; Fehlermeldung wenn offline |
|
||
| OpenAI/Gemini Timeout | 10s Timeout, Fallback: manuelles Label |
|
||
| Rate-Limit-Zähler bei DB-Fehler | `INSERT OR IGNORE` + UPDATE — Worst case: limit nicht enforced (akzeptabel) |
|
||
| UPCitemdb Tages-Limit (100/Tag frei) | Fallback auf Open Food Facts; für höheres Volumen kostenpflichtiger Plan |
|
||
| Barcode nicht in Datenbank | Fallback: roher EAN-String als Label |
|
||
| Asset ohne Bild (Barcode-Modus) | `coverImageId = null`, Platzhalter-Icon in Card |
|
||
| Vision-API nicht konfiguriert | Fehlermeldung "Bitte API-Key in Einstellungen hinterlegen" statt 500 |
|
||
| Rate-Limit Timezone-Drift | Server-UTC für `date`-Feld — dokumentiert, kein Fix nötig |
|
||
|
||
## Spätere Phase: Asset-Export
|
||
|
||
Für Versicherungszwecke:
|
||
- PDF-Export: Inventarliste mit Fotos, Werten, Seriennummern (via `jsPDF` oder Server-seitiger PDF-Render)
|
||
- CSV-Export: Alle Assets als Tabelle (client-seitig, kein Server nötig)
|
||
- Button auf Dashboard: "Inventar exportieren"
|