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>
223 lines
8.9 KiB
PHP
223 lines
8.9 KiB
PHP
<?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);
|
||
});
|