Warenwirtschaft: AP-09 bis AP-13 (Produktbestand, Set-Produkte, Ausschuss, Konzepte)
- 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>
This commit is contained in:
parent
78679e0c55
commit
3ee2d756e9
63 changed files with 5968 additions and 901 deletions
375
tests/Feature/ProductionManufacturerRecipeTest.php
Normal file
375
tests/Feature/ProductionManufacturerRecipeTest.php
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
<?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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue