From 736cb087ae56e14f7e3d58f63b3a7449caa62c17 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 14 Sep 2026 14:51:27 +0000 Subject: [PATCH] =?UTF-8?q?Warenwirtschaft:=20Eink=C3=A4ufe=20auch=20nach?= =?UTF-8?q?=20Buchung=20korrigierbar,=20mit=20=C3=84nderungsprotokoll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher ließen sich nur offene Bestellungen bearbeiten; ein Tippfehler in einem gebuchten Wareneingang war nicht korrigierbar (Anforderung 14.09.2026). - Bearbeiten/Korrigieren für gebuchte Eingänge (Admin): Bestell- und Eingangsdaten (Menge, Datum, Charge, MHD, Qualität, Preise, Lieferant, Lagerort). Menge wirkt sofort auf den Bestand; ml-Eingänge werden in ml korrigiert und über die gespeicherte Dichte in Gramm gebucht. - Schutz: Menge nicht unter den bereits verbrauchten Anteil (Rohstoff je Eingang, Verpackung je Artikel); bei Verbrauch sind Art und Rohstoff/ Artikel gesperrt (Formular + Server). - Änderungsprotokoll (stock_entry_changes): wer/wann/Grund + geänderte Felder alt → neu; Anzeige auf der Detailseite. Grund bei gebuchten Eingängen Pflicht, bei offenen optional. Speichern ohne Änderung erzeugt keine Zeile. - Tests (6 neue), In-App-Doku (Hinweise + Leitfaden 3.2) nachgezogen. Migration: 2026_09_14_110000_create_stock_entry_changes_table. Suite: 396 grün. Co-Authored-By: Claude Opus 5 (1M context) --- .../Admin/Inventory/StockEntryController.php | 21 +- .../Inventory/UpdateStockEntryRequest.php | 47 ++++ app/Models/StockEntry.php | 11 + app/Models/StockEntryChange.php | 136 ++++++++++++ app/Repositories/StockEntryRepository.php | 152 +++++++++++-- ...10000_create_stock_entry_changes_table.php | 35 +++ resources/docs/hinweise.md | 18 +- resources/docs/testleitfaden.md | 19 +- .../inventory/stock-entries/_form.blade.php | 98 ++++++++- .../stock-entries/_scripts.blade.php | 30 +++ .../inventory/stock-entries/edit.blade.php | 4 +- .../inventory/stock-entries/index.blade.php | 4 +- .../inventory/stock-entries/show.blade.php | 43 +++- tests/Feature/StockEntryCorrectionTest.php | 202 ++++++++++++++++++ 14 files changed, 783 insertions(+), 37 deletions(-) create mode 100644 app/Models/StockEntryChange.php create mode 100644 database/migrations/2026_09_14_110000_create_stock_entry_changes_table.php create mode 100644 tests/Feature/StockEntryCorrectionTest.php diff --git a/app/Http/Controllers/Admin/Inventory/StockEntryController.php b/app/Http/Controllers/Admin/Inventory/StockEntryController.php index cc2ddf4..633e04d 100644 --- a/app/Http/Controllers/Admin/Inventory/StockEntryController.php +++ b/app/Http/Controllers/Admin/Inventory/StockEntryController.php @@ -92,6 +92,7 @@ class StockEntryController extends Controller 'quality', 'orderedByUser.account', 'receivedByUser.account', + 'changes.user', ]); return view('admin.inventory.stock-entries.show', array_merge($this->formSharedData(), [ @@ -105,16 +106,13 @@ class StockEntryController extends Controller if (! auth()->user()->isAdmin()) { return redirect()->route('home'); } - if (! $stockEntry->isPending()) { - \Session::flash('alert-error', __('Nur offene Bestellungen können bearbeitet werden.')); - - return redirect()->route('admin.inventory.stock-entries.show', $stockEntry); - } + // Anforderung 14.09.2026: auch gebuchte Eingänge sind korrigierbar (mit Protokoll). $stockEntry->load(['ingredient', 'packagingItem']); return view('admin.inventory.stock-entries.edit', array_merge($this->formSharedData(), [ 'model' => $stockEntry, + 'correctionBlockers' => $stockEntry->isReceived() ? app(DeletionGuard::class)->blockersFor($stockEntry) : [], ])); } @@ -123,17 +121,18 @@ class StockEntryController extends Controller if (! $request->user()->isAdmin()) { return redirect()->route('home'); } - if (! $stockEntry->isPending()) { - \Session::flash('alert-error', __('Nur offene Bestellungen können bearbeitet werden.')); - return redirect()->route('admin.inventory.stock-entries.show', $stockEntry); + try { + $this->stockEntryRepository->update($stockEntry, $request->validatedPayload(), $request->input('change_reason')); + } catch (ValidationException $e) { + return redirect()->route('admin.inventory.stock-entries.edit', $stockEntry)->withErrors($e->errors())->withInput(); } - $this->stockEntryRepository->update($stockEntry, $request->validatedPayload()); - \Session::flash('alert-save', '1'); - return redirect()->route('admin.inventory.stock-entries.index'); + return $stockEntry->isReceived() + ? redirect()->route('admin.inventory.stock-entries.show', $stockEntry) + : redirect()->route('admin.inventory.stock-entries.index'); } public function copy(StockEntry $stockEntry): RedirectResponse diff --git a/app/Http/Requests/Inventory/UpdateStockEntryRequest.php b/app/Http/Requests/Inventory/UpdateStockEntryRequest.php index 1eaf35e..6c9a4a8 100644 --- a/app/Http/Requests/Inventory/UpdateStockEntryRequest.php +++ b/app/Http/Requests/Inventory/UpdateStockEntryRequest.php @@ -21,6 +21,8 @@ class UpdateStockEntryRequest extends FormRequest */ public function rules(): array { + $received = $this->isReceivedEntry(); + return [ 'entry_type' => ['required', Rule::in(['ingredient', 'packaging', 'shipping'])], 'ingredient_id' => ['nullable', 'integer', 'exists:ingredients,id'], @@ -35,9 +37,28 @@ class UpdateStockEntryRequest extends FormRequest 'price_per_kg' => ['nullable', 'numeric', 'min:0'], 'price_per_kg_gross' => ['nullable', 'numeric', 'min:0'], 'price_total' => ['nullable', 'numeric', 'min:0'], + // Anforderung 14.09.2026: Korrektur gebuchter Eingänge inkl. Eingangsdaten, + // Grund ist bei gebuchten Eingängen Pflicht (Änderungsprotokoll). + 'received_at' => [$received ? 'required' : 'nullable', 'date'], + 'received_quantity' => [$received ? 'required' : 'nullable', 'numeric', 'min:0.001', 'max:'.InventoryService::QUANTITY_MAX], + 'batch_number' => ['nullable', 'string', 'max:100'], + 'best_before' => ['nullable', 'date'], + 'change_reason' => [$received ? 'required' : 'nullable', 'string', 'max:500'], ]; } + protected function stockEntry(): ?StockEntry + { + $entry = $this->route('stock_entry') ?? $this->route('stockEntry'); + + return $entry instanceof StockEntry ? $entry : null; + } + + protected function isReceivedEntry(): bool + { + return (bool) $this->stockEntry()?->isReceived(); + } + /** * @return array */ @@ -47,6 +68,9 @@ class UpdateStockEntryRequest extends FormRequest 'quality_id.required' => __('Bitte eine Rohstoffqualität wählen.'), 'price_per_kg.required' => __('Bitte den Netto- oder Brutto-Preis pro kg angeben.'), 'price_total.required' => __('Bitte den Gesamtpreis netto angeben.'), + 'change_reason.required' => __('Bitte den Grund der Korrektur angeben — er wird im Änderungsprotokoll gespeichert.'), + 'received_at.required' => __('Bitte das Eingangsdatum angeben.'), + 'received_quantity.required' => __('Bitte die eingegangene Menge angeben.'), ]; } @@ -54,6 +78,8 @@ class UpdateStockEntryRequest extends FormRequest { $this->merge([ 'ordered_quantity' => normalizeWawiQuantity($this->input('ordered_quantity')), + 'received_quantity' => $this->filled('received_quantity') ? normalizeWawiQuantity($this->input('received_quantity')) : null, + 'change_reason' => $this->filled('change_reason') ? trim((string) $this->input('change_reason')) : null, 'price_per_kg' => $this->filled('price_per_kg') ? reFormatWawiNumber($this->input('price_per_kg')) : null, 'price_per_kg_gross' => $this->filled('price_per_kg_gross') ? reFormatWawiNumber($this->input('price_per_kg_gross')) : null, 'price_total' => $this->filled('price_total') ? reFormatWawiNumber($this->input('price_total')) : null, @@ -91,6 +117,20 @@ class UpdateStockEntryRequest extends FormRequest $validator->errors()->add('ordered_quantity', __('Die Menge ergibt umgerechnet weniger als 0,001 g — bitte mindestens 0,001 g erfassen.')); } } + if ($this->isReceivedEntry()) { + if (empty($this->input('batch_number'))) { + $validator->errors()->add('batch_number', __('Bitte eine Chargennummer angeben.')); + } + if (empty($this->input('best_before'))) { + $validator->errors()->add('best_before', __('Bitte die Mindesthaltbarkeit angeben.')); + } + if ($this->input('ordered_unit') === 'ml' && is_numeric($this->input('received_quantity'))) { + $density = Ingredient::query()->whereKey($this->input('ingredient_id'))->value('density') ?? $this->stockEntry()?->density_used; + if ($density !== null && (float) $density > 0 && roundWawiQuantity((float) $this->input('received_quantity') * (float) $density) <= 0) { + $validator->errors()->add('received_quantity', __('Die Menge ergibt umgerechnet weniger als 0,001 g — bitte mindestens 0,001 g erfassen.')); + } + } + } $net = $this->input('price_per_kg'); $gross = $this->input('price_per_kg_gross'); $hasNet = is_numeric($net) && (float) $net > 0; @@ -115,11 +155,18 @@ class UpdateStockEntryRequest extends FormRequest public function validatedPayload(): array { $data = $this->validated(); + unset($data['change_reason']); if (($data['entry_type'] ?? '') === 'ingredient') { $data['packaging_item_id'] = null; } else { $data['ingredient_id'] = null; $data['quality_id'] = null; + $data['batch_number'] = null; + $data['best_before'] = null; + } + if (! $this->isReceivedEntry()) { + // Offene Bestellung: Eingangsfelder gehören nicht ins Formular. + unset($data['received_at'], $data['received_quantity'], $data['batch_number'], $data['best_before']); } return $data; diff --git a/app/Models/StockEntry.php b/app/Models/StockEntry.php index 0550b4a..db7ef66 100644 --- a/app/Models/StockEntry.php +++ b/app/Models/StockEntry.php @@ -7,6 +7,7 @@ use Database\Factories\StockEntryFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; class StockEntry extends Model @@ -61,6 +62,16 @@ class StockEntry extends Model ]; } + /** + * Änderungsprotokoll (nachträgliche Korrekturen), neueste zuerst. + * + * @return HasMany + */ + public function changes(): HasMany + { + return $this->hasMany(StockEntryChange::class)->orderByDesc('id'); + } + /** * @return BelongsTo */ diff --git a/app/Models/StockEntryChange.php b/app/Models/StockEntryChange.php new file mode 100644 index 0000000..bd112eb --- /dev/null +++ b/app/Models/StockEntryChange.php @@ -0,0 +1,136 @@ + ['old' => …, 'new' => …]] mit Rohwerten (IDs, Y-m-d, Dezimal); + * die lesbare Darstellung liefert describe(). + */ +class StockEntryChange extends Model +{ + protected $fillable = [ + 'stock_entry_id', + 'user_id', + 'status_before', + 'reason', + 'field_changes', + ]; + + /** + * Felder, die im Protokoll verfolgt werden (Reihenfolge = Anzeige). + * + * @var array + */ + public const TRACKED = [ + 'entry_type', 'ingredient_id', 'packaging_item_id', 'supplier_id', 'location_id', + 'ordered_at', 'ordered_quantity', 'purchase_unit', 'density_used', + 'price_per_kg', 'price_per_kg_gross', 'tax_rate_id', 'price_total', + 'received_at', 'received_quantity', 'batch_number', 'best_before', 'quality_id', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return ['field_changes' => 'array']; + } + + /** + * @return BelongsTo + */ + public function stockEntry(): BelongsTo + { + return $this->belongsTo(StockEntry::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * Geänderte Felder als lesbare Zeilen [label, alt, neu]. + * + * @return array + */ + public function describe(): array + { + $rows = []; + foreach (self::TRACKED as $field) { + if (! isset($this->field_changes[$field])) { + continue; + } + $rows[] = [ + 'label' => self::label($field), + 'old' => $this->format($field, $this->field_changes[$field]['old'] ?? null), + 'new' => $this->format($field, $this->field_changes[$field]['new'] ?? null), + ]; + } + + return $rows; + } + + public static function label(string $field): string + { + return match ($field) { + 'entry_type' => __('Art'), + 'ingredient_id' => __('Inhaltsstoff'), + 'packaging_item_id' => __('Verpackungsartikel'), + 'supplier_id' => __('Lieferant'), + 'location_id' => __('Lagerort'), + 'ordered_at' => __('Bestelldatum'), + 'ordered_quantity' => __('Bestellte Menge'), + 'purchase_unit' => __('Einheit'), + 'density_used' => __('Dichte (g/ml)'), + 'price_per_kg' => __('Netto-Preis pro kg'), + 'price_per_kg_gross' => __('Brutto-Preis pro kg'), + 'tax_rate_id' => __('Umsatzsteuer'), + 'price_total' => __('Gesamtpreis netto'), + 'received_at' => __('Eingangsdatum'), + 'received_quantity' => __('Eingegangene Menge'), + 'batch_number' => __('Charge'), + 'best_before' => __('Mindesthaltbarkeit'), + 'quality_id' => __('Rohstoffqualität'), + default => $field, + }; + } + + protected function format(string $field, mixed $value): string + { + if ($value === null || $value === '') { + return '—'; + } + + return match ($field) { + 'entry_type' => match ($value) { + 'ingredient' => __('Rohstoff'), + 'packaging' => __('Verpackung'), + 'shipping' => __('Versand'), + default => (string) $value, + }, + 'ingredient_id' => Ingredient::withTrashed()->whereKey($value)->value('name') ?? '#'.$value, + 'packaging_item_id' => PackagingItem::withTrashed()->whereKey($value)->value('name') ?? '#'.$value, + 'supplier_id' => Supplier::withTrashed()->whereKey($value)->value('name') ?? '#'.$value, + 'location_id' => Location::withTrashed()->whereKey($value)->value('name') ?? '#'.$value, + 'quality_id' => MaterialQuality::withTrashed()->whereKey($value)->value('name') ?? '#'.$value, + 'tax_rate_id' => TaxRate::withTrashed()->whereKey($value)->value('name') ?? '#'.$value, + 'ordered_at', 'received_at', 'best_before' => Carbon::parse($value)->format('d.m.Y'), + 'ordered_quantity', 'received_quantity' => formatWawiQuantity((float) $value).' '.($this->stockEntry?->unit === 'piece' ? __('Stk.') : 'g'), + 'purchase_unit' => $value === 'ml' ? 'ml' : 'g', + 'density_used' => Util::formatNumber($value), + 'price_per_kg', 'price_per_kg_gross', 'price_total' => Util::formatNumber($value).' €', + default => (string) $value, + }; + } +} diff --git a/app/Repositories/StockEntryRepository.php b/app/Repositories/StockEntryRepository.php index 784d231..e67ffee 100644 --- a/app/Repositories/StockEntryRepository.php +++ b/app/Repositories/StockEntryRepository.php @@ -4,7 +4,11 @@ namespace App\Repositories; use App\Models\Ingredient; use App\Models\StockEntry; +use App\Models\StockEntryChange; use App\Models\TaxRate; +use App\Services\DeletionGuard; +use App\Services\InventoryService; +use App\Services\ProductionService; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -24,27 +28,149 @@ class StockEntryRepository } /** + * Bearbeiten/Korrigieren. Anforderung 14.09.2026: auch gebuchte Eingänge dürfen + * korrigiert werden (Tippfehler, Nachmeldung); jede Änderung landet mit Grund im + * Änderungsprotokoll (stock_entry_changes). Der Bestand ergibt sich weiterhin + * aus received_quantity − Verbrauch, eine Mengenkorrektur wirkt also sofort. + * * @param array $data */ - public function update(StockEntry $stockEntry, array $data): StockEntry + public function update(StockEntry $stockEntry, array $data, ?string $reason = null): StockEntry { - if (array_key_exists('entry_type', $data)) { - $data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece'; - // N-06: Dichte-Snapshot nur bei unverändertem Rohstoff als Rückfall mitgeben — - // eine fremde alte Dichte darf bei Rohstoffwechsel nicht übernommen werden. - if (($data['ordered_unit'] ?? null) === 'ml' - && (int) ($data['ingredient_id'] ?? 0) === (int) $stockEntry->ingredient_id - && (float) $stockEntry->density_used > 0) { - $data['density_used'] = (float) $stockEntry->density_used; + return DB::transaction(function () use ($stockEntry, $data, $reason) { + $locked = StockEntry::query()->lockForUpdate()->findOrFail($stockEntry->id); + $before = $this->snapshot($locked); + + if (array_key_exists('entry_type', $data)) { + $data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece'; + // N-06: Dichte-Snapshot nur bei unverändertem Rohstoff als Rückfall mitgeben — + // eine fremde alte Dichte darf bei Rohstoffwechsel nicht übernommen werden. + if (($data['ordered_unit'] ?? null) === 'ml' + && (int) ($data['ingredient_id'] ?? 0) === (int) $locked->ingredient_id + && (float) $locked->density_used > 0) { + $data['density_used'] = (float) $locked->density_used; + } + $data = $this->applyPurchaseUnit($data); + + // Gebuchter Eingang: die eingegangene Menge wird in derselben Einheit wie die + // Bestellung erfasst und bei ml über dieselbe Dichte in Gramm umgerechnet. + if (isset($data['received_quantity']) && ($data['purchase_unit'] ?? null) === 'ml' && (float) ($data['density_used'] ?? 0) > 0) { + $data['received_quantity'] = roundWawiQuantity((float) $data['received_quantity'] * (float) $data['density_used']); + } + } + + $data = $this->resolvePrices($data); + + if ($locked->isReceived()) { + $this->assertReceivedCorrectionAllowed($locked, $data); + } + + $locked->update($data); + $locked->refresh(); + + $diff = $this->diff($before, $this->snapshot($locked)); + if ($diff !== [] || ($reason !== null && $reason !== '')) { + $locked->changes()->create([ + 'user_id' => auth()->id(), + 'status_before' => $before['status'], + 'reason' => $reason !== '' ? $reason : null, + 'field_changes' => $diff, + ]); + } + + return $locked; + }); + } + + /** + * Korrektur eines gebuchten Eingangs darf den Bestand nicht negativ machen und + * keinen Rohstoff/Artikel wechseln, aus dem bereits verbraucht wurde. + * + * @param array $data + */ + protected function assertReceivedCorrectionAllowed(StockEntry $locked, array $data): void + { + $blockers = app(DeletionGuard::class)->blockersFor($locked); + if ($blockers !== []) { + $typeChanged = array_key_exists('entry_type', $data) && $data['entry_type'] !== $locked->entry_type; + $itemChanged = (array_key_exists('ingredient_id', $data) && (int) $data['ingredient_id'] !== (int) $locked->ingredient_id) + || (array_key_exists('packaging_item_id', $data) && (int) $data['packaging_item_id'] !== (int) $locked->packaging_item_id); + if ($typeChanged || $itemChanged) { + throw ValidationException::withMessages([ + 'ingredient_id' => __('Aus diesem Eingang wurde bereits verbraucht (:blockers) — Art und Rohstoff/Artikel können nicht mehr geändert werden.', ['blockers' => implode(', ', $blockers)]), + ]); } - $data = $this->applyPurchaseUnit($data); } - $data = $this->resolvePrices($data); + if (! array_key_exists('received_quantity', $data) || $data['received_quantity'] === null) { + return; + } - $stockEntry->update($data); + $new = (float) $data['received_quantity']; + if ($locked->entry_type === 'ingredient') { + $used = app(ProductionService::class)->consumedByStockEntry([$locked->id])[$locked->id] ?? 0.0; + if (roundWawiQuantity($new - $used) < 0) { + throw ValidationException::withMessages([ + 'received_quantity' => __('Aus diesem Eingang wurden bereits :used g verbraucht — die eingegangene Menge kann nicht darunter liegen.', ['used' => formatWawiQuantity($used)]), + ]); + } - return $stockEntry->fresh(); + return; + } + + // Verpackung: Verbrauch hängt am Artikel, nicht am Eingang — Gesamtbestand darf nicht negativ werden. + $itemId = (int) $locked->packaging_item_id; + if ($itemId > 0) { + $remaining = app(InventoryService::class)->remainingByPackagingItem([$itemId])[$itemId] ?? 0.0; + $delta = $new - (float) $locked->received_quantity; + if (roundWawiQuantity($remaining + $delta) < 0) { + throw ValidationException::withMessages([ + 'received_quantity' => __('Der Bestand des Artikels würde negativ (aktuell :remaining Stk. verfügbar) — die Menge kann höchstens um :max Stk. gesenkt werden.', ['remaining' => formatWawiQuantity($remaining), 'max' => formatWawiQuantity(max(0.0, $remaining))]), + ]); + } + } + } + + /** + * Protokollierte Felder als vergleichbare Rohwerte. + * + * @return array + */ + protected function snapshot(StockEntry $entry): array + { + $out = ['status' => $entry->status]; + foreach (StockEntryChange::TRACKED as $field) { + $value = $entry->getAttribute($field); + if ($value instanceof \DateTimeInterface) { + $value = $value->format('Y-m-d'); + } elseif (is_numeric($value) && in_array($field, ['ordered_quantity', 'received_quantity', 'density_used', 'price_per_kg', 'price_per_kg_gross', 'price_total'], true)) { + $value = (float) $value; + } elseif ($value !== null && str_ends_with($field, '_id')) { + $value = (int) $value; + } elseif ($value !== null) { + $value = (string) $value; + } + $out[$field] = $value; + } + + return $out; + } + + /** + * @param array $before + * @param array $after + * @return array + */ + protected function diff(array $before, array $after): array + { + $diff = []; + foreach (StockEntryChange::TRACKED as $field) { + if (($before[$field] ?? null) !== ($after[$field] ?? null)) { + $diff[$field] = ['old' => $before[$field] ?? null, 'new' => $after[$field] ?? null]; + } + } + + return $diff; } /** diff --git a/database/migrations/2026_09_14_110000_create_stock_entry_changes_table.php b/database/migrations/2026_09_14_110000_create_stock_entry_changes_table.php new file mode 100644 index 0000000..7002dd4 --- /dev/null +++ b/database/migrations/2026_09_14_110000_create_stock_entry_changes_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('stock_entry_id')->constrained('stock_entries')->cascadeOnDelete(); + $table->unsignedInteger('user_id')->nullable(); + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + $table->string('status_before', 20); + $table->string('reason', 500)->nullable(); + $table->json('field_changes'); + $table->timestamps(); + + $table->index(['stock_entry_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('stock_entry_changes'); + } +}; diff --git a/resources/docs/hinweise.md b/resources/docs/hinweise.md index 83be3a2..7dec6ca 100644 --- a/resources/docs/hinweise.md +++ b/resources/docs/hinweise.md @@ -310,10 +310,26 @@ dadurch für die Bedienung ändert: - **iPad:** Das Notizfeld der Produktion wächst beim Tippen automatisch mit und lässt sich vergrößern; die Zwischenspeichern-Buttons sind über die Seite verteilt. +### Neu 14.09.2026 (Einkauf: Einheit, Dichte, Korrekturen) + +- **Einheit als Pflichtfeld:** Beim Rohstoff-Einkauf steht die Einheit (g/ml) als + eigenes Feld **über** der Menge und muss bewusst gewählt werden („Bitte wählen“). + Bei ml zeigt das Mengenfeld live „ml × Dichte = g“. +- **Dichte selbsterklärend:** Hilfetexte am Rohstoff trennen Schwundfaktor (Rezeptur) + und Dichte (nur ml-Einkauf); Live-Vorschau „1 ml = x g“. +- **Korrigieren mit Änderungsprotokoll:** Einkäufe lassen sich jetzt **auch nach der + Buchung** korrigieren (Liste → Stift, Detailseite → **„Korrigieren“**): Menge, + Eingangsdatum, Charge, MHD, Qualität, Preise, Lieferant, Lagerort. Der **Grund ist + Pflicht**; jede Änderung landet mit Datum, Bearbeiter und „alt → neu“ im + **Änderungsprotokoll** unten auf der Detailseite. Schutz: Die Menge darf nicht unter + den bereits verbrauchten Anteil fallen, und bei Verbrauch sind Art und + Rohstoff/Artikel gesperrt. Auch Änderungen an offenen Bestellungen werden + protokolliert (Grund dort optional). + ### In Vorbereitung / geplant - **Versand im System** (DHL-Etiketten per API, AP-32 — bis dahin gilt die - Brückenlösung „DHL-Label aus dem Shop", s. o.), **Audit-Trail**, + Brückenlösung „DHL-Label aus dem Shop", s. o.), **Audit-Trail** (für Einkäufe seit 14.09. als Änderungsprotokoll vorhanden), **blockbasierte Rechte**, **2FA für Admins**, **WaWi-Einstellungsseite** (Schwellwerte/Defaults zentral). - **Produktentwicklung:** Menüpunkt ist als Platzhalter angelegt – das genaue diff --git a/resources/docs/testleitfaden.md b/resources/docs/testleitfaden.md index 965242f..6b762c7 100644 --- a/resources/docs/testleitfaden.md +++ b/resources/docs/testleitfaden.md @@ -1,6 +1,6 @@ -# Testleitfaden Warenwirtschaft — Version 3.1 +# Testleitfaden Warenwirtschaft — Version 3.2 -> **Stand:** 09.09.2026 +> **Stand:** 14.09.2026 > Dieser Leitfaden führt einmal durch die Funktionen der Warenwirtschaft — in der Reihenfolge eines typischen Arbeitsablaufs, sodass jede Stelle direkt im Browser nachgeschaut und getestet werden kann. > Die fachlichen Erklärungen dazu stehen wie gewohnt in der App unter den **[Hinweisen zur Warenwirtschaft](/admin/inventory/notices)**. > @@ -423,10 +423,23 @@ --- +## 27. Einkauf korrigieren mit Änderungsprotokoll (14.09.2026) + +**Neu:** Einkäufe/Wareneingänge sind auch **nach der Buchung** korrigierbar (Tippfehler, Lieferschein nachgetragen). Jede Korrektur hält **wer/wann/warum** und die geänderten Werte **alt → neu** fest. + +**So testen:** +1. Gebuchten Eingang öffnen → **„Korrigieren“** (Liste: Stift-Symbol). Menge 900 → 850 g ändern, Grund eintragen, speichern → Rohstoffbestand sinkt um 50 g; unten auf der Detailseite erscheint das **Änderungsprotokoll** mit Grund und „900,00 g → 850,00 g“. +2. Ohne Grund speichern → Fehlermeldung, nichts geändert. +3. Aus einem Eingang 400 g verbrauchen (Produktion/Ausschuss), dann Menge auf 300 g korrigieren → abgelehnt mit Hinweis auf den Verbrauch; 400 g geht durch. Rohstoff wechseln → abgelehnt (gesperrt). +4. ml-Eingang: Formular zeigt die Mengen in ml, Korrektur wird über die gespeicherte Dichte in Gramm gebucht (Live-Vorschau am Feld). +5. Offene Bestellung bearbeiten → ebenfalls Protokollzeile, Grund dort optional. + +--- + ## Hinweis am Rande Die Listen **Einkauf & Wareneingang**, **Produktionen** und die **Produktbestand-Historie** zeigen aus Performance-Gründen die neuesten 500 Einträge; darüber erscheint ein Link **„Alle anzeigen"**. Offene Bestellungen sind immer vollständig gelistet. ## Was bewusst noch nicht dabei ist (nachgelagert) -Versand im System (DHL-Etiketten per API), Audit-Trail, 2FA, blockbasierte Rechte und die WaWi-Einstellungsseite folgen in der nächsten Runde. +Versand im System (DHL-Etiketten per API), ein systemweiter Audit-Trail (für Einkäufe siehe Abschnitt 27), 2FA, blockbasierte Rechte und die WaWi-Einstellungsseite folgen in der nächsten Runde. diff --git a/resources/views/admin/inventory/stock-entries/_form.blade.php b/resources/views/admin/inventory/stock-entries/_form.blade.php index 1fcfeff..a084276 100644 --- a/resources/views/admin/inventory/stock-entries/_form.blade.php +++ b/resources/views/admin/inventory/stock-entries/_form.blade.php @@ -1,11 +1,29 @@ @php /** @var \App\Models\StockEntry $model */ $isEdit = $model->exists; + // Anforderung 14.09.2026: gebuchte Eingänge korrigierbar. Wurde daraus schon + // verbraucht, sind Art und Rohstoff/Artikel gesperrt (Bestand bliebe sonst inkonsistent). + $isReceivedEdit = $isEdit && $model->isReceived(); + $correctionBlockers = $correctionBlockers ?? []; + $lockItem = $isReceivedEdit && $correctionBlockers !== []; @endphp +@if($isReceivedEdit) +
+ {{ __('Korrektur eines gebuchten Eingangs.') }} + {{ __('Änderungen wirken sofort auf den Bestand und werden mit Grund im Änderungsprotokoll festgehalten.') }} + @if($lockItem) +
{{ __('Aus diesem Eingang wurde bereits verbraucht (:blockers) — Art und Rohstoff/Artikel sind deshalb gesperrt.', ['blockers' => implode(', ', $correctionBlockers)]) }} + @endif +
+@endif +
- + @endif + + @endif
+ @endif
+ @error('received_at') +
{{ $message }}
+ @enderror +
+ +
+ + + {{ $model->unit === 'piece' ? __('Angabe in Stück.') : __('Angabe in der oben gewählten Einheit.') }} + + @error('received_quantity') +
{{ $message }}
+ @enderror +
+ + +@endif + +@if($isEdit) +
+
+ + + {{ __('Wird mit Datum, Bearbeiter und den geänderten Werten im Änderungsprotokoll des Eintrags gespeichert.') }} + @error('change_reason') +
{{ $message }}
+ @enderror +
+@endif diff --git a/resources/views/admin/inventory/stock-entries/_scripts.blade.php b/resources/views/admin/inventory/stock-entries/_scripts.blade.php index d4c1946..c27b9b5 100644 --- a/resources/views/admin/inventory/stock-entries/_scripts.blade.php +++ b/resources/views/admin/inventory/stock-entries/_scripts.blade.php @@ -8,6 +8,34 @@ $('#stock-entry-packaging-block').toggle(!isIng); $('#price-per-kg-block').toggle(isIng); $('#price-total-block').toggle(!isIng); + $('#received-ingredient-block').toggle(isIng); + } + + // Korrektur eines gebuchten Eingangs: die eingegangene Menge folgt derselben + // Einheit wie die Bestellung (Hinweis + ml→g-Vorschau am Feld). + function applyReceivedUnit(isIng, unit, density, hasDensity) { + var $rq = $('#received_quantity'); + if (!$rq.length) { + return; + } + var $hint = $('#received-quantity-hint'); + var $preview = $('#received-quantity-preview'); + $preview.hide().text(''); + if (!isIng) { + $hint.text('{{ __('Angabe in Stück.') }}'); + return; + } + if (unit === 'ml' && hasDensity) { + $hint.text('{{ __('Angabe in ml — der Bestand wird über die Dichte in Gramm gebucht.') }}'); + var ml = parseNumber($rq.val()); + if (ml != null && ml > 0) { + $preview.text(formatQuantity(ml) + ' ml × ' + formatDensity(density) + ' g/ml = ' + formatQuantity(ml * density) + ' g').show(); + } + } else if (unit === 'gram') { + $hint.text('{{ __('Angabe in Gramm.') }}'); + } else { + $hint.text('{{ __('Bitte zuerst oben die Einheit wählen.') }}'); + } } function initIngredientSelect2() { @@ -95,6 +123,7 @@ var $hint = $('#ordered-quantity-hint'); var $preview = $('#ordered-quantity-preview'); $preview.hide().text(''); + applyReceivedUnit(isIng, $unit.val(), density, hasDensity); if (!isIng) { $hint.text('{{ __('Bei Verpackung in Stück.') }}'); return; @@ -186,6 +215,7 @@ $('#ingredient_id').on('change', applyOrderedUnit); $('#ordered_unit').on('change', applyOrderedUnit); $('#ordered_quantity').on('input', applyOrderedUnit); + $('#received_quantity').on('input', applyOrderedUnit); $('#price_per_kg').on('input', recalcFromNet); $('#price_per_kg_gross').on('input', recalcFromGross); diff --git a/resources/views/admin/inventory/stock-entries/edit.blade.php b/resources/views/admin/inventory/stock-entries/edit.blade.php index def6a99..1917a0e 100644 --- a/resources/views/admin/inventory/stock-entries/edit.blade.php +++ b/resources/views/admin/inventory/stock-entries/edit.blade.php @@ -1,7 +1,7 @@ @extends('layouts.layout-2') @section('content') -

{{ __('Bestellung bearbeiten') }}

+

{{ $model->isReceived() ? __('Wareneingang korrigieren') : __('Bestellung bearbeiten') }}

@@ -9,7 +9,7 @@ @method('PUT') @include('admin.inventory.stock-entries._form') - {{ __('Zurück') }} + {{ __('Zurück') }}
diff --git a/resources/views/admin/inventory/stock-entries/index.blade.php b/resources/views/admin/inventory/stock-entries/index.blade.php index 05624fc..e2dd6f2 100644 --- a/resources/views/admin/inventory/stock-entries/index.blade.php +++ b/resources/views/admin/inventory/stock-entries/index.blade.php @@ -40,8 +40,8 @@ - @if(Auth::user()->isAdmin() && $row->status === 'pending') - + @if(Auth::user()->isAdmin()) + @endif diff --git a/resources/views/admin/inventory/stock-entries/show.blade.php b/resources/views/admin/inventory/stock-entries/show.blade.php index da4be6a..6dbf397 100644 --- a/resources/views/admin/inventory/stock-entries/show.blade.php +++ b/resources/views/admin/inventory/stock-entries/show.blade.php @@ -27,8 +27,8 @@ @endif - @if(Auth::user()->isAdmin() && $model->isPending()) - {{ __('Bearbeiten') }} + @if(Auth::user()->isAdmin()) + {{ $model->isPending() ? __('Bearbeiten') : __('Korrigieren') }} @endif
@@ -262,6 +262,45 @@ @endif + + {{-- Anforderung 14.09.2026: Änderungsprotokoll nachträglicher Korrekturen --}} + @if($model->changes->isNotEmpty()) +
+
{{ __('Änderungsprotokoll') }} ({{ $model->changes->count() }})
+
+ + + + + + + + + + @foreach($model->changes as $change) + + + + + + @endforeach + +
{{ __('Wann / Wer') }}{{ __('Grund') }}{{ __('Geändert (alt → neu)') }}
+ {{ $change->created_at->format('d.m.Y H:i') }}
+ {{ $change->user?->getFullName(false) ?: $change->user?->email ?? '—' }} + @if($change->status_before === 'received') +
{{ __('nach Buchung') }} + @endif +
{{ $change->reason ?: '—' }} + @forelse($change->describe() as $row) +
{{ $row['label'] }}: {{ $row['old'] }} → {{ $row['new'] }}
+ @empty + {{ __('keine Feldänderung') }} + @endforelse +
+
+
+ @endif @endsection diff --git a/tests/Feature/StockEntryCorrectionTest.php b/tests/Feature/StockEntryCorrectionTest.php new file mode 100644 index 0000000..bac5fb3 --- /dev/null +++ b/tests/Feature/StockEntryCorrectionTest.php @@ -0,0 +1,202 @@ +create(['email' => uniqid('corr_', true).'@test.example', 'password' => bcrypt('password')]); + $user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save(); + + return $user->fresh(); +} + +/** + * @return array{0: User, 1: Ingredient, 2: Location, 3: Supplier, 4: MaterialQuality, 5: StockEntry} + */ +function corrReceived(?float $density = null, float $received = 1000): array +{ + $admin = corrAdmin(); + $ing = Ingredient::query()->create(['name' => 'Corr-'.uniqid('', true), 'active' => true, 'pos' => 0, 'density' => $density]); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + $quality = MaterialQuality::factory()->create(); + $entry = StockEntry::query()->create([ + 'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id, + 'unit' => 'gram', 'purchase_unit' => $density ? 'ml' : 'gram', 'density_used' => $density, + 'ordered_by' => $admin->id, 'ordered_at' => '2026-09-01', 'ordered_quantity' => $received, 'price_per_kg' => 10, 'price_per_kg_gross' => 10, + 'status' => 'received', 'received_by' => $admin->id, 'received_at' => '2026-09-05', 'received_quantity' => $received, + 'batch_number' => 'CH-1', 'best_before' => '2027-01-01', 'quality_id' => $quality->id, + ]); + + return [$admin, $ing, $location, $supplier, $quality, $entry]; +} + +/** + * @return array + */ +function corrPayload(StockEntry $entry, array $overrides = []): array +{ + return array_merge([ + 'entry_type' => 'ingredient', 'ingredient_id' => (string) $entry->ingredient_id, 'supplier_id' => (string) $entry->supplier_id, + 'location_id' => (string) $entry->location_id, 'quality_id' => (string) $entry->quality_id, 'ordered_at' => '01.09.2026', + 'ordered_quantity' => '1000', 'ordered_unit' => 'gram', 'price_per_kg' => '10,00', + 'received_at' => '05.09.2026', 'received_quantity' => '1000', 'batch_number' => 'CH-1', 'best_before' => '01.01.2027', + 'change_reason' => 'Tippfehler bei der Menge', + ], $overrides); +} + +test('gebuchter eingang ist korrigierbar und wirkt sofort auf den bestand', function () { + [$admin, $ing, , , , $entry] = corrReceived(); + $this->actingAs($admin, 'user'); + + $this->get(route('admin.inventory.stock-entries.edit', $entry))->assertOk()->assertSee('Wareneingang korrigieren'); + + $this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['received_quantity' => '900', 'batch_number' => 'CH-2'])) + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.inventory.stock-entries.show', $entry)); + + $entry->refresh(); + expect((float) $entry->received_quantity)->toBe(900.0) + ->and($entry->batch_number)->toBe('CH-2') + ->and($entry->status)->toBe('received') + ->and(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(900.0); + + $change = StockEntryChange::query()->where('stock_entry_id', $entry->id)->firstOrFail(); + expect($change->user_id)->toBe($admin->id) + ->and($change->status_before)->toBe('received') + ->and($change->reason)->toBe('Tippfehler bei der Menge') + ->and($change->field_changes['received_quantity'])->toEqual(['old' => 1000, 'new' => 900]) + ->and($change->field_changes['batch_number'])->toBe(['old' => 'CH-1', 'new' => 'CH-2']) + ->and($change->field_changes)->not->toHaveKey('ordered_quantity'); + + $rows = collect($change->describe()); + expect($rows->pluck('label')->all())->toBe(['Eingegangene Menge', 'Charge']) + ->and($rows[0]['old'])->toBe('1.000,00 g')->and($rows[0]['new'])->toBe('900,00 g'); + + $this->get(route('admin.inventory.stock-entries.show', $entry)) + ->assertOk()->assertSee('Änderungsprotokoll')->assertSee('Tippfehler bei der Menge')->assertSee('CH-1')->assertSee('CH-2'); +}); + +test('korrektur eines gebuchten eingangs braucht einen grund', function () { + [$admin, , , , , $entry] = corrReceived(); + $this->actingAs($admin, 'user'); + + $this->from(route('admin.inventory.stock-entries.edit', $entry)) + ->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['received_quantity' => '900', 'change_reason' => ''])) + ->assertSessionHasErrors('change_reason'); + + expect((float) $entry->fresh()->received_quantity)->toBe(1000.0) + ->and(StockEntryChange::query()->count())->toBe(0); +}); + +test('korrektur darf die menge nicht unter den verbrauch senken', function () { + [$admin, $ing, $location, , , $entry] = corrReceived(); + $this->actingAs($admin, 'user'); + + StockDisposal::query()->create([ + 'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => $entry->id, 'location_id' => $location->id, + 'quantity' => 400, 'unit' => 'gram', 'reason' => 'Test', 'disposed_at' => '2026-09-06', 'user_id' => $admin->id, + ]); + + // 300 g < 400 g Verbrauch → abgelehnt, nichts geändert, kein Protokoll. + $this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['received_quantity' => '300'])) + ->assertRedirect(route('admin.inventory.stock-entries.edit', $entry)) + ->assertSessionHasErrors('received_quantity'); + expect((float) $entry->fresh()->received_quantity)->toBe(1000.0) + ->and(StockEntryChange::query()->count())->toBe(0); + + // Rohstoffwechsel bei Verbrauch → abgelehnt. + $other = Ingredient::query()->create(['name' => 'Corr-Other', 'active' => true, 'pos' => 0]); + $this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['ingredient_id' => (string) $other->id])) + ->assertSessionHasErrors('ingredient_id'); + expect((int) $entry->fresh()->ingredient_id)->toBe($ing->id); + + // 400 g = genau der Verbrauch → erlaubt. + $this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['received_quantity' => '400'])) + ->assertSessionHasNoErrors(); + expect((float) $entry->fresh()->received_quantity)->toBe(400.0); +}); + +test('korrektur eines ml-eingangs rechnet die eingegangene menge über die dichte um', function () { + [$admin, , , , , $entry] = corrReceived(0.8, 800); + $this->actingAs($admin, 'user'); + + // Formular zeigt 1.000 ml (800 g / 0,8); Korrektur auf 900 ml → 720 g. + $this->get(route('admin.inventory.stock-entries.edit', $entry))->assertOk()->assertSee('value="1.000,00"', false); + + $this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, [ + 'ordered_unit' => 'ml', 'ordered_quantity' => '1000', 'received_quantity' => '900', + ]))->assertSessionHasNoErrors(); + + $entry->refresh(); + expect((float) $entry->received_quantity)->toBe(720.0) + ->and((float) $entry->ordered_quantity)->toBe(800.0) + ->and($entry->purchase_unit)->toBe('ml'); +}); + +test('offene bestellung: bearbeiten protokolliert ohne pflichtgrund', function () { + [$admin, , , , , $entry] = corrReceived(); + $entry->update(['status' => 'pending', 'received_at' => null, 'received_quantity' => null, 'batch_number' => null, 'best_before' => null, 'received_by' => null]); + $this->actingAs($admin, 'user'); + + $this->put(route('admin.inventory.stock-entries.update', $entry), array_diff_key(corrPayload($entry, ['ordered_quantity' => '1200']), array_flip(['received_at', 'received_quantity', 'batch_number', 'best_before', 'change_reason']))) + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.inventory.stock-entries.index')); + + $entry->refresh(); + expect((float) $entry->ordered_quantity)->toBe(1200.0)->and($entry->status)->toBe('pending'); + $change = StockEntryChange::query()->firstOrFail(); + expect($change->status_before)->toBe('pending')->and($change->reason)->toBeNull() + ->and($change->field_changes)->toHaveKey('ordered_quantity')->and($change->field_changes)->not->toHaveKey('received_quantity'); + + // Speichern ohne Änderung erzeugt keine Protokollzeile. + $this->put(route('admin.inventory.stock-entries.update', $entry), array_diff_key(corrPayload($entry, ['ordered_quantity' => '1200']), array_flip(['received_at', 'received_quantity', 'batch_number', 'best_before', 'change_reason']))) + ->assertSessionHasNoErrors(); + expect(StockEntryChange::query()->count())->toBe(1); +}); + +test('verpackung: korrektur darf den artikelbestand nicht negativ machen', function () { + $admin = corrAdmin(); + $this->actingAs($admin, 'user'); + $item = PackagingItem::factory()->create(); + $location = Location::factory()->create(); + $supplier = Supplier::factory()->create(); + $entry = StockEntry::query()->create([ + 'entry_type' => 'packaging', 'packaging_item_id' => $item->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id, + 'unit' => 'piece', 'ordered_by' => $admin->id, 'ordered_at' => '2026-09-01', 'ordered_quantity' => 100, 'price_total' => 50, + 'status' => 'received', 'received_by' => $admin->id, 'received_at' => '2026-09-05', 'received_quantity' => 100, + ]); + StockDisposal::query()->create([ + 'disposal_type' => 'packaging', 'packaging_item_id' => $item->id, 'location_id' => $location->id, + 'quantity' => 60, 'unit' => 'piece', 'reason' => 'Test', 'disposed_at' => '2026-09-06', 'user_id' => $admin->id, + ]); + + $base = [ + 'entry_type' => 'packaging', 'packaging_item_id' => (string) $item->id, 'supplier_id' => (string) $supplier->id, + 'location_id' => (string) $location->id, 'ordered_at' => '01.09.2026', 'ordered_quantity' => '100', 'price_total' => '50,00', + 'received_at' => '05.09.2026', 'change_reason' => 'Zählfehler', + ]; + $this->put(route('admin.inventory.stock-entries.update', $entry), $base + ['received_quantity' => '50']) + ->assertSessionHasErrors('received_quantity'); + $this->put(route('admin.inventory.stock-entries.update', $entry), $base + ['received_quantity' => '60']) + ->assertSessionHasNoErrors(); + expect((float) $entry->fresh()->received_quantity)->toBe(60.0) + ->and(app(InventoryService::class)->remainingByPackagingItem([$item->id])[$item->id])->toBe(0.0); +});