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

@ -0,0 +1,160 @@
<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Models\Production;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\ProductionService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function phUser(): User
{
$user = User::query()->create([
'email' => uniqid('ph_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
return $user->fresh();
}
function phIngredient(string $name): Ingredient
{
return Ingredient::query()->create([
'name' => $name, 'trans_name' => '', 'inci' => $name, 'trans_inci' => '',
'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0,
]);
}
function phStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
{
return StockEntry::query()->create(array_merge([
'entry_type' => 'ingredient', 'ingredient_id' => $ing->id,
'supplier_id' => $supplier->id, 'location_id' => $location->id,
'unit' => 'gram', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01',
'ordered_quantity' => 1000, 'status' => 'received',
'received_by' => $user->id, 'received_at' => '2026-01-15',
'received_quantity' => 1000, 'batch_number' => 'PH-B1', 'best_before' => '2030-12-31',
], $overrides));
}
function phRecipeProduct(Ingredient $ing): Product
{
$product = Product::query()->create(['name' => 'PH-Prod', 'title' => 'PH-Prod', 'active' => true]);
ProductIngredient::create([
'product_id' => $product->id, 'ingredient_id' => $ing->id,
'pos' => 0, 'gram' => 10, 'factor' => 1, 'recipe_type' => 'manufacturer',
]);
return $product;
}
test('negative chargen-menge wird abgelehnt, auch wenn die summe stimmt (H-2)', function () {
$user = phUser();
$this->actingAs($user, 'user');
$ing = phIngredient('PH-Neg');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$a = phStock($ing, $location, $supplier, $user);
$b = phStock($ing, $location, $supplier, $user, ['batch_number' => 'PH-B2']);
$product = phRecipeProduct($ing);
// Soll = 10 g × 10 Stück = 100 g; +150 und −50 ergäben in Summe 100.
$response = $this->post(route('admin.inventory.productions.store'), [
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-07-15',
'quantity' => 10,
'ingredient_lines' => [
['ingredient_id' => $ing->id, 'stock_entry_id' => $a->id, 'quantity_used' => '150'],
['ingredient_id' => $ing->id, 'stock_entry_id' => $b->id, 'quantity_used' => '-50'],
],
'outputs' => [
['product_id' => $product->id, 'quantity' => 10, 'batch_number' => null],
],
]);
$response->assertSessionHasErrors('ingredient_lines.1.quantity_used');
expect(Production::query()->count())->toBe(0);
});
test('zwei zeilen derselben charge belasten nicht denselben einkauf doppelt (H-3)', function () {
$user = phUser();
$ing = phIngredient('PH-Doppel');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
// Gleiche Chargennummer, zwei Einkäufe: A = 100 g Rest, B = 200 g Rest.
$a = phStock($ing, $location, $supplier, $user, ['batch_number' => 'PH-X', 'received_quantity' => 100, 'received_at' => '2026-01-10']);
$b = phStock($ing, $location, $supplier, $user, ['batch_number' => 'PH-X', 'received_quantity' => 200, 'received_at' => '2026-01-12']);
$expanded = app(ProductionService::class)->expandBatchLines([
['ingredient_id' => $ing->id, 'batch_key' => 'b:PH-X', 'quantity_used' => 100],
['ingredient_id' => $ing->id, 'batch_key' => 'b:PH-X', 'quantity_used' => 100],
], $location->id);
$byEntry = [];
foreach ($expanded as $line) {
$byEntry[$line['stock_entry_id']] = ($byEntry[$line['stock_entry_id']] ?? 0) + (float) $line['quantity_used'];
}
// Vorher: beide Zeilen landeten auf A (−100 g Überzug). Jetzt: A 100, B 100.
expect($byEntry[$a->id])->toBe(100.0);
expect($byEntry[$b->id])->toBe(100.0);
});
test('ausschuss mit fremder charge wird abgelehnt (M-1)', function () {
$user = phUser();
$this->actingAs($user, 'user');
$ingA = phIngredient('PH-M1-A');
$ingB = phIngredient('PH-M1-B');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
phStock($ingA, $location, $supplier, $user);
$entryB = phStock($ingB, $location, $supplier, $user, ['batch_number' => 'PH-FREMD']);
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
'disposal_type' => 'ingredient',
'ingredient_id' => $ingA->id,
'stock_entry_id' => $entryB->id,
'location_id' => $location->id,
'quantity' => '100',
'reason' => 'Test',
'disposed_at' => '15.07.2026',
]);
$response->assertSessionHasErrors('stock_entry_id');
expect(StockDisposal::query()->count())->toBe(0);
});
test('ausschuss ueber restbestand wird abgelehnt (M-1)', function () {
$user = phUser();
$this->actingAs($user, 'user');
$ing = phIngredient('PH-M1-Rest');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
phStock($ing, $location, $supplier, $user); // 1000 g
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
'disposal_type' => 'ingredient',
'ingredient_id' => $ing->id,
'location_id' => $location->id,
'quantity' => '1500',
'reason' => 'Test',
'disposed_at' => '15.07.2026',
]);
$response->assertSessionHasErrors('quantity');
expect(StockDisposal::query()->count())->toBe(0);
});