- AP-09 Produktbestand inkl. Bewegungshistorie (product_stock_movements, ProductStockService) - AP-10 Rohstoffbestand-Ansicht je Lager (RawMaterialStockController) - AP-11 Bestandsschwellen / Out-of-Stock-Handling fuer Produkte und Shop - AP-12 Ausgang/Ausschuss (stock_disposals, StockDisposalController, InventoryService) - Set-Produkte (product_set_items) inkl. Aufloesung - Produktentwicklung & Hinweise-Verwaltung (Notices) - AP-13 Entwicklungskonzept Shop-Bestandsabzug im Plan dokumentiert - Feature-Tests fuer neue Module + aktualisierter Entwicklungsplan Co-authored-by: Cursor <cursoragent@cursor.com>
375 lines
11 KiB
PHP
375 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Models\Country;
|
|
use App\Models\Ingredient;
|
|
use App\Models\Location;
|
|
use App\Models\Product;
|
|
use App\Models\ProductIngredient;
|
|
use App\Models\StockEntry;
|
|
use App\Models\Supplier;
|
|
use App\Services\ProductionService;
|
|
use App\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
function ap09MakeUser(int $adminLevel = 7): User
|
|
{
|
|
$user = User::query()->create([
|
|
'email' => uniqid('ap09_', true).'@test.example',
|
|
'password' => bcrypt('password'),
|
|
]);
|
|
$user->forceFill([
|
|
'admin' => $adminLevel,
|
|
'confirmed' => true,
|
|
'active' => true,
|
|
'wizard' => 100,
|
|
'blocked' => false,
|
|
])->save();
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
function ap09EnsureCountry(): Country
|
|
{
|
|
return Country::query()->firstOrCreate(
|
|
['code' => 'DE'],
|
|
[
|
|
'phone' => '00',
|
|
'en' => 'Germany',
|
|
'de' => 'Deutschland',
|
|
'es' => 'Germany',
|
|
'fr' => 'Germany',
|
|
'it' => 'Germany',
|
|
'ru' => 'Germany',
|
|
'active' => true,
|
|
]
|
|
);
|
|
}
|
|
|
|
function ap09MakeIngredient(string $name): Ingredient
|
|
{
|
|
return Ingredient::query()->create([
|
|
'name' => $name,
|
|
'trans_name' => '',
|
|
'inci' => $name,
|
|
'trans_inci' => '',
|
|
'effect' => '',
|
|
'trans_effect' => '',
|
|
'active' => true,
|
|
'pos' => 0,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function ap09MakeStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
|
|
{
|
|
return StockEntry::query()->create(array_merge([
|
|
'entry_type' => 'ingredient',
|
|
'ingredient_id' => $ing->id,
|
|
'packaging_item_id' => null,
|
|
'supplier_id' => $supplier->id,
|
|
'location_id' => $location->id,
|
|
'unit' => 'gram',
|
|
'ordered_by' => $user->id,
|
|
'ordered_at' => '2026-01-01',
|
|
'ordered_quantity' => 10000,
|
|
'price_per_kg' => 1,
|
|
'status' => 'received',
|
|
'received_by' => $user->id,
|
|
'received_at' => '2026-01-15',
|
|
'received_quantity' => 10000,
|
|
'batch_number' => 'AP09-B',
|
|
'best_before' => '2030-12-31',
|
|
'quality_id' => null,
|
|
], $overrides));
|
|
}
|
|
|
|
test('production rechnet soll-verbrauch aus hersteller-rezeptur', function () {
|
|
ap09EnsureCountry();
|
|
$user = ap09MakeUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap09MakeIngredient('AP09-Soll');
|
|
|
|
$product = Product::query()->create([
|
|
'name' => 'AP09-Soll-Produkt',
|
|
'title' => 'AP09-Soll-Produkt',
|
|
'active' => true,
|
|
]);
|
|
|
|
// Produkt-Rezeptur (darf NICHT verwendet werden)
|
|
ProductIngredient::query()->create([
|
|
'product_id' => $product->id,
|
|
'ingredient_id' => $ing->id,
|
|
'pos' => 0,
|
|
'gram' => 999,
|
|
'factor' => 1.0,
|
|
'recipe_type' => 'product',
|
|
]);
|
|
// Hersteller-Rezeptur (maßgeblich): 10 g * 1.2 * 5 Stück = 60 g
|
|
ProductIngredient::query()->create([
|
|
'product_id' => $product->id,
|
|
'ingredient_id' => $ing->id,
|
|
'pos' => 0,
|
|
'gram' => 10,
|
|
'factor' => 1.2,
|
|
'recipe_type' => 'manufacturer',
|
|
]);
|
|
|
|
$stock = ap09MakeStock($ing, $location, $supplier, $user);
|
|
|
|
$production = app(ProductionService::class)->store(
|
|
[
|
|
'product_id' => $product->id,
|
|
'location_id' => $location->id,
|
|
'produced_at' => '2026-03-01',
|
|
'quantity' => 5,
|
|
'notes' => null,
|
|
],
|
|
[
|
|
['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '60'],
|
|
],
|
|
$user->id
|
|
);
|
|
|
|
expect((float) $production->productionIngredients->first()->quantity_used)->toBe(60.0);
|
|
});
|
|
|
|
test('production blockiert ohne hersteller-rezeptur trotz vorhandener produkt-rezeptur', function () {
|
|
ap09EnsureCountry();
|
|
$user = ap09MakeUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap09MakeIngredient('AP09-NoMfg');
|
|
|
|
$product = Product::query()->create([
|
|
'name' => 'AP09-NoMfg-Produkt',
|
|
'title' => 'AP09-NoMfg-Produkt',
|
|
'active' => true,
|
|
]);
|
|
|
|
ProductIngredient::query()->create([
|
|
'product_id' => $product->id,
|
|
'ingredient_id' => $ing->id,
|
|
'pos' => 0,
|
|
'gram' => 10,
|
|
'factor' => 1.0,
|
|
'recipe_type' => 'product',
|
|
]);
|
|
|
|
$stock = ap09MakeStock($ing, $location, $supplier, $user);
|
|
|
|
expect(fn () => app(ProductionService::class)->store(
|
|
[
|
|
'product_id' => $product->id,
|
|
'location_id' => $location->id,
|
|
'produced_at' => '2026-03-01',
|
|
'quantity' => 1,
|
|
'notes' => null,
|
|
],
|
|
[
|
|
['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10'],
|
|
],
|
|
$user->id
|
|
))->toThrow(ValidationException::class);
|
|
});
|
|
|
|
test('recipe json meldet fehlende hersteller-rezeptur', function () {
|
|
ap09EnsureCountry();
|
|
$user = ap09MakeUser(1);
|
|
$location = Location::factory()->create();
|
|
|
|
$product = Product::query()->create([
|
|
'name' => 'AP09-Recipe-Empty',
|
|
'title' => 'AP09-Recipe-Empty',
|
|
'active' => true,
|
|
]);
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->getJson(route('admin.inventory.api.products.recipe', ['product' => $product->id]).'?location_id='.$location->id.'&quantity=1');
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonPath('has_recipe', false);
|
|
expect($response->json('ingredients'))->toBe([]);
|
|
});
|
|
|
|
test('chargen ohne restbestand erscheinen nicht im rezept', function () {
|
|
ap09EnsureCountry();
|
|
$user = ap09MakeUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = ap09MakeIngredient('AP09-Rest');
|
|
|
|
$product = Product::query()->create([
|
|
'name' => 'AP09-Rest-Produkt',
|
|
'title' => 'AP09-Rest-Produkt',
|
|
'active' => true,
|
|
]);
|
|
|
|
ProductIngredient::query()->create([
|
|
'product_id' => $product->id,
|
|
'ingredient_id' => $ing->id,
|
|
'pos' => 0,
|
|
'gram' => 100,
|
|
'factor' => 1.0,
|
|
'recipe_type' => 'manufacturer',
|
|
]);
|
|
|
|
// Charge komplett aufgebraucht
|
|
$emptyStock = ap09MakeStock($ing, $location, $supplier, $user, [
|
|
'batch_number' => 'AP09-LEER',
|
|
'received_quantity' => 100,
|
|
'best_before' => '2030-01-01',
|
|
]);
|
|
// Charge mit Restbestand
|
|
$fullStock = ap09MakeStock($ing, $location, $supplier, $user, [
|
|
'batch_number' => 'AP09-VOLL',
|
|
'received_quantity' => 100,
|
|
'best_before' => '2030-06-01',
|
|
]);
|
|
|
|
// 1 Produktion verbraucht die erste Charge vollständig (100 g * 1.0 * 1 Stück)
|
|
app(ProductionService::class)->store(
|
|
[
|
|
'product_id' => $product->id,
|
|
'location_id' => $location->id,
|
|
'produced_at' => '2026-03-01',
|
|
'quantity' => 1,
|
|
'notes' => null,
|
|
],
|
|
[
|
|
['ingredient_id' => $ing->id, 'stock_entry_id' => $emptyStock->id, 'quantity_used' => '100'],
|
|
],
|
|
$user->id
|
|
);
|
|
|
|
$payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1);
|
|
|
|
$entryIds = collect($payload['ingredients'][0]['stock_entries'])->pluck('id')->all();
|
|
expect($entryIds)->toContain($fullStock->id);
|
|
expect($entryIds)->not->toContain($emptyStock->id);
|
|
});
|
|
|
|
test('chargen-label zeigt lieferant, charge und datum ohne mhd-text', function () {
|
|
ap09EnsureCountry();
|
|
$user = ap09MakeUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create(['name' => 'AP09-Lieferant']);
|
|
$ing = ap09MakeIngredient('AP09-Label');
|
|
|
|
$product = Product::query()->create([
|
|
'name' => 'AP09-Label-Produkt',
|
|
'title' => 'AP09-Label-Produkt',
|
|
'active' => true,
|
|
]);
|
|
|
|
ProductIngredient::query()->create([
|
|
'product_id' => $product->id,
|
|
'ingredient_id' => $ing->id,
|
|
'pos' => 0,
|
|
'gram' => 10,
|
|
'factor' => 1.0,
|
|
'recipe_type' => 'manufacturer',
|
|
]);
|
|
|
|
ap09MakeStock($ing, $location, $supplier, $user, [
|
|
'batch_number' => 'CHARGE-123',
|
|
'best_before' => '2030-12-31',
|
|
]);
|
|
|
|
$payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1);
|
|
$label = $payload['ingredients'][0]['stock_entries'][0]['label'];
|
|
|
|
expect($label)->toBe('AP09-Lieferant - CHARGE-123 - 31.12.2030');
|
|
expect($label)->not->toContain('MHD');
|
|
});
|
|
|
|
test('produkt ohne rezeptur kann ohne chargen produziert werden', function () {
|
|
ap09EnsureCountry();
|
|
$user = ap09MakeUser();
|
|
$location = Location::factory()->create();
|
|
|
|
$product = Product::query()->create([
|
|
'name' => 'AP09-Broschuere',
|
|
'title' => 'AP09-Broschuere',
|
|
'active' => true,
|
|
'no_recipe_required' => true,
|
|
]);
|
|
|
|
$production = app(ProductionService::class)->store(
|
|
[
|
|
'product_id' => $product->id,
|
|
'location_id' => $location->id,
|
|
'produced_at' => '2026-03-01',
|
|
'quantity' => 25,
|
|
'notes' => null,
|
|
],
|
|
[],
|
|
$user->id
|
|
);
|
|
|
|
expect($production->productionIngredients)->toHaveCount(0);
|
|
expect($production->quantity)->toBe(25);
|
|
});
|
|
|
|
test('recipe json meldet recipe_required false bei eigenprodukt', function () {
|
|
ap09EnsureCountry();
|
|
$user = ap09MakeUser(1);
|
|
$location = Location::factory()->create();
|
|
|
|
$product = Product::query()->create([
|
|
'name' => 'AP09-Etikett',
|
|
'title' => 'AP09-Etikett',
|
|
'active' => true,
|
|
'no_recipe_required' => true,
|
|
]);
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->getJson(route('admin.inventory.api.products.recipe', ['product' => $product->id]).'?location_id='.$location->id.'&quantity=10');
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertJsonPath('recipe_required', false);
|
|
});
|
|
|
|
test('produktion per http speichert eigenprodukt ohne chargen', function () {
|
|
ap09EnsureCountry();
|
|
$user = ap09MakeUser();
|
|
$location = Location::factory()->create();
|
|
|
|
$product = Product::query()->create([
|
|
'name' => 'AP09-HTTP-Eigen',
|
|
'title' => 'AP09-HTTP-Eigen',
|
|
'active' => true,
|
|
'no_recipe_required' => true,
|
|
]);
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->post(route('admin.inventory.productions.store'), [
|
|
'product_id' => $product->id,
|
|
'location_id' => $location->id,
|
|
'produced_at' => '01.03.2026',
|
|
'quantity' => 12,
|
|
]);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertDatabaseHas('productions', [
|
|
'product_id' => $product->id,
|
|
'quantity' => 12,
|
|
]);
|
|
});
|
|
|
|
test('produktentwicklung platzhalter rendert briefing-hinweis', function () {
|
|
ap09EnsureCountry();
|
|
$user = ap09MakeUser(1);
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->get(route('admin.inventory.product-development'));
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertSee('Briefing ausstehend');
|
|
});
|