- 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>
325 lines
11 KiB
PHP
325 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\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);
|
||
});
|