From 0c902135392adabeeabac016a443629afb5f483d Mon Sep 17 00:00:00 2001 From: Stefan Zwischenbrugger Date: Tue, 31 Mar 2026 21:04:39 +0200 Subject: [PATCH] Kassazettel-Upload mit OCR-Auswertung und Produktvorschlaege einfuehren. Es gibt jetzt einen eigenen Kassazettel-Bereich mit Foto-Upload, OCR-Extraktion (Tesseract) und editierbaren Vorschlagsfeldern fuer Geschaeft, Datum und Summe. Zusaetzlich zeigt das schnelle Neueintrag-Feld Vorschlaege aus bestehenden Produkten der aktuellen Liste als Dropdown. Made-with: Cursor --- .../Controllers/ReceiptScanController.php | 84 ++++++++++ .../Controllers/ShoppingListController.php | 8 + app/Http/Requests/StoreReceiptScanRequest.php | 33 ++++ .../Requests/UpdateReceiptScanRequest.php | 22 +++ app/Models/ReceiptScan.php | 44 ++++++ app/Services/ReceiptOcr/ReceiptOcrService.php | 143 ++++++++++++++++++ ...3_31_100100_create_receipt_scans_table.php | 31 ++++ resources/views/layouts/navigation.blade.php | 6 + resources/views/receipt-scans/index.blade.php | 127 ++++++++++++++++ resources/views/shopping-list/index.blade.php | 8 +- routes/web.php | 4 + 11 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/ReceiptScanController.php create mode 100644 app/Http/Requests/StoreReceiptScanRequest.php create mode 100644 app/Http/Requests/UpdateReceiptScanRequest.php create mode 100644 app/Models/ReceiptScan.php create mode 100644 app/Services/ReceiptOcr/ReceiptOcrService.php create mode 100644 database/migrations/2026_03_31_100100_create_receipt_scans_table.php create mode 100644 resources/views/receipt-scans/index.blade.php diff --git a/app/Http/Controllers/ReceiptScanController.php b/app/Http/Controllers/ReceiptScanController.php new file mode 100644 index 0000000..bbf3d0d --- /dev/null +++ b/app/Http/Controllers/ReceiptScanController.php @@ -0,0 +1,84 @@ +currentShoppingList($request); + $this->authorize('view', $currentList); + $ocrService = app(ReceiptOcrService::class); + + $scans = ReceiptScan::query() + ->where('shopping_list_id', $currentList->id) + ->latest() + ->paginate(12); + + return view('receipt-scans.index', [ + 'currentList' => $currentList, + 'scans' => $scans, + 'uploadLimits' => [ + 'upload_max_filesize' => (string) ini_get('upload_max_filesize'), + 'post_max_size' => (string) ini_get('post_max_size'), + 'max_file_uploads' => (string) ini_get('max_file_uploads'), + 'memory_limit' => (string) ini_get('memory_limit'), + ], + 'ocrAvailable' => $ocrService->isAvailable(), + ]); + } + + public function store(StoreReceiptScanRequest $request, ReceiptOcrService $ocrService): RedirectResponse + { + $currentList = $this->currentShoppingList($request); + $this->authorize('view', $currentList); + + $path = $request->file('receipt_photo')->store('receipt-scans', 'public'); + $absolute = Storage::disk('public')->path($path); + $ocr = $ocrService->extractFromImage($absolute); + + ReceiptScan::query()->create([ + 'shopping_list_id' => $currentList->id, + 'created_by' => $request->user()->id, + 'image_path' => $path, + 'ocr_text' => $ocr['text'], + 'store_name' => $request->filled('store_name') ? $request->string('store_name')->toString() : $ocr['store_name'], + 'receipt_date' => $request->filled('receipt_date') ? $request->date('receipt_date')?->toDateString() : $ocr['receipt_date'], + 'total_decimal' => $request->filled('total_decimal') ? $request->input('total_decimal') : $ocr['total_decimal'], + 'raw_meta' => $ocr['meta'], + ]); + + $message = $ocr['ok'] + ? 'Kassazettel gespeichert und OCR ausgewertet.' + : 'Kassazettel gespeichert. OCR war nicht verfuegbar (Bild kann spaeter manuell erfasst werden).'; + + return back()->with('status', $message); + } + + public function update(UpdateReceiptScanRequest $request, ReceiptScan $receiptScan): RedirectResponse + { + $currentList = $this->currentShoppingList($request); + $this->authorize('view', $currentList); + abort_if((int) $receiptScan->shopping_list_id !== (int) $currentList->id, 403); + + $receiptScan->update([ + 'store_name' => $request->filled('store_name') ? $request->string('store_name')->toString() : null, + 'receipt_date' => $request->filled('receipt_date') ? $request->date('receipt_date')?->toDateString() : null, + 'total_decimal' => $request->filled('total_decimal') ? $request->input('total_decimal') : null, + ]); + + return back()->with('status', 'Kassazettel-Daten aktualisiert.'); + } +} diff --git a/app/Http/Controllers/ShoppingListController.php b/app/Http/Controllers/ShoppingListController.php index 299dc1a..b34cafe 100644 --- a/app/Http/Controllers/ShoppingListController.php +++ b/app/Http/Controllers/ShoppingListController.php @@ -46,6 +46,13 @@ class ShoppingListController extends Controller $openItems = $items->where('is_done', false); $doneItems = $items->where('is_done', true); $stores = Store::query()->orderBy('name')->get(); + $productSuggestions = $items + ->pluck('product_name') + ->map(fn ($name) => trim((string) $name)) + ->filter() + ->unique(fn ($name) => mb_strtolower($name)) + ->sort() + ->values(); $totalsByStore = $items ->filter(fn (ShoppingItem $item) => $item->latestPriceLog !== null) @@ -63,6 +70,7 @@ class ShoppingListController extends Controller 'members' => $members, 'openItems' => $openItems, 'doneItems' => $doneItems, + 'productSuggestions' => $productSuggestions, 'stores' => $stores, 'totalsByStore' => $totalsByStore, 'totalAll' => $totalsByStore->sum(), diff --git a/app/Http/Requests/StoreReceiptScanRequest.php b/app/Http/Requests/StoreReceiptScanRequest.php new file mode 100644 index 0000000..07082fd --- /dev/null +++ b/app/Http/Requests/StoreReceiptScanRequest.php @@ -0,0 +1,33 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'receipt_photo' => ['required', 'file', 'max:15360', 'mimes:jpeg,jpg,png,webp,heic,heif,pdf'], + 'store_name' => ['nullable', 'string', 'max:255'], + 'receipt_date' => ['nullable', 'date'], + 'total_decimal' => ['nullable', 'numeric', 'min:0', 'max:999999.99'], + ]; + } + + public function messages(): array + { + return [ + 'receipt_photo.required' => 'Bitte ein Kassazettel-Foto auswaehlen.', + 'receipt_photo.max' => 'Das Kassazettel-Foto darf maximal 15 MB gross sein.', + 'receipt_photo.mimes' => 'Erlaubte Formate: jpg, png, webp, heic, heif, pdf.', + 'receipt_photo.uploaded' => 'Das Kassazettel-Foto konnte nicht hochgeladen werden. Haeufige Ursache: PHP-Limits (upload_max_filesize / post_max_size) sind zu klein.', + ]; + } +} diff --git a/app/Http/Requests/UpdateReceiptScanRequest.php b/app/Http/Requests/UpdateReceiptScanRequest.php new file mode 100644 index 0000000..f67f7fc --- /dev/null +++ b/app/Http/Requests/UpdateReceiptScanRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'store_name' => ['nullable', 'string', 'max:255'], + 'receipt_date' => ['nullable', 'date'], + 'total_decimal' => ['nullable', 'numeric', 'min:0', 'max:999999.99'], + ]; + } +} diff --git a/app/Models/ReceiptScan.php b/app/Models/ReceiptScan.php new file mode 100644 index 0000000..325c448 --- /dev/null +++ b/app/Models/ReceiptScan.php @@ -0,0 +1,44 @@ + 'date', + 'total_decimal' => 'decimal:2', + 'raw_meta' => 'array', + ]; + } + + public function shoppingList(): BelongsTo + { + return $this->belongsTo(ShoppingList::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function imageUrl(): string + { + return asset('storage/'.ltrim($this->image_path, '/')); + } +} diff --git a/app/Services/ReceiptOcr/ReceiptOcrService.php b/app/Services/ReceiptOcr/ReceiptOcrService.php new file mode 100644 index 0000000..673060a --- /dev/null +++ b/app/Services/ReceiptOcr/ReceiptOcrService.php @@ -0,0 +1,143 @@ +&1', escapeshellcmd($bin)); + $output = []; + $exitCode = 0; + @exec($command, $output, $exitCode); + + return $exitCode === 0; + } + + /** + * @return array{ok: bool, text: string|null, store_name: string|null, receipt_date: string|null, total_decimal: string|null, meta: array} + */ + public function extractFromImage(string $absolutePath): array + { + $bin = (string) config('app.receipt_ocr_bin', env('RECEIPT_OCR_BIN', 'tesseract')); + if (! $this->isAvailable()) { + return [ + 'ok' => false, + 'text' => null, + 'store_name' => null, + 'receipt_date' => null, + 'total_decimal' => null, + 'meta' => [ + 'error' => 'ocr_unavailable', + 'hint' => 'Installiere tesseract und setze optional RECEIPT_OCR_BIN.', + ], + ]; + } + + $command = sprintf( + '%s %s stdout -l deu+eng 2>&1', + escapeshellcmd($bin), + escapeshellarg($absolutePath) + ); + + $output = []; + $exitCode = 0; + @exec($command, $output, $exitCode); + + if ($exitCode !== 0) { + return [ + 'ok' => false, + 'text' => null, + 'store_name' => null, + 'receipt_date' => null, + 'total_decimal' => null, + 'meta' => [ + 'error' => 'ocr_failed', + 'exit_code' => $exitCode, + 'command' => $command, + 'output' => implode("\n", $output), + ], + ]; + } + + $text = trim(implode("\n", $output)); + $store = $this->guessStoreName($text); + $receiptDate = $this->guessDate($text); + $total = $this->guessTotal($text); + + return [ + 'ok' => true, + 'text' => $text !== '' ? $text : null, + 'store_name' => $store, + 'receipt_date' => $receiptDate, + 'total_decimal' => $total, + 'meta' => [ + 'engine' => 'tesseract', + 'command' => $command, + 'exit_code' => $exitCode, + ], + ]; + } + + private function guessStoreName(string $text): ?string + { + foreach (preg_split('/\R+/', $text) ?: [] as $line) { + $candidate = trim($line); + if ($candidate === '' || mb_strlen($candidate) < 2 || mb_strlen($candidate) > 60) { + continue; + } + if (preg_match('/\d{2}[.\/-]\d{2}[.\/-]\d{2,4}/', $candidate)) { + continue; + } + if (preg_match('/^[\p{L}\p{N}\s&\-.]+$/u', $candidate) === 1) { + return $candidate; + } + } + + return null; + } + + private function guessDate(string $text): ?string + { + if (! preg_match('/\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/u', $text, $matches)) { + return null; + } + + $day = str_pad($matches[1], 2, '0', STR_PAD_LEFT); + $month = str_pad($matches[2], 2, '0', STR_PAD_LEFT); + $year = $matches[3]; + if (strlen($year) === 2) { + $year = ((int) $year > 69 ? '19' : '20').$year; + } + + try { + return Carbon::createFromFormat('Y-m-d', "{$year}-{$month}-{$day}")->toDateString(); + } catch (\Throwable) { + return null; + } + } + + private function guessTotal(string $text): ?string + { + $normalized = str_replace(',', '.', $text); + $normalized = preg_replace('/(?<=\d)\.(?=\d{3}\b)/', '', $normalized) ?? $normalized; + $patterns = [ + '/(?:summe|gesamt|zu\s*zahlen|betrag)\D{0,20}(\d{1,5}(?:\.\d{1,2})?)/iu', + '/(\d{1,5}(?:\.\d{1,2})?)\s*(?:eur|€)/iu', + ]; + + foreach ($patterns as $pattern) { + if (preg_match_all($pattern, $normalized, $matches) && isset($matches[1])) { + $candidate = end($matches[1]); + if ($candidate !== false && is_numeric($candidate)) { + return number_format((float) $candidate, 2, '.', ''); + } + } + } + + return null; + } +} diff --git a/database/migrations/2026_03_31_100100_create_receipt_scans_table.php b/database/migrations/2026_03_31_100100_create_receipt_scans_table.php new file mode 100644 index 0000000..cc9b06b --- /dev/null +++ b/database/migrations/2026_03_31_100100_create_receipt_scans_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('shopping_list_id')->constrained()->cascadeOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('image_path'); + $table->longText('ocr_text')->nullable(); + $table->string('store_name')->nullable(); + $table->date('receipt_date')->nullable(); + $table->decimal('total_decimal', 10, 2)->nullable(); + $table->json('raw_meta')->nullable(); + $table->timestamps(); + + $table->index(['shopping_list_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('receipt_scans'); + } +}; diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 83e76ad..6d9d3ef 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -15,6 +15,9 @@ {{ __('Einkaufsliste') }} + + {{ __('Kassazettel') }} + @@ -70,6 +73,9 @@ {{ __('Einkaufsliste') }} + + {{ __('Kassazettel') }} + diff --git a/resources/views/receipt-scans/index.blade.php b/resources/views/receipt-scans/index.blade.php new file mode 100644 index 0000000..47eec8a --- /dev/null +++ b/resources/views/receipt-scans/index.blade.php @@ -0,0 +1,127 @@ + + +

