Warenwirtschaft: OPT-7 Buchungs-Härtung (Audit H-2, H-3, M-1, M-9)

- H-2: quantity_used muss > 0 sein (FormRequest-Regel + Service-Guard) —
  negative Zeilen konnten sich in der Summenprüfung aufheben und
  Chargen-Restbestände aufblähen.
- H-3: expandBatchLines() dekrementiert die gecachten Restmengen —
  zwei Zeilen derselben Charge belasten nicht mehr denselben Einkauf
  doppelt (FEFO korrekt über beide Zeilen).
- M-1: Ausschuss validiert Chargen-Konsistenz (gehört zum Rohstoff,
  received) und Restbestand (je Charge bzw. gesamt).
- M-9: lockForUpdate auf die Produktionszeile bei Update und Löschen.

Tests: neuer ProductionHardeningTest (4). Suite: 223 grün, 806 Assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-15 12:45:23 +00:00
parent 84450385e3
commit c69721d174
5 changed files with 231 additions and 1 deletions

View file

@ -30,7 +30,14 @@ class StoreProductionRequest extends FormRequest
// AP-27: Das Formular übergibt Chargen (batch_key); Einzel-Einträge (stock_entry_id) bleiben unterstützt.
'ingredient_lines.*.batch_key' => ['required_without:ingredient_lines.*.stock_entry_id', 'nullable', 'string', 'max:120'],
'ingredient_lines.*.stock_entry_id' => ['required_without:ingredient_lines.*.batch_key', 'nullable', 'integer', 'exists:stock_entries,id'],
'ingredient_lines.*.quantity_used' => ['required', 'string'],
// Audit H-2: Vorzeichen-/Zahlprüfung — negative Zeilen konnten sich
// in der Summenprüfung gegenseitig aufheben und Bestand aufblähen.
'ingredient_lines.*.quantity_used' => ['required', 'string', function (string $attribute, mixed $value, \Closure $fail) {
$n = reFormatNumber((string) $value);
if (! is_numeric($n) || (float) $n <= 0) {
$fail(__('Die Menge je Chargen-Zeile muss größer als 0 sein.'));
}
}],
// AP-28: Produktionsergebnis — bucht den Produktbestand; Planung oben ist unverbindlich.
'outputs' => ['required', 'array', 'min:1'],
'outputs.*.product_id' => ['required', 'integer', 'exists:products,id'],

View file

@ -2,6 +2,9 @@
namespace App\Http\Requests\Inventory;
use App\Models\StockEntry;
use App\Services\InventoryService;
use App\Services\ProductionService;
use Carbon\Carbon;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -66,6 +69,7 @@ class StoreStockDisposalRequest extends FormRequest
if (empty($this->input('ingredient_id'))) {
$validator->errors()->add('ingredient_id', __('Bitte einen Rohstoff wählen.'));
}
$this->validateChargeConsistency($validator);
} elseif ($this->input('disposal_type') === 'packaging') {
if (empty($this->input('packaging_item_id'))) {
$validator->errors()->add('packaging_item_id', __('Bitte einen Verpackungsartikel wählen.'));
@ -74,6 +78,48 @@ class StoreStockDisposalRequest extends FormRequest
});
}
/**
* Audit M-1: Charge muss zum gewählten Rohstoff gehören (eingegangen,
* Typ ingredient), und die Menge darf den Restbestand nicht übersteigen.
*/
protected function validateChargeConsistency(Validator $validator): void
{
$quantity = (float) ($this->input('quantity') ?: 0);
$ingredientId = (int) $this->input('ingredient_id');
if (! $this->filled('stock_entry_id')) {
if ($ingredientId > 0 && $quantity > 0) {
$remaining = app(InventoryService::class)
->remainingByIngredient([$ingredientId])[$ingredientId] ?? 0.0;
if ($quantity > $remaining) {
$validator->errors()->add('quantity', __('Die Menge (:qty g) übersteigt den Restbestand (:rest g).', [
'qty' => number_format($quantity, 2, ',', '.'),
'rest' => number_format(max(0, $remaining), 2, ',', '.'),
]));
}
}
return;
}
$entry = StockEntry::query()->find((int) $this->input('stock_entry_id'));
if (! $entry || $entry->entry_type !== 'ingredient' || $entry->status !== 'received'
|| (int) $entry->ingredient_id !== $ingredientId) {
$validator->errors()->add('stock_entry_id', __('Die gewählte Charge gehört nicht zum gewählten Rohstoff.'));
return;
}
$consumed = app(ProductionService::class)->consumedByStockEntry([$entry->id])[$entry->id] ?? 0.0;
$remaining = round((float) $entry->received_quantity - $consumed, 2);
if ($quantity > $remaining) {
$validator->errors()->add('quantity', __('Die Menge (:qty g) übersteigt den Restbestand der Charge (:rest g).', [
'qty' => number_format($quantity, 2, ',', '.'),
'rest' => number_format(max(0, $remaining), 2, ',', '.'),
]));
}
}
/**
* @return array<string, mixed>
*/