gruene-seele/tests/Feature/InventoryDeletionTest.php
Kevin Adametz 35b9076cbc Warenwirtschaft: AP-31 Löschen mit Soft-Delete + Abhängigkeitsprüfung
Anforderung 15.07.2026: Nahezu alle WaWi-Einträge sind jetzt löschbar
(Soft-Delete, deleted_at), mit Abhängigkeitsprüfung und Bestätigungs-
warnung vor jeder Löschung.

- Migration: deleted_at auf 14 WaWi-Tabellen (Stammdaten, ingredients,
  stock_entries, stock_disposals, productions); SoftDeletes-Trait in
  den Models (suppliers/packaging_items hatten es bereits).
- Neuer App\Services\DeletionGuard: zentrale Abhängigkeitsprüfung je
  Entität — referenzierte Einträge sind nicht löschbar, Fehlermeldung
  nennt die Verwendungen (behebt auch Audit M-4: kein 500er/stiller
  Cascade-Verlust mehr beim Löschen verwendeter Stammdaten).
- SettingCrudController::destroy() prüft Blocker; Supplier-,
  PackagingItem-, Ingredient-destroy ebenso (Ingredient: vollständige
  Prüfung inkl. Produktion/Ausschuss, Route GET→DELETE, Audit M-6).
- Wareneingänge: auch gebuchte Eingänge löschbar, sofern kein
  Produktions-Verbrauch/Ausschuss darauf gebucht ist.
- Ausschuss: löschbar (Menge steht dem Bestand wieder zur Verfügung).
- Produktionen: löschbar (admin >= 7); Produktbestand wird per
  Gegenbuchung zurückgeführt (reverseProductionStock, append-only,
  withTrashed), Rohstoffverbrauch zählt nicht mehr (Bestandsqueries
  schließen gelöschte Produktionen aus).
- lastNetPricePerSupplier: DB::query statt Model-Query (Global Scope
  auf Sub-Select-Alias).
- Debug-Log-Reste im PackagingItemController entfernt (Audit N-16).
- Views: Lösch-Buttons mit deutschen Bestätigungswarnungen (Ausschuss,
  Produktionen, gebuchte Wareneingänge, INCI als DELETE-Formular).

Tests: neuer InventoryDeletionTest (10 Tests), StorageSettingsTest an
neue Löschregel angepasst. Volle Suite: 214 grün, 783 Assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 12:31:11 +00:00

325 lines
11 KiB
PHP