+ Kassazettel scannen - {{ $currentList->name }} +

+
+ +
+
+ @if (session('status')) +
+ {{ session('status') }} +
+ @endif + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+

Neuen Kassazettel erfassen

+

+ Foto aufnehmen oder Bild/PDF hochladen. Die OCR-Auswertung (V2) versucht automatisch Geschaeft, Datum und Summe zu erkennen. +

+
+ @if($ocrAvailable) + OCR-Engine aktiv: Tesseract erkannt. + @else + OCR-Engine nicht verfuegbar: Bitte Tesseract installieren (Server) oder RECEIPT_OCR_BIN setzen. Upload funktioniert trotzdem. + @endif +
+
+

Aktive PHP-Upload-Limits auf diesem System

+

+ upload_max_filesize: {{ $uploadLimits['upload_max_filesize'] }} + · post_max_size: {{ $uploadLimits['post_max_size'] }} + · max_file_uploads: {{ $uploadLimits['max_file_uploads'] }} + · memory_limit: {{ $uploadLimits['memory_limit'] }} +

+
+
+ @csrf +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Letzte Kassazettel

+
+ @forelse($scans as $scan) +
+
+
+ + Kassazettel + +
+
+
+ @csrf + @method('PATCH') +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ OCR-Text anzeigen +
{{ $scan->ocr_text ?: 'Kein OCR-Text vorhanden.' }}
+
+ @if(($scan->raw_meta['error'] ?? null) === 'ocr_unavailable') +

OCR war beim Upload nicht verfuegbar.

+ @endif +
+
+
+ @empty +

Noch keine Kassazettel erfasst.

+ @endforelse +
+ +
+ {{ $scans->links() }} +
+
+
+
+
diff --git a/resources/views/shopping-list/index.blade.php b/resources/views/shopping-list/index.blade.php index 8346fa4..89cf234 100644 --- a/resources/views/shopping-list/index.blade.php +++ b/resources/views/shopping-list/index.blade.php @@ -12,6 +12,11 @@ @endforeach + + @foreach($productSuggestions as $productSuggestion) + + @endforeach + @if (session('status'))
@@ -37,11 +42,12 @@ type="text" name="product_name" id="new-item-name" + list="product-options" value="{{ old('product_name') }}" placeholder="z. B. Milch" class="flex-1 rounded-md border-gray-300 shadow-sm min-h-[44px] text-base" required - autocomplete="off" + autocomplete="on" autofocus >