gruene-seele/tests/Feature/ProductionDraftTest.php
Kevin Adametz abd202a02e Warenwirtschaft: AP-43 — Produktion zwischenspeichern (Entwurf) + „abschließen"
Der Speicher-Button heißt jetzt „Produktion abschließen"; daneben gibt es
„Zwischenspeichern (Entwurf)". Ein Entwurf sichert den Stand ohne Pflichtfeld-
und ohne Bestandsprüfung und bucht KEINEN Bestand (Roh-/Produkt-/Verpackung).
Erst „abschließen" prüft vollständig und bucht.

- Migration: productions.status (default 'completed'; Bestand ist bereits gebucht).
- Production-Model: STATUS_DRAFT/COMPLETED, isDraft(), scopeBooked().
- InventoryService: Entwürfe fallen aus allen Verbrauchs-/Bestandsjoins heraus
  (status != draft an den 4 productions-Joins).
- ProductionService store()/updateProduction() bekommen $draft: Entwurf
  überspringt Rezeptur-/Mengen-/Bestandsprüfung + Buchungen, sichert nur
  vollständige Chargen-Zeilen (filterCompleteLines) und die angegebenen Outputs.
  Ein abgeschlossene Produktion kann NICHT zurück in den Entwurf (sonst
  verwaisen die Buchungen) — Guard wirft ValidationException.
- StoreProductionRequest: save_mode=draft => nur Kopfdaten Pflicht, Chargen/
  Ergebnis optional; validatedPayload filtert unvollständige Zeilen/Outputs.
- Controller: Entwurf löst Chargen tolerant auf (nicht auflösbare Chargen werden
  übersprungen, Entwurf ist immer speicherbar) und leitet zur Bearbeitung zurück.
- Views: create/edit „abschließen" + „Zwischenspeichern"-Button (nur solange
  Entwurf); Entwurf-Badge in Liste + Bearbeiten-Kopf.

Tests: 5 neue (kein Produktbestand, kein Rohstoffverbrauch, Abschluss bucht,
Rück-Entwurf verboten, HTTP-Entwurf ohne Ergebnis ok / Abschluss erzwingt
Ergebnis). Migration auf Dev-/Live-DB. Suite 336 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 09:59:42 +00:00

200 lines
8.2 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: 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');
});