- M-2: Produktionen buchen Bestand — StoreProductionRequest verlangt
jetzt admin >= 7 (wie manuelle Bestandsbewegungen); create/edit/copy
im Controller gegated, UI-Buttons für niedrigere Stufen ausgeblendet.
- M-3: Ausschuss-/Bewegungs-/Produktions-Buttons nur noch für
Berechtigte sichtbar (keine toten Formulare mehr).
- M-6: Zustandsändernde GET-Routen umgestellt — stock-entries/copy
jetzt POST mit CSRF; /admin/product/delete jetzt DELETE-Formulare
(Produktliste + Rezeptur-Zeile im Produktformular).
- M-7: ProductController::imageAttribute whitelistet Spalten
('active', 'pos') + numerische Werte, sonst 400 — kein beliebiger
Spalten-Write per URL mehr.
- M-10: URL-Felder (Lieferant, Verpackungsartikel, INCI-Lieferanten-
Repeater) validieren url:http,https — keine speicherbaren
javascript:-Links mehr.
Tests: ProductionHardeningTest +2; drei bestehende Tests an neue
Regeln angepasst (admin-Level 7 für Produktions-Formular, POST-Copy).
Suite: 231 grün.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
194 lines
7.3 KiB
PHP
194 lines
7.3 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' => '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);
|
||
});
|
||
|
||
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();
|
||
});
|