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
This commit is contained in:
Stefan Zwischenbrugger 2026-03-31 21:04:39 +02:00
parent 9e354d8ef5
commit 0c90213539
11 changed files with 509 additions and 1 deletions

View File

@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\ResolvesCurrentShoppingList;
use App\Http\Requests\StoreReceiptScanRequest;
use App\Http\Requests\UpdateReceiptScanRequest;
use App\Models\ReceiptScan;
use App\Services\ReceiptOcr\ReceiptOcrService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Storage;
class ReceiptScanController extends Controller
{
use ResolvesCurrentShoppingList;
public function index(): View
{
$request = request();
$currentList = $this->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.');
}
}

View File

@ -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(),

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreReceiptScanRequest extends FormRequest
{
public function authorize(): bool
{
return $this->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.',
];
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateReceiptScanRequest extends FormRequest
{
public function authorize(): bool
{
return $this->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'],
];
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReceiptScan extends Model
{
protected $fillable = [
'shopping_list_id',
'created_by',
'image_path',
'ocr_text',
'store_name',
'receipt_date',
'total_decimal',
'raw_meta',
];
protected function casts(): array
{
return [
'receipt_date' => '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, '/'));
}
}

View File

@ -0,0 +1,143 @@
<?php
namespace App\Services\ReceiptOcr;
use Illuminate\Support\Carbon;
class ReceiptOcrService
{
public function isAvailable(): bool
{
$bin = (string) config('app.receipt_ocr_bin', env('RECEIPT_OCR_BIN', 'tesseract'));
$command = sprintf('%s --version 2>&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<string, mixed>}
*/
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;
}
}

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('receipt_scans', function (Blueprint $table) {
$table->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');
}
};

View File

@ -15,6 +15,9 @@
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Einkaufsliste') }}
</x-nav-link>
<x-nav-link :href="route('receipt-scans.index')" :active="request()->routeIs('receipt-scans.*')">
{{ __('Kassazettel') }}
</x-nav-link>
</div>
</div>
@ -70,6 +73,9 @@
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Einkaufsliste') }}
</x-responsive-nav-link>
<x-responsive-nav-link :href="route('receipt-scans.index')" :active="request()->routeIs('receipt-scans.*')">
{{ __('Kassazettel') }}
</x-responsive-nav-link>
</div>
<!-- Responsive Settings Options -->

View File

@ -0,0 +1,127 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Kassazettel scannen - {{ $currentList->name }}
</h2>
</x-slot>
<div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
@if (session('status'))
<div class="bg-green-100 border border-green-300 text-green-800 px-4 py-3 rounded">
{{ session('status') }}
</div>
@endif
@if ($errors->any())
<div class="bg-red-100 border border-red-300 text-red-800 px-4 py-3 rounded">
<ul class="list-disc ms-5">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="bg-white overflow-hidden shadow-sm sm:rounded-xl p-4 sm:p-6">
<h3 class="text-lg font-semibold mb-3">Neuen Kassazettel erfassen</h3>
<p class="text-sm text-gray-600 mb-4">
Foto aufnehmen oder Bild/PDF hochladen. Die OCR-Auswertung (V2) versucht automatisch Geschaeft, Datum und Summe zu erkennen.
</p>
<div class="mb-3 rounded-md border px-3 py-2 text-xs {{ $ocrAvailable ? 'border-green-200 bg-green-50 text-green-800' : 'border-amber-200 bg-amber-50 text-amber-900' }}">
@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
</div>
<div class="mb-4 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
<p class="font-semibold mb-1">Aktive PHP-Upload-Limits auf diesem System</p>
<p>
upload_max_filesize: <span class="font-mono">{{ $uploadLimits['upload_max_filesize'] }}</span>
· post_max_size: <span class="font-mono">{{ $uploadLimits['post_max_size'] }}</span>
· max_file_uploads: <span class="font-mono">{{ $uploadLimits['max_file_uploads'] }}</span>
· memory_limit: <span class="font-mono">{{ $uploadLimits['memory_limit'] }}</span>
</p>
</div>
<form method="POST" action="{{ route('receipt-scans.store') }}" enctype="multipart/form-data" class="grid grid-cols-1 md:grid-cols-3 gap-3">
@csrf
<div class="md:col-span-3">
<label class="text-sm text-gray-700 block mb-1">Kassazettel-Foto</label>
<input type="file" name="receipt_photo" accept="image/*,.pdf" capture="environment" required class="w-full rounded-md border-gray-300 min-h-[44px]">
</div>
<div>
<label class="text-sm text-gray-700 block mb-1">Geschaeft (optional)</label>
<input type="text" name="store_name" value="{{ old('store_name') }}" class="w-full rounded-md border-gray-300 min-h-[44px]" placeholder="z. B. Spar">
</div>
<div>
<label class="text-sm text-gray-700 block mb-1">Datum (optional)</label>
<input type="date" name="receipt_date" value="{{ old('receipt_date') }}" class="w-full rounded-md border-gray-300 min-h-[44px]">
</div>
<div>
<label class="text-sm text-gray-700 block mb-1">Summe (optional)</label>
<input type="number" name="total_decimal" step="0.01" min="0" value="{{ old('total_decimal') }}" class="w-full rounded-md border-gray-300 min-h-[44px]" placeholder="z. B. 18.90">
</div>
<div class="md:col-span-3">
<button type="submit" class="rounded-md bg-blue-600 text-white px-5 min-h-[44px] text-base font-medium hover:bg-blue-700">
Foto speichern und auswerten
</button>
</div>
</form>
</div>
<div class="bg-white overflow-hidden shadow-sm sm:rounded-xl p-4 sm:p-6">
<h3 class="text-lg font-semibold mb-4">Letzte Kassazettel</h3>
<div class="space-y-4">
@forelse($scans as $scan)
<div class="rounded-xl border border-gray-200 p-3 sm:p-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<a href="{{ $scan->imageUrl() }}" target="_blank" rel="noopener noreferrer" class="block">
<img src="{{ $scan->imageUrl() }}" alt="Kassazettel" class="max-h-64 w-full rounded-lg border border-gray-200 object-contain bg-gray-50">
</a>
</div>
<div class="md:col-span-2">
<form method="POST" action="{{ route('receipt-scans.update', $scan) }}" class="grid grid-cols-1 sm:grid-cols-3 gap-2">
@csrf
@method('PATCH')
<div>
<label class="text-xs text-gray-600 block mb-1">Geschaeft</label>
<input type="text" name="store_name" value="{{ $scan->store_name }}" class="w-full rounded-md border-gray-300 min-h-[40px] text-sm">
</div>
<div>
<label class="text-xs text-gray-600 block mb-1">Datum</label>
<input type="date" name="receipt_date" value="{{ optional($scan->receipt_date)->toDateString() }}" class="w-full rounded-md border-gray-300 min-h-[40px] text-sm">
</div>
<div>
<label class="text-xs text-gray-600 block mb-1">Summe</label>
<input type="number" name="total_decimal" step="0.01" min="0" value="{{ $scan->total_decimal }}" class="w-full rounded-md border-gray-300 min-h-[40px] text-sm">
</div>
<div class="sm:col-span-3">
<button type="submit" class="rounded-md bg-gray-800 text-white px-4 min-h-[40px] text-sm font-medium hover:bg-gray-900">
Daten speichern
</button>
</div>
</form>
<details class="mt-3">
<summary class="cursor-pointer text-sm text-gray-600 hover:text-gray-900">OCR-Text anzeigen</summary>
<pre class="mt-2 whitespace-pre-wrap rounded-md bg-gray-50 p-3 text-xs text-gray-700 border border-gray-200">{{ $scan->ocr_text ?: 'Kein OCR-Text vorhanden.' }}</pre>
</details>
@if(($scan->raw_meta['error'] ?? null) === 'ocr_unavailable')
<p class="mt-2 text-xs text-amber-700">OCR war beim Upload nicht verfuegbar.</p>
@endif
</div>
</div>
</div>
@empty
<p class="text-gray-600">Noch keine Kassazettel erfasst.</p>
@endforelse
</div>
<div class="mt-4">
{{ $scans->links() }}
</div>
</div>
</div>
</div>
</x-app-layout>

View File

@ -12,6 +12,11 @@
<option value="{{ $store->name }}"></option>
@endforeach
</datalist>
<datalist id="product-options">
@foreach($productSuggestions as $productSuggestion)
<option value="{{ $productSuggestion }}"></option>
@endforeach
</datalist>
@if (session('status'))
<div class="bg-green-100 border border-green-300 text-green-800 px-4 py-3 rounded">
@ -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
>
<button

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ReceiptScanController;
use App\Http\Controllers\ShoppingListController;
use App\Http\Controllers\ShoppingListCreateController;
use App\Http\Controllers\ShoppingListMemberController;
@ -24,6 +25,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/shopping-lists/{shoppingList}/members', [ShoppingListMemberController::class, 'store'])->name('shopping-lists.members.store');
Route::delete('/shopping-lists/{shoppingList}/members/{user}', [ShoppingListMemberController::class, 'destroy'])->name('shopping-lists.members.destroy');
Route::patch('/stores/{store}/search-template', StoreSearchTemplateController::class)->name('stores.search-template.update');
Route::get('/receipt-scans', [ReceiptScanController::class, 'index'])->name('receipt-scans.index');
Route::post('/receipt-scans', [ReceiptScanController::class, 'store'])->name('receipt-scans.store');
Route::patch('/receipt-scans/{receiptScan}', [ReceiptScanController::class, 'update'])->name('receipt-scans.update');
});
Route::middleware('auth')->group(function () {