gruene-seele/tests/Feature/ProductionOutputTest.php
Kevin Adametz 99a6c92e6c Warenwirtschaft: AP-28 Neue Produktions-Maske + Produktionsergebnis
- production_outputs (Migration inkl. idempotentem Backfill): eine Produktion
  kann mehrere Output-Produkte erzeugen (z. B. 40x50 ml + 50x5 ml)
- ProductStockService bucht je Output-Produkt (idempotenter Diff, append-only);
  die geplante Stückzahl bucht nichts mehr, entfernte Outputs werden rückgebucht
- ProductionService store/update mit outputs[] (leer = Fallback auf Planung
  inkl. suggestedBatchNumber — Backfill-Semantik, Alt-Aufrufer stabil)
- Maske: INCI-Liste (Alternativname/Qualität/Anteil/Gramm/Regal), Chargen-
  Erfassung nach Rezeptur-Phasen mit Notiz + Status-Ampel, Produktionsergebnis-
  Repeater mit Chargen-Nr.-Vorbelegung (Präfix + ttmmjj), Planungs-Hinweis
- Show: Produktionsergebnis-Karte; Index: Ergebnis-Spalte
- Hinweise-Doku + Plan V6.0 §6 fortgeschrieben (nächster Schritt: AP-13)

Tests: ProductionOutputTest (8 neu); Suite 201 grün, 731 Assertionen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:02:54 +00:00

