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:
parent
84450385e3
commit
c69721d174
5 changed files with 231 additions and 1 deletions
160
tests/Feature/ProductionHardeningTest.php
Normal file
160
tests/Feature/ProductionHardeningTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue