Warenwirtschaft: Anforderungsrunde 12.06. — Plan V5.0 + AP-26/AP-25/AP-22
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>
This commit is contained in:
parent
a8f6fef38e
commit
e53201f229
32 changed files with 1377 additions and 94 deletions
116
tests/Feature/DisposalReasonSettingsTest.php
Normal file
116
tests/Feature/DisposalReasonSettingsTest.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
use App\Models\DisposalReason;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function disposalReasonUser(int $adminLevel = 8): User
|
||||
{
|
||||
$user = User::query()->create([
|
||||
'email' => uniqid('dr_', true).'@test.example',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'admin' => $adminLevel,
|
||||
'confirmed' => true,
|
||||
'active' => true,
|
||||
'wizard' => 100,
|
||||
'blocked' => false,
|
||||
])->save();
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
test('allgemein-seite zeigt ausschuss-gruende', function () {
|
||||
$this->actingAs(disposalReasonUser(8), 'user');
|
||||
|
||||
DisposalReason::create(['label' => 'Bruch / Beschädigung', 'active' => true, 'pos' => 0]);
|
||||
|
||||
$response = $this->get(route('admin.inventory.general'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Ausschuss-Gründe', false);
|
||||
$response->assertSee('Bruch / Beschädigung', false);
|
||||
$response->assertSee(route('admin.inventory.disposal-reasons.create'), false);
|
||||
});
|
||||
|
||||
test('superadmin legt ausschuss-grund an', function () {
|
||||
$this->actingAs(disposalReasonUser(8), 'user');
|
||||
|
||||
$this->get(route('admin.inventory.disposal-reasons.create'))->assertSuccessful();
|
||||
|
||||
$this->post(route('admin.inventory.disposal-reasons.store'), [
|
||||
'label' => 'Fehlproduktion',
|
||||
'active' => '1',
|
||||
'pos' => 0,
|
||||
])->assertRedirect(route('admin.inventory.general'));
|
||||
|
||||
$reason = DisposalReason::query()->where('label', 'Fehlproduktion')->firstOrFail();
|
||||
expect($reason->active)->toBeTrue();
|
||||
});
|
||||
|
||||
test('ausschuss-grund kann bearbeitet und deaktiviert werden', function () {
|
||||
$this->actingAs(disposalReasonUser(8), 'user');
|
||||
|
||||
$reason = DisposalReason::create(['label' => 'Qualitätsmangel', 'active' => true, 'pos' => 1]);
|
||||
|
||||
$this->get(route('admin.inventory.disposal-reasons.edit', $reason))->assertSuccessful();
|
||||
|
||||
$this->put(route('admin.inventory.disposal-reasons.update', $reason), [
|
||||
'label' => 'Qualitätsmangel (Rohstoff)',
|
||||
'pos' => 1,
|
||||
])->assertRedirect(route('admin.inventory.general'));
|
||||
|
||||
$reason->refresh();
|
||||
expect($reason->label)->toBe('Qualitätsmangel (Rohstoff)');
|
||||
expect($reason->active)->toBeFalse();
|
||||
});
|
||||
|
||||
test('ausschuss-grund kann geloescht werden', function () {
|
||||
$this->actingAs(disposalReasonUser(8), 'user');
|
||||
|
||||
$reason = DisposalReason::create(['label' => 'Testgrund', 'active' => true, 'pos' => 2]);
|
||||
|
||||
$this->delete(route('admin.inventory.disposal-reasons.destroy', $reason))
|
||||
->assertRedirect(route('admin.inventory.general'));
|
||||
|
||||
expect(DisposalReason::query()->whereKey($reason->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('bezeichnung ist pflicht', function () {
|
||||
$this->actingAs(disposalReasonUser(8), 'user');
|
||||
|
||||
$this->post(route('admin.inventory.disposal-reasons.store'), [
|
||||
'label' => '',
|
||||
])->assertSessionHasErrors('label');
|
||||
});
|
||||
|
||||
test('nur aktive gruende ueber active-scope', function () {
|
||||
DisposalReason::create(['label' => 'Aktiv', 'active' => true, 'pos' => 0]);
|
||||
DisposalReason::create(['label' => 'Inaktiv', 'active' => false, 'pos' => 1]);
|
||||
|
||||
expect(DisposalReason::query()->active()->pluck('label')->all())->toBe(['Aktiv']);
|
||||
});
|
||||
|
||||
test('ausschuss-formular zeigt nur aktive gruende in sortierung', function () {
|
||||
$this->actingAs(disposalReasonUser(7), 'user');
|
||||
|
||||
DisposalReason::create(['label' => 'Zweiter Grund', 'active' => true, 'pos' => 1]);
|
||||
DisposalReason::create(['label' => 'Erster Grund', 'active' => true, 'pos' => 0]);
|
||||
DisposalReason::create(['label' => 'Deaktivierter Grund', 'active' => false, 'pos' => 2]);
|
||||
|
||||
$response = $this->get(route('admin.inventory.stock-disposals.create'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSeeInOrder(['Erster Grund', 'Zweiter Grund'], false);
|
||||
$response->assertDontSee('Deaktivierter Grund', false);
|
||||
});
|
||||
|
||||
test('nicht-superadmin hat keinen zugriff auf grund-anlage', function () {
|
||||
$this->actingAs(disposalReasonUser(7), 'user');
|
||||
|
||||
$this->get(route('admin.inventory.disposal-reasons.create'))->assertRedirect('/home');
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\ActiveAccount;
|
||||
use App\Models\Product;
|
||||
use App\Repositories\ProductRepository;
|
||||
use App\User;
|
||||
|
|
@ -34,8 +35,8 @@ function oosMakeProduct(string $name): Product
|
|||
]);
|
||||
}
|
||||
|
||||
test('repository berechnet out_of_stock_until aus tagesangabe', function () {
|
||||
$product = oosMakeProduct('OOS-Tage');
|
||||
test('repository speichert out_of_stock_until aus datumsangabe', function () {
|
||||
$product = oosMakeProduct('OOS-Datum');
|
||||
|
||||
$repo = new ProductRepository($product);
|
||||
$repo->update([
|
||||
|
|
@ -44,7 +45,7 @@ test('repository berechnet out_of_stock_until aus tagesangabe', function () {
|
|||
'title' => $product->title,
|
||||
'active' => '1',
|
||||
'out_of_stock_active' => '1',
|
||||
'out_of_stock_days' => '14',
|
||||
'out_of_stock_date' => now()->addDays(14)->format('d.m.Y'),
|
||||
]);
|
||||
|
||||
$product->refresh();
|
||||
|
|
@ -65,7 +66,7 @@ test('unbestimmt hat vorrang und nullt das datum', function () {
|
|||
'title' => $product->title,
|
||||
'active' => '1',
|
||||
'out_of_stock_active' => '1',
|
||||
'out_of_stock_days' => '14',
|
||||
'out_of_stock_date' => now()->addDays(14)->format('d.m.Y'),
|
||||
'out_of_stock_indefinite' => '1',
|
||||
]);
|
||||
|
||||
|
|
@ -96,6 +97,25 @@ test('ohne aktivierung werden die felder geleert', function () {
|
|||
expect($product->isOutOfStock())->toBeFalse();
|
||||
});
|
||||
|
||||
test('ungueltiges datum leert das feld statt zu crashen', function () {
|
||||
$product = oosMakeProduct('OOS-Invalid');
|
||||
|
||||
$repo = new ProductRepository($product);
|
||||
$repo->update([
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'title' => $product->title,
|
||||
'active' => '1',
|
||||
'out_of_stock_active' => '1',
|
||||
'out_of_stock_date' => 'kein-datum',
|
||||
]);
|
||||
|
||||
$product->refresh();
|
||||
|
||||
expect($product->out_of_stock_until)->toBeNull();
|
||||
expect($product->isOutOfStock())->toBeFalse();
|
||||
});
|
||||
|
||||
test('vergangenes datum gilt nicht als nicht vorrätig', function () {
|
||||
$product = oosMakeProduct('OOS-Vergangen');
|
||||
$product->update(['out_of_stock_until' => now()->subDays(2)]);
|
||||
|
|
@ -106,7 +126,7 @@ test('vergangenes datum gilt nicht als nicht vorrätig', function () {
|
|||
expect($product->outOfStockNotice())->toBeNull();
|
||||
});
|
||||
|
||||
test('hinweis zeigt verbleibende tage', function () {
|
||||
test('hinweis zeigt verbleibende tage und zaehlt taeglich herunter', function () {
|
||||
$product = oosMakeProduct('OOS-Hinweis');
|
||||
$product->update(['out_of_stock_until' => now()->addDays(5)]);
|
||||
$product->refresh();
|
||||
|
|
@ -114,16 +134,20 @@ test('hinweis zeigt verbleibende tage', function () {
|
|||
expect($product->isOutOfStock())->toBeTrue();
|
||||
expect($product->outOfStockRemainingDays())->toBe(5);
|
||||
expect($product->outOfStockNotice())->toBe('In ca. 5 Tagen wieder da!');
|
||||
|
||||
$this->travel(2)->days();
|
||||
expect($product->outOfStockRemainingDays())->toBe(3);
|
||||
$this->travelBack();
|
||||
});
|
||||
|
||||
test('http-store speichert nicht-vorrätig mit tagen', function () {
|
||||
test('http-store speichert nicht-vorrätig mit datum', function () {
|
||||
$this->actingAs(oosMakeUser(1), 'user');
|
||||
|
||||
$response = $this->post(route('admin_product_store'), [
|
||||
'id' => 'new',
|
||||
'name' => 'OOS-HTTP',
|
||||
'out_of_stock_active' => '1',
|
||||
'out_of_stock_days' => '7',
|
||||
'out_of_stock_date' => now()->addDays(7)->format('d.m.Y'),
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
|
@ -133,3 +157,25 @@ test('http-store speichert nicht-vorrätig mit tagen', function () {
|
|||
expect($product->out_of_stock_until->isSameDay(now()->addDays(7)))->toBeTrue();
|
||||
expect($product->isOutOfStock())->toBeTrue();
|
||||
});
|
||||
|
||||
test('interne bestellliste zeigt mengen-buttons trotz nicht-vorrätig', function () {
|
||||
// active.account prüft `payment_account` (Spalte existiert nur im Live-Schema, nicht in den Migrationen)
|
||||
$this->withoutMiddleware(ActiveAccount::class);
|
||||
$this->actingAs(oosMakeUser(1), 'user');
|
||||
|
||||
$product = oosMakeProduct('OOS-Bestellliste');
|
||||
$product->update([
|
||||
'out_of_stock_until' => now()->addDays(12),
|
||||
'show_on' => ['2'],
|
||||
]);
|
||||
|
||||
$response = $this->getJson(route('user_order_my_datatable', ['shipping_is_for' => 'me', 'draw' => 1, 'start' => 0, 'length' => 50]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$row = collect($response->json('data'))->first(fn ($r) => str_contains($r['product'] ?? '', 'OOS-Bestellliste'));
|
||||
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row['product'])->toContain('product-stock-hint');
|
||||
expect($row['product'])->toContain('wieder da');
|
||||
expect($row['product'])->toContain('add-product-basket');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -189,3 +189,85 @@ test('historie rendert und filtert nach richtung', function () {
|
|||
$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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue