gruene-seele/tests/Feature/ProductionHardeningTest.php
Kevin Adametz 9b6c9def97 Warenwirtschaft: N-1 — Chargen-Gruppierung je Lieferant (Kundenentscheid 15.07.2026)
Chargennummern kommen von extern und können kollidieren. Der Lieferant
gehört jetzt zum Gruppenschlüssel (batchGroupKey b:<supplier>:<lot>) —
gleiche Lot-Nummern verschiedener Lieferanten verschmelzen nicht mehr
(kein gemeinsames min-MHD/Summen-Rest). Batch-Check (AP-27) prüft
zusätzlich den Lieferanten des zu buchenden Eingangs.

Tests: +1 (getrennte Optionen je Lieferant); Chargen-Tests auf neues
Schlüsselformat umgestellt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:25:35 +00:00

223 lines
8.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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' => $a->batchGroupKey(), 'quantity_used' => 100],
['ingredient_id' => $ing->id, 'batch_key' => $a->batchGroupKey(), '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);
});
test('copyreader darf keine produktion buchen (M-2)', function () {
$viewer = User::query()->create([
'email' => uniqid('ph_', true).'@test.example',
'password' => bcrypt('password'),
]);
$viewer->forceFill(['admin' => 1, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
$this->actingAs($viewer->fresh(), 'user');
$product = Product::query()->create(['name' => 'PH-M2', 'title' => 'PH-M2', 'active' => true, 'no_recipe_required' => true]);
$location = Location::factory()->create();
$this->post(route('admin.inventory.productions.store'), [
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-07-15',
'quantity' => 5,
'outputs' => [['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]],
])->assertForbidden();
expect(Production::query()->count())->toBe(0);
});
test('javascript-url wird bei lieferanten abgelehnt (M-10)', function () {
$user = phUser();
$this->actingAs($user, 'user');
$this->post(route('admin.inventory.suppliers.store'), [
'name' => 'PH-XSS-Lieferant',
'url' => 'javascript:alert(1)',
])->assertSessionHasErrors('url');
expect(Supplier::query()->where('name', 'PH-XSS-Lieferant')->exists())->toBeFalse();
});
test('gleiche lot-nummer verschiedener lieferanten bleibt getrennt (N-1)', function () {
$user = phUser();
$ing = phIngredient('PH-N1');
$location = Location::factory()->create();
$supplierA = Supplier::factory()->create(['name' => 'Lieferant A']);
$supplierB = Supplier::factory()->create(['name' => 'Lieferant B']);
// Beide extern mit Lot "0107", aber unterschiedliches MHD.
$a = phStock($ing, $location, $supplierA, $user, ['batch_number' => '0107', 'received_quantity' => 100, 'best_before' => '2027-01-01']);
$b = phStock($ing, $location, $supplierB, $user, ['batch_number' => '0107', 'received_quantity' => 200, 'best_before' => '2030-01-01']);
expect($a->batchGroupKey())->not->toBe($b->batchGroupKey());
$available = app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $location->id);
$options = app(ProductionService::class)->batchOptionsForEntries($available);
// Zwei getrennte Chargen-Optionen, keine MHD-/Rest-Verschmelzung.
expect($options)->toHaveCount(2);
expect(collect($options)->pluck('remaining')->sort()->values()->all())->toBe([100.0, 200.0]);
});
test('punkt mit ein bis zwei nachkommastellen gilt als dezimaltrenner (N-6)', function () {
expect((float) reFormatWawiNumber('5.1'))->toBe(5.1);
expect((float) reFormatWawiNumber('5.12'))->toBe(5.12);
expect((float) reFormatWawiNumber('1.500'))->toBe(1500.0); // 3 Stellen = Tausender
expect((float) reFormatWawiNumber('1.234,56'))->toBe(1234.56);
expect((float) reFormatWawiNumber('100'))->toBe(100.0);
expect((float) reFormatWawiNumber('3,5'))->toBe(3.5);
});