325 lines
11 KiB
PHP
Raw Permalink 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\Country;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Models\Production;
use App\Models\ProductStockMovement;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\ProductionService;
use App\Services\ProductStockService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap28MakeUser(int $adminLevel = 7): User
{
Country::query()->firstOrCreate(
['code' => 'DE'],
[
'phone' => '00',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Germany',
'fr' => 'Germany',
'it' => 'Germany',
'ru' => 'Germany',
'active' => true,
]
);
$user = User::query()->create([
'email' => uniqid('ap28_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function ap28MakeProduct(string $name, array $overrides = []): Product
{
return Product::query()->create(array_merge([
'name' => $name,
'title' => $name,
'active' => true,
], $overrides));
}
/**
* Produkt ohne Rezeptur-Pflicht — Produktionen brauchen dann keine Chargen-Zeilen.
*/
function ap28MakeSimpleProduct(string $name, array $overrides = []): Product
{
return ap28MakeProduct($name, array_merge(['no_recipe_required' => true], $overrides));
}
function ap28Stock(int $productId): int
{
return app(ProductStockService::class)->currentStock($productId);
}
test('mehrere outputs buchen produktbestand je produkt, planung bucht nichts', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$planned = ap28MakeSimpleProduct('AP28-Plan-50ml');
$small = ap28MakeSimpleProduct('AP28-5ml');
$production = app(ProductionService::class)->store(
[
'product_id' => $planned->id,
'location_id' => $location->id,
'produced_at' => '2026-07-01',
'quantity' => 50, // Planung — darf nicht buchen
'notes' => null,
],
[],
$user->id,
[
['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => 'GS1010726'],
['product_id' => $small->id, 'quantity' => 50, 'batch_number' => 'GS2010726'],
]
);
expect($production->outputs)->toHaveCount(2);
expect(ap28Stock($planned->id))->toBe(40);
expect(ap28Stock($small->id))->toBe(50);
});
test('service ohne outputs faellt auf planung zurueck (backfill-semantik)', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$product = ap28MakeSimpleProduct('AP28-Fallback', ['batch_prefix' => 'TP1']);
$production = app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-06-15',
'quantity' => 12,
'notes' => null,
],
[],
$user->id
);
expect($production->outputs)->toHaveCount(1);
expect($production->outputs->first()->quantity)->toBe(12);
// Chargen-Nr. der Fallback-Zeile = Präfix + ttmmjj des Produktionsdatums (AP-24).
expect($production->outputs->first()->batch_number)->toBe('TP1150626');
expect(ap28Stock($product->id))->toBe(12);
});
test('bearbeiten korrigiert produktbestand per differenz je output-produkt', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$planned = ap28MakeSimpleProduct('AP28-Edit-50ml');
$small = ap28MakeSimpleProduct('AP28-Edit-5ml');
$service = app(ProductionService::class);
$production = $service->store(
['product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, 'notes' => null],
[],
$user->id,
[['product_id' => $planned->id, 'quantity' => 50, 'batch_number' => null]]
);
expect(ap28Stock($planned->id))->toBe(50);
// Ergebnis nachträglich: nur 40×50 ml, dafür 50×5 ml.
$production = $service->updateProduction(
$production,
['product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, 'notes' => null],
[],
$user->id,
[
['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => null],
['product_id' => $small->id, 'quantity' => 50, 'batch_number' => null],
]
);
expect(ap28Stock($planned->id))->toBe(40);
expect(ap28Stock($small->id))->toBe(50);
// Append-only: Korrektur als Gegenbuchung, keine Historie gelöscht.
expect(ProductStockMovement::query()->where('product_id', $planned->id)->count())->toBe(2);
// Output-Produkt komplett entfernen ⇒ Bestand wird auf null zurückgeführt.
$service->updateProduction(
$production->fresh(),
['product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, 'notes' => null],
[],
$user->id,
[['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => null]]
);
expect(ap28Stock($small->id))->toBe(0);
});
test('rohstoffverbrauch bleibt chargenbasiert und unabhaengig vom ergebnis', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = Ingredient::query()->create([
'name' => 'AP28-Rohstoff',
'trans_name' => '',
'inci' => 'AP28-Rohstoff',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
$stock = StockEntry::query()->create([
'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' => 5000,
'status' => 'received',
'received_by' => $user->id,
'received_at' => '2026-01-15',
'received_quantity' => 5000,
'batch_number' => 'AP28-CHARGE',
'best_before' => '2030-12-31',
]);
$product = ap28MakeProduct('AP28-Rezeptur-Produkt');
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
]);
// Plan 2 Stück (Soll 20 g) — Ergebnis weicht ab (nur 1 Stück), Verbrauch bleibt bei den Chargen-Werten.
$production = app(ProductionService::class)->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 2, 'notes' => null],
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '20']],
$user->id,
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]]
);
expect((float) $production->productionIngredients->first()->quantity_used)->toBe(20.0);
expect(ap28Stock($product->id))->toBe(1);
});
test('http-validierung verlangt mindestens eine ergebniszeile', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$product = ap28MakeSimpleProduct('AP28-HTTP-Pflicht');
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.productions.store'), [
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '01.07.2026',
'quantity' => 10,
]);
$response->assertSessionHasErrors('outputs');
expect(Production::query()->count())->toBe(0);
});
test('outputs validieren produkt und stueckzahl im service', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$product = ap28MakeSimpleProduct('AP28-Validierung');
$set = ap28MakeProduct('AP28-Set', ['is_set' => true, 'no_recipe_required' => true]);
$base = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 1, 'notes' => null];
expect(fn () => app(ProductionService::class)->store($base, [], $user->id, [
['product_id' => $set->id, 'quantity' => 5, 'batch_number' => null],
]))->toThrow(ValidationException::class);
expect(fn () => app(ProductionService::class)->store($base, [], $user->id, [
['product_id' => $product->id, 'quantity' => 0, 'batch_number' => null],
]))->toThrow(ValidationException::class);
});
test('recipe-payload liefert batch_prefix, phasen und inci-zusatzspalten', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$ing = Ingredient::query()->create([
'name' => 'AP28-Payload-Rohstoff',
'trans_name' => '',
'inci' => 'AP28-Payload-Rohstoff',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
'alt_name' => 'Phospholipon 80 H',
]);
$product = ap28MakeProduct('AP28-Payload-Produkt', ['batch_prefix' => 'TP1']);
$phase = $product->recipePhases()->create([
'recipe_type' => 'manufacturer',
'name' => 'Phase A',
'note' => 'auf 70 °C erhitzen',
'pos' => 0,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 100,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
'recipe_phase_id' => $phase->id,
]);
$payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1);
expect($payload['product']['batch_prefix'])->toBe('TP1');
expect($payload['phases'])->toHaveCount(1);
expect($payload['phases'][0]['name'])->toBe('Phase A');
expect($payload['phases'][0]['note'])->toBe('auf 70 °C erhitzen');
$first = $payload['ingredients'][0];
expect($first['alt_name'])->toBe('Phospholipon 80 H');
expect($first['phase_id'])->toBe($phase->id);
expect(array_key_exists('storage_label', $first))->toBeTrue();
expect(array_key_exists('quality', $first))->toBeTrue();
});
test('produktions-maske rendert ergebnis-block und planungs-hinweis', function () {
$user = ap28MakeUser();
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.productions.create'));
$response->assertOk();
$response->assertSee('Produktionsergebnis');
$response->assertSee('Geplante Stückzahl (Planung)');
$response->assertSee('Die Planung oben ist unverbindlich.', false);
$response->assertSee('output-row-template', false);
});