Neue Anforderungen (docs/) interpretiert und als Entwicklungsplan V5.0 (AP-20 bis AP-28) aufgenommen; erste drei Pakete umgesetzt: AP-26 Ausschuss-Gründe konfigurierbar: - Stammdaten-Tabelle disposal_reasons + CRUD unter Einstellungen → Allgemein - StockDisposalController liest aktive DB-Gründe statt hartkodierter Liste - Seeder übernimmt die bisherigen 6 Gründe idempotent AP-25 Lieferbestand — Datum statt Tage: - "Nicht vorrätig" wird über Datepicker "Wieder lieferbar ab" gepflegt; Resttage-Hinweis zählt täglich automatisch herunter - Interne Bestellliste wieder kaufbar: Hinweis erscheint zusätzlich zu den Mengen-Buttons (VP entscheidet selbst) AP-22 Produktbestand-Erweiterungen: - Default-Sortierung nach Dringlichkeit, Status-Kopf toggelt - Alle vier Status-Kacheln als Filter klickbar - Neue Spalte "Verbrauch/Monat" (Ø Abgänge der letzten 6 Monate) - Produkt-Flag "Im Produktbestand anzeigen" (products.show_in_product_stock) Tests: 77 grün (DisposalReasonSettings 8, ProductOutOfStock 8, ProductStock 13 + Regression). Hinweise-Doku + Plan-Protokoll fortgeschrieben; nächster Schritt laut Plan: AP-21 (INCI-Erweiterungen). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
9.8 KiB
PHP
273 lines
9.8 KiB
PHP
<?php
|
|
|
|
use App\Models\Location;
|
|
use App\Models\Product;
|
|
use App\Models\ProductStockMovement;
|
|
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 ap11MakeUser(int $adminLevel = 7): User
|
|
{
|
|
$user = User::query()->create([
|
|
'email' => uniqid('ap11_', true).'@test.example',
|
|
'password' => bcrypt('password'),
|
|
]);
|
|
$user->forceFill([
|
|
'admin' => $adminLevel,
|
|
'confirmed' => true,
|
|
'active' => true,
|
|
'wizard' => 100,
|
|
'blocked' => false,
|
|
])->save();
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $overrides
|
|
*/
|
|
function ap11MakeProduct(string $name, array $overrides = []): Product
|
|
{
|
|
return Product::query()->create(array_merge([
|
|
'name' => $name,
|
|
'title' => $name,
|
|
'active' => true,
|
|
'is_set' => false,
|
|
], $overrides));
|
|
}
|
|
|
|
test('bestand ist summe eingaenge minus ausgaenge', function () {
|
|
$product = ap11MakeProduct('AP11-Bestand');
|
|
$service = app(ProductStockService::class);
|
|
|
|
$service->recordMovement($product, 'in', 100, 'Initialbestand');
|
|
$service->recordMovement($product, 'out', 30, 'Verkauf');
|
|
$service->recordMovement($product, 'in', 5, 'Retoure');
|
|
|
|
expect($service->currentStock($product->id))->toBe(75);
|
|
});
|
|
|
|
test('status liefert kritisch, warnung und ok', function () {
|
|
$service = app(ProductStockService::class);
|
|
|
|
expect($service->productStatus(5, 20, 10))->toBe('critical')
|
|
->and($service->productStatus(15, 20, 10))->toBe('warning')
|
|
->and($service->productStatus(30, 20, 10))->toBe('ok')
|
|
->and($service->productStatus(0, null, null))->toBe('ok');
|
|
});
|
|
|
|
test('kritisch-zaehler beruecksichtigt nur hauptprodukte mit schwellwert', function () {
|
|
$service = app(ProductStockService::class);
|
|
|
|
$critical = ap11MakeProduct('AP11-Krit', ['critical_product_stock' => 10]);
|
|
$service->recordMovement($critical, 'in', 5, 'Initialbestand');
|
|
|
|
$ok = ap11MakeProduct('AP11-OK', ['critical_product_stock' => 10]);
|
|
$ok->stockMovements()->create(['direction' => 'in', 'quantity' => 50, 'reason' => 'Initialbestand', 'source' => 'manual']);
|
|
|
|
// ohne Schwellwert => nie kritisch
|
|
ap11MakeProduct('AP11-NoThreshold');
|
|
|
|
// Set wird ignoriert
|
|
ap11MakeProduct('AP11-Set', ['is_set' => true, 'critical_product_stock' => 10]);
|
|
|
|
expect($service->criticalProductCount())->toBe(1);
|
|
});
|
|
|
|
test('produktion bucht produktbestand als eingang', function () {
|
|
$user = ap11MakeUser();
|
|
$location = Location::factory()->create();
|
|
$product = ap11MakeProduct('AP11-Produktion', ['no_recipe_required' => true]);
|
|
|
|
app(ProductionService::class)->store([
|
|
'product_id' => $product->id,
|
|
'location_id' => $location->id,
|
|
'produced_at' => '2026-03-01',
|
|
'quantity' => 40,
|
|
'notes' => null,
|
|
], [], $user->id);
|
|
|
|
expect(app(ProductStockService::class)->currentStock($product->id))->toBe(40);
|
|
|
|
$movement = ProductStockMovement::query()->where('product_id', $product->id)->where('source', 'production')->first();
|
|
expect($movement)->not->toBeNull()
|
|
->and($movement->direction)->toBe('in')
|
|
->and((int) $movement->quantity)->toBe(40);
|
|
});
|
|
|
|
test('produktions-update korrigiert den produktbestand per differenz', function () {
|
|
$user = ap11MakeUser();
|
|
$location = Location::factory()->create();
|
|
$product = ap11MakeProduct('AP11-ProdUpdate', ['no_recipe_required' => true]);
|
|
|
|
$production = app(ProductionService::class)->store([
|
|
'product_id' => $product->id,
|
|
'location_id' => $location->id,
|
|
'produced_at' => '2026-03-01',
|
|
'quantity' => 40,
|
|
'notes' => null,
|
|
], [], $user->id);
|
|
|
|
app(ProductionService::class)->updateProduction($production, [
|
|
'product_id' => $product->id,
|
|
'location_id' => $location->id,
|
|
'produced_at' => '2026-03-01',
|
|
'quantity' => 25,
|
|
'notes' => null,
|
|
], [], $user->id);
|
|
|
|
expect(app(ProductStockService::class)->currentStock($product->id))->toBe(25);
|
|
// append-only: zwei Bewegungen (Produktion + Korrektur)
|
|
expect(ProductStockMovement::query()->where('product_id', $product->id)->count())->toBe(2);
|
|
});
|
|
|
|
test('uebersicht zeigt produkt und bestand', function () {
|
|
$user = ap11MakeUser(1);
|
|
$product = ap11MakeProduct('AP11-Liste', ['critical_product_stock' => 10]);
|
|
app(ProductStockService::class)->recordMovement($product, 'in', 13, 'Initialbestand');
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->get(route('admin.inventory.product-stock.index'));
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertSee('Produktbestand');
|
|
$response->assertSee('AP11-Liste');
|
|
$response->assertSee('nur kritische anzeigen');
|
|
});
|
|
|
|
test('manuelle bewegung wird per http gebucht', function () {
|
|
$user = ap11MakeUser(7);
|
|
$product = ap11MakeProduct('AP11-HTTP');
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->post(route('admin.inventory.product-stock.movement', $product), [
|
|
'direction' => 'in',
|
|
'quantity' => 12,
|
|
'reason' => 'Initialbestand',
|
|
'note' => 'Erstbefüllung',
|
|
]);
|
|
|
|
$response->assertRedirect();
|
|
$this->assertDatabaseHas('product_stock_movements', [
|
|
'product_id' => $product->id,
|
|
'direction' => 'in',
|
|
'quantity' => 12,
|
|
'reason' => 'Initialbestand',
|
|
]);
|
|
});
|
|
|
|
test('nicht-admin darf keine bewegung buchen', function () {
|
|
$user = ap11MakeUser(1);
|
|
$product = ap11MakeProduct('AP11-NoAdmin');
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->post(route('admin.inventory.product-stock.movement', $product), [
|
|
'direction' => 'in',
|
|
'quantity' => 5,
|
|
'reason' => 'Initialbestand',
|
|
]);
|
|
|
|
$response->assertForbidden();
|
|
});
|
|
|
|
test('historie rendert und filtert nach richtung', function () {
|
|
$user = ap11MakeUser(1);
|
|
$product = ap11MakeProduct('AP11-Hist');
|
|
$service = app(ProductStockService::class);
|
|
$service->recordMovement($product, 'in', 50, 'Produktion', 'production');
|
|
$service->recordMovement($product, 'out', 2, 'Verkauf', 'sale');
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->get(route('admin.inventory.product-stock.history', ['direction' => 'out']));
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertSee('Produktbestand Historie');
|
|
$response->assertSee('Verkauf');
|
|
});
|
|
|
|
// --- AP-22: Produktbestand-Erweiterungen ---
|
|
|
|
test('flag blendet produkt aus uebersicht und kritisch-zaehler aus', function () {
|
|
$user = ap11MakeUser(1);
|
|
$service = app(ProductStockService::class);
|
|
|
|
$hidden = ap11MakeProduct('AP22-Versteckt', ['show_in_product_stock' => false, 'critical_product_stock' => 10]);
|
|
$service->recordMovement($hidden, 'in', 2, 'Initialbestand');
|
|
|
|
$visible = ap11MakeProduct('AP22-Sichtbar', ['critical_product_stock' => 10]);
|
|
$service->recordMovement($visible, 'in', 2, 'Initialbestand');
|
|
|
|
expect($service->criticalProductCount())->toBe(1);
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->get(route('admin.inventory.product-stock.index'));
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertSee('AP22-Sichtbar');
|
|
$response->assertDontSee('AP22-Versteckt');
|
|
});
|
|
|
|
test('verbrauch pro monat mittelt abgaenge der letzten 6 monate', function () {
|
|
$product = ap11MakeProduct('AP22-Verbrauch');
|
|
$service = app(ProductStockService::class);
|
|
|
|
// 60 Stück Abgang innerhalb des Fensters => Ø 10/Monat
|
|
$service->recordMovement($product, 'out', 24, 'Verkauf', 'sale');
|
|
$m = $service->recordMovement($product, 'out', 36, 'Verkauf', 'sale');
|
|
$m->forceFill(['created_at' => now()->subMonths(3)])->save();
|
|
|
|
// außerhalb des Fensters: zählt nicht
|
|
$old = $service->recordMovement($product, 'out', 600, 'Verkauf', 'sale');
|
|
$old->forceFill(['created_at' => now()->subMonths(7)])->save();
|
|
|
|
// Produktions-Gegenbuchung (Korrektur): zählt nicht als Verbrauch
|
|
$service->recordMovement($product, 'out', 100, 'Produktionskorrektur', 'production');
|
|
|
|
// Eingänge zählen nie
|
|
$service->recordMovement($product, 'in', 500, 'Initialbestand');
|
|
|
|
$result = $service->monthlyConsumptionByProduct([$product->id]);
|
|
|
|
expect($result[$product->id])->toBe(10.0);
|
|
});
|
|
|
|
test('uebersicht sortiert default nach dringlichkeit', function () {
|
|
$user = ap11MakeUser(1);
|
|
$service = app(ProductStockService::class);
|
|
|
|
$ok = ap11MakeProduct('AP22-Zustand-OK', ['critical_product_stock' => 10]);
|
|
$service->recordMovement($ok, 'in', 100, 'Initialbestand');
|
|
|
|
$critical = ap11MakeProduct('AP22-Zustand-Kritisch', ['critical_product_stock' => 10]);
|
|
$service->recordMovement($critical, 'in', 2, 'Initialbestand');
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->get(route('admin.inventory.product-stock.index'));
|
|
|
|
$response->assertSuccessful();
|
|
// Kritisches Produkt erscheint vor dem OK-Produkt (Default-Sortierung nach Dringlichkeit)
|
|
$response->assertSeeInOrder(['AP22-Zustand-Kritisch', 'AP22-Zustand-OK']);
|
|
});
|
|
|
|
test('uebersicht zeigt verbrauch-spalte und alle kacheln klickbar', function () {
|
|
$user = ap11MakeUser(1);
|
|
ap11MakeProduct('AP22-Render');
|
|
|
|
$this->actingAs($user, 'user');
|
|
$response = $this->get(route('admin.inventory.product-stock.index'));
|
|
|
|
$response->assertSuccessful();
|
|
$response->assertSee('Verbrauch/Monat');
|
|
$response->assertSee('data-filter="all"', false);
|
|
$response->assertSee('data-filter="ok"', false);
|
|
$response->assertSee('data-filter="warning"', false);
|
|
$response->assertSee('data-filter="critical"', false);
|
|
// alle vier Kacheln tragen is-clickable
|
|
expect(substr_count($response->getContent(), 'wawi-stat is-clickable')
|
|
+ substr_count($response->getContent(), 'is-clickable" data-filter'))->toBeGreaterThanOrEqual(1);
|
|
});
|