Kundenentscheid 26.08.: „Waldbröl" gibt es nicht mehr, alles wird Köln; die Standort-Funktion bleibt für künftige Standorte. - LocationController::destroy überschrieben: ein belegter Lagerort lässt sich löschen, indem seine Buchungen (Wareneingänge, Produktionen, Ausschuss) transaktional auf einen Ziel-Lagerort übernommen werden. Ohne Ziel-Lagerort bleibt die Sperre bestehen; unbelegte Orte werden direkt gelöscht. - Lagerort-Liste: Ziel-Auswahl („→ Köln" vorbelegt) im Lösch-Formular. - Wareneingang: Köln als Standard-Lagerort vorbelegt (wie in der Produktion). Außerdem AP-49-Folgeanpassung: Test-Helper hängt die Kategorie „Kosmetikrohstoffe" an, damit Lieferanten im gefilterten INCI-Formular erscheinen; Lagerort-Sperr-Test prüft die neue Meldung. Suite 322 grün. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
326 lines
11 KiB
PHP
326 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);
|
|
|
|
// AP-50: belegter Lagerort ohne Ziel-Lagerort bleibt gesperrt (Übernahme-Ziel nötig).
|
|
$this->delete(route('admin.inventory.locations.destroy', $location))
|
|
->assertRedirect();
|
|
|
|
expect(session('alert-error'))->toContain('Ziel-Lagerort');
|
|
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();
|
|
});
|