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>
This commit is contained in:
parent
d8d4a9be55
commit
35b9076cbc
32 changed files with 844 additions and 41 deletions
325
tests/Feature/InventoryDeletionTest.php
Normal file
325
tests/Feature/InventoryDeletionTest.php
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
<?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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue