Entwürfe speichern batch_key roh (stock_entry_id nullable), keine Auflösung und keine Filterung unfertiger Zeilen mehr beim Zwischenspeichern. Eine nicht mehr verfügbare Charge wird im Formular markiert statt alle Zeilen zu verwerfen. Ergebniszeilen dürfen im Entwurf ohne Stückzahl bleiben. Auflösung, Prüfung und Buchung erst beim Abschluss. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
348 lines
16 KiB
PHP
348 lines
16 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\StockEntry;
|
|
use App\Models\Supplier;
|
|
use App\Services\InventoryService;
|
|
use App\Services\ProductionService;
|
|
use App\Services\ProductStockService;
|
|
use App\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Laravel\Passport\Passport;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
function draftCountry(): Country
|
|
{
|
|
return Country::query()->firstOrCreate(
|
|
['code' => 'DE'],
|
|
['phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany', 'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true]
|
|
);
|
|
}
|
|
|
|
function draftUser(int $adminLevel = 7): User
|
|
{
|
|
draftCountry();
|
|
$user = User::query()->create(['email' => uniqid('draft_', true).'@test.example', 'password' => bcrypt('password')]);
|
|
$user->forceFill(['admin' => $adminLevel, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
function draftRecipeProduct(Ingredient $ing): Product
|
|
{
|
|
$product = Product::query()->create(['name' => 'Draft-Produkt', 'title' => 'Draft-Produkt', 'active' => true]);
|
|
// Hersteller-Rezeptur: 10 g * 1.0 * 1 Stück = 10 g Soll
|
|
ProductIngredient::query()->create([
|
|
'product_id' => $product->id, 'ingredient_id' => $ing->id, 'pos' => 0,
|
|
'gram' => 10, 'factor' => 1.0, 'recipe_type' => 'manufacturer',
|
|
]);
|
|
|
|
return $product;
|
|
}
|
|
|
|
function draftIngredient(): Ingredient
|
|
{
|
|
return Ingredient::query()->create([
|
|
'name' => 'Draft-Roh', 'trans_name' => '', 'inci' => 'DR', 'trans_inci' => '',
|
|
'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0,
|
|
]);
|
|
}
|
|
|
|
function draftStock(Ingredient $ing, Location $location, Supplier $supplier, User $user): StockEntry
|
|
{
|
|
return StockEntry::query()->create([
|
|
'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' => 1000,
|
|
'price_per_kg' => 1, 'status' => 'received', 'received_by' => $user->id,
|
|
'received_at' => '2026-01-15', 'received_quantity' => 1000, 'batch_number' => 'DRAFT-B',
|
|
'best_before' => '2030-12-31', 'quality_id' => null,
|
|
]);
|
|
}
|
|
|
|
test('AP-43: entwurf bucht keinen produktbestand und bleibt status draft', function () {
|
|
$user = draftUser();
|
|
$location = Location::factory()->create();
|
|
$product = Product::query()->create(['name' => 'Draft-Simpel', 'title' => 'Draft-Simpel', 'active' => true, 'no_recipe_required' => true]);
|
|
|
|
$production = app(ProductionService::class)->store(
|
|
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 20, 'notes' => 'WIP'],
|
|
[],
|
|
$user->id,
|
|
[['product_id' => $product->id, 'quantity' => 20, 'batch_number' => null]],
|
|
true // draft
|
|
);
|
|
|
|
expect($production->isDraft())->toBeTrue()
|
|
->and(app(ProductStockService::class)->currentStock($product->id))->toBe(0);
|
|
});
|
|
|
|
test('AP-43: entwurf reduziert den rohstoffbestand nicht', function () {
|
|
$user = draftUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = draftIngredient();
|
|
$product = draftRecipeProduct($ing);
|
|
$stock = draftStock($ing, $location, $supplier, $user);
|
|
|
|
$before = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0;
|
|
|
|
app(ProductionService::class)->store(
|
|
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
|
|
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']],
|
|
$user->id,
|
|
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
|
|
true // draft
|
|
);
|
|
|
|
$after = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0;
|
|
|
|
expect((float) $after)->toBe((float) $before); // Entwurf verbraucht nichts
|
|
});
|
|
|
|
test('AP-43: entwurf abschließen bucht bestand und wird completed', function () {
|
|
$user = draftUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = draftIngredient();
|
|
$product = draftRecipeProduct($ing);
|
|
$stock = draftStock($ing, $location, $supplier, $user);
|
|
|
|
$service = app(ProductionService::class);
|
|
|
|
$draft = $service->store(
|
|
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
|
|
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']],
|
|
$user->id,
|
|
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
|
|
true
|
|
);
|
|
|
|
expect(app(ProductStockService::class)->currentStock($product->id))->toBe(0);
|
|
|
|
$completed = $service->updateProduction(
|
|
$draft->fresh(),
|
|
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
|
|
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']],
|
|
$user->id,
|
|
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
|
|
false // abschließen
|
|
);
|
|
|
|
expect($completed->isDraft())->toBeFalse()
|
|
->and($completed->status)->toBe(Production::STATUS_COMPLETED)
|
|
->and(app(ProductStockService::class)->currentStock($product->id))->toBe(1);
|
|
|
|
// Jetzt zählt der Rohstoffverbrauch: 1000 - 10 = 990
|
|
$remaining = app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0;
|
|
expect((float) $remaining)->toBe(990.0);
|
|
});
|
|
|
|
test('AP-43: abgeschlossene produktion kann nicht zum entwurf zurück', function () {
|
|
$user = draftUser();
|
|
$location = Location::factory()->create();
|
|
$product = Product::query()->create(['name' => 'Draft-Done', 'title' => 'Draft-Done', 'active' => true, 'no_recipe_required' => true]);
|
|
|
|
$completed = app(ProductionService::class)->store(
|
|
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 5, 'notes' => null],
|
|
[],
|
|
$user->id,
|
|
[['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]],
|
|
false
|
|
);
|
|
|
|
expect(fn () => app(ProductionService::class)->updateProduction(
|
|
$completed->fresh(),
|
|
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 5, 'notes' => null],
|
|
[],
|
|
$user->id,
|
|
[['product_id' => $product->id, 'quantity' => 5, 'batch_number' => null]],
|
|
true // Rück-Entwurf verboten
|
|
))->toThrow(ValidationException::class);
|
|
});
|
|
|
|
test('AP-43/44: neue-produktion-maske zeigt abschließen + zwischenspeichern und die aufgehübschte rezeptur-optik', function () {
|
|
$user = draftUser();
|
|
Location::factory()->create();
|
|
$this->actingAs($user, 'user');
|
|
|
|
$this->get(route('admin.inventory.productions.create'))
|
|
->assertOk()
|
|
// AP-43: umbenannter Abschluss-Button + Entwurf-Button
|
|
->assertSee('Produktion abschließen')
|
|
->assertSee('Zwischenspeichern (Entwurf)')
|
|
->assertSee('name="save_mode"', false)
|
|
// AP-44: Optik-Hooks (Phasen-Beschreibung formatiert, kein innerer Kasten, iPad-Felder)
|
|
->assertSee('recipe-phase-note', false)
|
|
->assertSee('recipe-item', false)
|
|
->assertSee('pointer: coarse', false);
|
|
});
|
|
|
|
test('AP-43: HTTP zwischenspeichern ohne ergebnis speichert entwurf; abschließen erzwingt ergebnis', function () {
|
|
$user = draftUser();
|
|
$location = Location::factory()->create();
|
|
$product = Product::query()->create(['name' => 'Draft-HTTP', 'title' => 'Draft-HTTP', 'active' => true, 'no_recipe_required' => true]);
|
|
|
|
Passport::actingAs($user);
|
|
$this->actingAs($user, 'user');
|
|
|
|
// Entwurf: kein Produktionsergebnis nötig → wird gespeichert (Redirect zur Bearbeitung)
|
|
$this->post(route('admin.inventory.productions.store'), [
|
|
'save_mode' => 'draft',
|
|
'product_id' => $product->id,
|
|
'location_id' => $location->id,
|
|
'produced_at' => '2026-08-01',
|
|
'quantity' => 3,
|
|
])->assertRedirect();
|
|
|
|
$draft = Production::query()->where('product_id', $product->id)->sole();
|
|
expect($draft->isDraft())->toBeTrue();
|
|
|
|
// Abschließen ohne outputs → Validierungsfehler (outputs required)
|
|
$this->from(route('admin.inventory.productions.create'))->post(route('admin.inventory.productions.store'), [
|
|
'save_mode' => 'complete',
|
|
'product_id' => $product->id,
|
|
'location_id' => $location->id,
|
|
'produced_at' => '2026-08-01',
|
|
'quantity' => 3,
|
|
])->assertSessionHasErrors('outputs');
|
|
});
|
|
|
|
test('F-01: entwurf reduziert weder chargen- noch standortbestand und blockiert keine andere produktion', function () {
|
|
$user = draftUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = draftIngredient();
|
|
$product = draftRecipeProduct($ing);
|
|
$stock = draftStock($ing, $location, $supplier, $user);
|
|
|
|
$draft = app(ProductionService::class)->store(
|
|
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
|
|
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10']],
|
|
$user->id,
|
|
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
|
|
true // draft
|
|
);
|
|
|
|
$productionService = app(ProductionService::class);
|
|
$inventory = app(InventoryService::class);
|
|
|
|
// Chargenverbrauch ohne Ausschluss der eigenen Produktion — wie ihn andere Produktionen/Ausschuss sehen
|
|
expect($productionService->consumedByStockEntry([$stock->id]))->toBe([])
|
|
->and((float) $productionService->availableStockEntriesForIngredient($ing->id, $location->id)->first()->getAttribute('remaining_quantity'))->toBe(1000.0)
|
|
->and((float) $inventory->chargesWithRemainingForIngredient($ing->id)->first()->getAttribute('remaining_quantity'))->toBe(1000.0)
|
|
->and((float) ($inventory->remainingByLocationForIngredient($ing->id)[$location->id] ?? 0.0))->toBe(1000.0);
|
|
|
|
// Eine zweite Produktion kann die komplette Charge trotz Entwurf buchen
|
|
$completed = $productionService->store(
|
|
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-02', 'quantity' => 100, 'notes' => null],
|
|
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '1000']],
|
|
$user->id,
|
|
[['product_id' => $product->id, 'quantity' => 100, 'batch_number' => null]]
|
|
);
|
|
|
|
expect($completed->isDraft())->toBeFalse()
|
|
->and($productionService->consumedByStockEntry([$stock->id])[$stock->id])->toBe(1000.0)
|
|
->and($productionService->availableStockEntriesForIngredient($ing->id, $location->id))->toHaveCount(0);
|
|
|
|
// Ausschuss mit Charge: Entwurf darf die zulässige Menge nicht verkleinern (vor Abschluss der zweiten Produktion wäre 1000 g erlaubt)
|
|
$completed->delete();
|
|
$this->actingAs($user, 'user')
|
|
->post(route('admin.inventory.stock-disposals.store'), [
|
|
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id,
|
|
'location_id' => $location->id, 'quantity' => '1000', 'disposed_at' => '2026-08-03', 'reason' => 'Test',
|
|
])
|
|
->assertSessionHasNoErrors();
|
|
});
|
|
|
|
test('F-03: entwurf sichert alle chargenzeilen roh, auch eine nicht verfügbare, und löst erst beim abschluss auf', function () {
|
|
$user = draftUser();
|
|
$location = Location::factory()->create();
|
|
$supplier = Supplier::factory()->create();
|
|
$ing = draftIngredient();
|
|
$product = draftRecipeProduct($ing);
|
|
$stock = draftStock($ing, $location, $supplier, $user);
|
|
$validKey = $stock->batchGroupKey();
|
|
$missingKey = 'b:'.$supplier->id.':GIBT-ES-NICHT';
|
|
|
|
Passport::actingAs($user);
|
|
$this->actingAs($user, 'user');
|
|
|
|
$head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1];
|
|
|
|
$this->post(route('admin.inventory.productions.store'), $head + [
|
|
'save_mode' => 'draft',
|
|
'ingredient_lines' => [
|
|
['ingredient_id' => $ing->id, 'batch_key' => $validKey, 'quantity_used' => '10'],
|
|
['ingredient_id' => $ing->id, 'batch_key' => $missingKey, 'quantity_used' => ''],
|
|
],
|
|
])->assertRedirect()->assertSessionHasNoErrors();
|
|
|
|
$draft = Production::query()->where('product_id', $product->id)->sole();
|
|
$lines = $draft->productionIngredients()->orderBy('id')->get();
|
|
expect($draft->isDraft())->toBeTrue()
|
|
->and($lines)->toHaveCount(2)
|
|
->and($lines[0]->stock_entry_id)->toBeNull()
|
|
->and($lines[0]->batch_key)->toBe($validKey)
|
|
->and((float) $lines[0]->quantity_used)->toBe(10.0)
|
|
->and($lines[1]->batch_key)->toBe($missingKey)
|
|
->and((float) $lines[1]->quantity_used)->toBe(0.0);
|
|
|
|
// Wiederöffnen: beide Chargen-Schlüssel werden ins Formular gegeben.
|
|
$this->get(route('admin.inventory.productions.edit', $draft))
|
|
->assertOk()
|
|
->assertSee($validKey)
|
|
->assertSee($missingKey);
|
|
|
|
// Abschluss mit der nicht verfügbaren Charge wird abgelehnt — nichts geht verloren.
|
|
$this->from(route('admin.inventory.productions.edit', $draft))
|
|
->put(route('admin.inventory.productions.update', $draft), $head + [
|
|
'save_mode' => 'complete',
|
|
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $missingKey, 'quantity_used' => '10']],
|
|
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
|
|
])->assertSessionHasErrors('ingredient_lines');
|
|
expect($draft->fresh()->productionIngredients()->count())->toBe(2);
|
|
|
|
// Abschluss mit der gültigen Charge löst auf den Wareneingang auf und bucht.
|
|
$this->put(route('admin.inventory.productions.update', $draft), $head + [
|
|
'save_mode' => 'complete',
|
|
'ingredient_lines' => [['ingredient_id' => $ing->id, 'batch_key' => $validKey, 'quantity_used' => '10']],
|
|
'outputs' => [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]],
|
|
])->assertSessionHasNoErrors();
|
|
|
|
$done = $draft->fresh();
|
|
expect($done->isDraft())->toBeFalse()
|
|
->and($done->productionIngredients()->count())->toBe(1)
|
|
->and($done->productionIngredients()->first()->stock_entry_id)->toBe($stock->id)
|
|
->and($done->productionIngredients()->first()->batch_key)->toBeNull()
|
|
->and((float) app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(990.0);
|
|
});
|
|
|
|
test('F-03: entwurf mit gewähltem ergebnisprodukt ohne stückzahl wird gespeichert', function () {
|
|
$user = draftUser();
|
|
$location = Location::factory()->create();
|
|
$product = Product::query()->create(['name' => 'Draft-Out0', 'title' => 'Draft-Out0', 'active' => true, 'no_recipe_required' => true]);
|
|
|
|
Passport::actingAs($user);
|
|
$this->actingAs($user, 'user');
|
|
|
|
$this->post(route('admin.inventory.productions.store'), [
|
|
'save_mode' => 'draft',
|
|
'product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 3,
|
|
'outputs' => [['product_id' => $product->id, 'quantity' => 0, 'batch_number' => 'VORLAEUFIG']],
|
|
])->assertRedirect()->assertSessionHasNoErrors();
|
|
|
|
$draft = Production::query()->where('product_id', $product->id)->sole();
|
|
expect($draft->outputs()->count())->toBe(1)
|
|
->and($draft->outputs()->first()->quantity)->toBe(0)
|
|
->and($draft->outputs()->first()->batch_number)->toBe('VORLAEUFIG')
|
|
->and(app(ProductStockService::class)->currentStock($product->id))->toBe(0);
|
|
});
|