<?php
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\StockDisposal;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Models\TaxRate;
use App\Services\InventoryService;
use App\Services\ProductionService;
use App\Services\ProductStockService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function delUser(int $adminLevel = 7): User
{
$user = User::query()->create([
'email' => uniqid('del_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function delIngredient(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 delStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
{
return StockEntry::query()->create(array_merge([
'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' => 1000,
'status' => 'received',
'received_by' => $user->id,
'received_at' => '2026-01-15',
'received_quantity' => 1000,
'batch_number' => 'DEL-B1',
'best_before' => '2030-12-31',
], $overrides));
}
function delSimpleProduct(string $name): Product
{
return Product::query()->create([
'name' => $name,
'title' => $name,
'active' => true,
'no_recipe_required' => true,
]);
}
// --- Stammdaten (SettingCrudController) ---
test('verwendeter steuersatz kann nicht geloescht werden', function () {
$this->actingAs(delUser(8), 'user');
$taxRate = TaxRate::factory()->create();
$user = delUser();
$ing = delIngredient('Del-Tax-INCI');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
delStock($ing, $location, $supplier, $user, ['tax_rate_id' => $taxRate->id]);
$this->delete(route('admin.inventory.tax-rates.destroy', $taxRate));
expect(session('alert-error'))->toContain('Löschen nicht möglich');
expect(TaxRate::query()->find($taxRate->id))->not->toBeNull();
});
test('unbenutzter steuersatz wird soft-geloescht', function () {
$this->actingAs(delUser(8), 'user');
$taxRate = TaxRate::factory()->create();
$this->delete(route('admin.inventory.tax-rates.destroy', $taxRate));
expect(TaxRate::query()->find($taxRate->id))->toBeNull();
expect(TaxRate::withTrashed()->find($taxRate->id))->not->toBeNull();
});
test('lagerort mit wareneingang kann nicht geloescht werden (kein 500)', function () {
$this->actingAs(delUser(8), 'user');
$user = delUser();
$ing = delIngredient('Del-Loc-INCI');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
delStock($ing, $location, $supplier, $user);
$this->delete(route('admin.inventory.locations.destroy', $location))
->assertRedirect();
expect(session('alert-error'))->toContain('Löschen nicht möglich');
expect(Location::query()->find($location->id))->not->toBeNull();
});
test('lieferant mit wareneingang kann nicht geloescht werden', function () {
$this->actingAs(delUser(7), 'user');
$user = delUser();
$ing = delIngredient('Del-Sup-INCI');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
delStock($ing, $location, $supplier, $user);
$this->delete(route('admin.inventory.suppliers.destroy', $supplier));
expect(session('alert-error'))->toContain('Löschen nicht möglich');
expect(Supplier::query()->find($supplier->id))->not->toBeNull();
});
// --- Wareneingänge ---
test('gebuchter wareneingang ohne abhaengigkeiten wird geloescht und bestand sinkt', function () {
$admin = delUser(7);
$this->actingAs($admin, 'user');
$ing = delIngredient('Del-SE-INCI');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$entry = delStock($ing, $location, $supplier, $admin);
expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0)->toBe(1000.0);
$this->delete(route('admin.inventory.stock-entries.destroy', $entry))
->assertRedirect(route('admin.inventory.stock-entries.index'));
expect(StockEntry::query()->find($entry->id))->toBeNull();
expect(StockEntry::withTrashed()->find($entry->id))->not->toBeNull();
expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id] ?? 0.0)->toBe(0.0);
});
test('wareneingang mit produktions-verbrauch kann nicht geloescht werden', function () {
$admin = delUser(7);
$this->actingAs($admin, 'user');
$ing = delIngredient('Del-SE-Blocked');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$entry = delStock($ing, $location, $supplier, $admin);
$product = Product::query()->create(['name' => 'Del-Prod', 'title' => 'Del-Prod', 'active' => true]);
ProductIngredient::create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1,
'recipe_type' => 'manufacturer',
]);
app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-07-01',
'quantity' => 10,
'notes' => null,
],
[['ingredient_id' => $ing->id, 'stock_entry_id' => $entry->id, 'quantity_used' => 100]],
$admin->id,
);
$this->delete(route('admin.inventory.stock-entries.destroy', $entry));
expect(session('alert-error'))->toContain('Löschen nicht möglich');
expect(StockEntry::query()->find($entry->id))->not->toBeNull();
});
// --- Ausschuss ---
test('geloeschter ausschuss gibt bestand wieder frei', function () {
$admin = delUser(7);
$this->actingAs($admin, 'user');
$ing = delIngredient('Del-Disposal');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
delStock($ing, $location, $supplier, $admin);
$disposal = StockDisposal::query()->create([
'disposal_type' => 'ingredient',
'ingredient_id' => $ing->id,
'location_id' => $location->id,
'quantity' => 400,
'unit' => 'gram',
'reason' => 'MHD abgelaufen',
'disposed_at' => '2026-07-01',
'user_id' => $admin->id,
]);
expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(600.0);
$this->delete(route('admin.inventory.stock-disposals.destroy', $disposal))
->assertRedirect(route('admin.inventory.stock-disposals.index'));
expect(StockDisposal::query()->find($disposal->id))->toBeNull();
expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(1000.0);
});
// --- Produktionen ---
test('geloeschte produktion bucht produktbestand zurueck und gibt rohstoffe frei', function () {
$admin = delUser(7);
$this->actingAs($admin, 'user');
$ing = delIngredient('Del-Prod-INCI');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$entry = delStock($ing, $location, $supplier, $admin);
$product = Product::query()->create(['name' => 'Del-Prod2', 'title' => 'Del-Prod2', 'active' => true]);
ProductIngredient::create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1,
'recipe_type' => 'manufacturer',
]);
$production = app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-07-01',
'quantity' => 10,
'notes' => null,
],
[['ingredient_id' => $ing->id, 'stock_entry_id' => $entry->id, 'quantity_used' => 100]],
$admin->id,
);
expect(app(ProductStockService::class)->currentStock($product->id))->toBe(10);
expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(900.0);
$this->delete(route('admin.inventory.productions.destroy', $production))
->assertRedirect(route('admin.inventory.productions.index'));
// Produktion soft-gelöscht, Bestand zurückgebucht (append-only), Rohstoff frei.
expect(Production::query()->find($production->id))->toBeNull();
expect(Production::withTrashed()->find($production->id))->not->toBeNull();
expect(app(ProductStockService::class)->currentStock($product->id))->toBe(0);
expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(1000.0);
expect(ProductStockMovement::query()->where('product_id', $product->id)->count())->toBe(2);
});
test('nicht-admin darf produktion nicht loeschen', function () {
$admin = delUser(7);
$location = Location::factory()->create();
$product = delSimpleProduct('Del-Guard');
$production = app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-07-01',
'quantity' => 5,
'notes' => null,
],
[],
$admin->id,
);
$this->actingAs(delUser(1), 'user');
$this->delete(route('admin.inventory.productions.destroy', $production))
->assertRedirect(route('home'));
expect(Production::query()->find($production->id))->not->toBeNull();
});
// --- INCI ---
test('inci mit wareneingang kann nicht geloescht werden, sauberer inci schon', function () {
$admin = delUser(7);
$this->actingAs($admin, 'user');
$used = delIngredient('Del-INCI-used');
$clean = delIngredient('Del-INCI-clean');
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
delStock($used, $location, $supplier, $admin);
$this->delete(route('admin_product_ingredient_delete', $used->id));
expect(session('alert-error'))->toContain('Löschen nicht möglich');
expect(Ingredient::query()->find($used->id))->not->toBeNull();
$this->delete(route('admin_product_ingredient_delete', $clean->id));
expect(Ingredient::query()->find($clean->id))->toBeNull();
expect(Ingredient::withTrashed()->find($clean->id))->not->toBeNull();
});