gruene-seele/tests/Feature/StockEntryCorrectionTest.php
Kevin Adametz 736cb087ae Warenwirtschaft: Einkäufe auch nach Buchung korrigierbar, mit Änderungsprotokoll
Bisher ließen sich nur offene Bestellungen bearbeiten; ein Tippfehler in
einem gebuchten Wareneingang war nicht korrigierbar (Anforderung 14.09.2026).

- Bearbeiten/Korrigieren für gebuchte Eingänge (Admin): Bestell- und
  Eingangsdaten (Menge, Datum, Charge, MHD, Qualität, Preise, Lieferant,
  Lagerort). Menge wirkt sofort auf den Bestand; ml-Eingänge werden in ml
  korrigiert und über die gespeicherte Dichte in Gramm gebucht.
- Schutz: Menge nicht unter den bereits verbrauchten Anteil (Rohstoff je
  Eingang, Verpackung je Artikel); bei Verbrauch sind Art und Rohstoff/
  Artikel gesperrt (Formular + Server).
- Änderungsprotokoll (stock_entry_changes): wer/wann/Grund + geänderte
  Felder alt → neu; Anzeige auf der Detailseite. Grund bei gebuchten
  Eingängen Pflicht, bei offenen optional. Speichern ohne Änderung
  erzeugt keine Zeile.
- Tests (6 neue), In-App-Doku (Hinweise + Leitfaden 3.2) nachgezogen.

Migration: 2026_09_14_110000_create_stock_entry_changes_table.
Suite: 396 grün.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 14:51:27 +00:00

202 lines
11 KiB
PHP

<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\MaterialQuality;
use App\Models\PackagingItem;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use App\Models\StockEntryChange;
use App\Models\Supplier;
use App\Services\InventoryService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Anforderung 14.09.2026: Einkäufe/Wareneingänge nachträglich korrigierbar —
* auch gebuchte — mit Änderungsprotokoll (wer/wann/warum, alt → neu).
*/
function corrAdmin(): User
{
$user = User::query()->create(['email' => uniqid('corr_', true).'@test.example', 'password' => bcrypt('password')]);
$user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
return $user->fresh();
}
/**
* @return array{0: User, 1: Ingredient, 2: Location, 3: Supplier, 4: MaterialQuality, 5: StockEntry}
*/
function corrReceived(?float $density = null, float $received = 1000): array
{
$admin = corrAdmin();
$ing = Ingredient::query()->create(['name' => 'Corr-'.uniqid('', true), 'active' => true, 'pos' => 0, 'density' => $density]);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$quality = MaterialQuality::factory()->create();
$entry = StockEntry::query()->create([
'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id,
'unit' => 'gram', 'purchase_unit' => $density ? 'ml' : 'gram', 'density_used' => $density,
'ordered_by' => $admin->id, 'ordered_at' => '2026-09-01', 'ordered_quantity' => $received, 'price_per_kg' => 10, 'price_per_kg_gross' => 10,
'status' => 'received', 'received_by' => $admin->id, 'received_at' => '2026-09-05', 'received_quantity' => $received,
'batch_number' => 'CH-1', 'best_before' => '2027-01-01', 'quality_id' => $quality->id,
]);
return [$admin, $ing, $location, $supplier, $quality, $entry];
}
/**
* @return array<string, string>
*/
function corrPayload(StockEntry $entry, array $overrides = []): array
{
return array_merge([
'entry_type' => 'ingredient', 'ingredient_id' => (string) $entry->ingredient_id, 'supplier_id' => (string) $entry->supplier_id,
'location_id' => (string) $entry->location_id, 'quality_id' => (string) $entry->quality_id, 'ordered_at' => '01.09.2026',
'ordered_quantity' => '1000', 'ordered_unit' => 'gram', 'price_per_kg' => '10,00',
'received_at' => '05.09.2026', 'received_quantity' => '1000', 'batch_number' => 'CH-1', 'best_before' => '01.01.2027',
'change_reason' => 'Tippfehler bei der Menge',
], $overrides);
}
test('gebuchter eingang ist korrigierbar und wirkt sofort auf den bestand', function () {
[$admin, $ing, , , , $entry] = corrReceived();
$this->actingAs($admin, 'user');
$this->get(route('admin.inventory.stock-entries.edit', $entry))->assertOk()->assertSee('Wareneingang korrigieren');
$this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['received_quantity' => '900', 'batch_number' => 'CH-2']))
->assertSessionHasNoErrors()
->assertRedirect(route('admin.inventory.stock-entries.show', $entry));
$entry->refresh();
expect((float) $entry->received_quantity)->toBe(900.0)
->and($entry->batch_number)->toBe('CH-2')
->and($entry->status)->toBe('received')
->and(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(900.0);
$change = StockEntryChange::query()->where('stock_entry_id', $entry->id)->firstOrFail();
expect($change->user_id)->toBe($admin->id)
->and($change->status_before)->toBe('received')
->and($change->reason)->toBe('Tippfehler bei der Menge')
->and($change->field_changes['received_quantity'])->toEqual(['old' => 1000, 'new' => 900])
->and($change->field_changes['batch_number'])->toBe(['old' => 'CH-1', 'new' => 'CH-2'])
->and($change->field_changes)->not->toHaveKey('ordered_quantity');
$rows = collect($change->describe());
expect($rows->pluck('label')->all())->toBe(['Eingegangene Menge', 'Charge'])
->and($rows[0]['old'])->toBe('1.000,00 g')->and($rows[0]['new'])->toBe('900,00 g');
$this->get(route('admin.inventory.stock-entries.show', $entry))
->assertOk()->assertSee('Änderungsprotokoll')->assertSee('Tippfehler bei der Menge')->assertSee('CH-1')->assertSee('CH-2');
});
test('korrektur eines gebuchten eingangs braucht einen grund', function () {
[$admin, , , , , $entry] = corrReceived();
$this->actingAs($admin, 'user');
$this->from(route('admin.inventory.stock-entries.edit', $entry))
->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['received_quantity' => '900', 'change_reason' => '']))
->assertSessionHasErrors('change_reason');
expect((float) $entry->fresh()->received_quantity)->toBe(1000.0)
->and(StockEntryChange::query()->count())->toBe(0);
});
test('korrektur darf die menge nicht unter den verbrauch senken', function () {
[$admin, $ing, $location, , , $entry] = corrReceived();
$this->actingAs($admin, 'user');
StockDisposal::query()->create([
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => $entry->id, 'location_id' => $location->id,
'quantity' => 400, 'unit' => 'gram', 'reason' => 'Test', 'disposed_at' => '2026-09-06', 'user_id' => $admin->id,
]);
// 300 g < 400 g Verbrauch → abgelehnt, nichts geändert, kein Protokoll.
$this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['received_quantity' => '300']))
->assertRedirect(route('admin.inventory.stock-entries.edit', $entry))
->assertSessionHasErrors('received_quantity');
expect((float) $entry->fresh()->received_quantity)->toBe(1000.0)
->and(StockEntryChange::query()->count())->toBe(0);
// Rohstoffwechsel bei Verbrauch → abgelehnt.
$other = Ingredient::query()->create(['name' => 'Corr-Other', 'active' => true, 'pos' => 0]);
$this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['ingredient_id' => (string) $other->id]))
->assertSessionHasErrors('ingredient_id');
expect((int) $entry->fresh()->ingredient_id)->toBe($ing->id);
// 400 g = genau der Verbrauch → erlaubt.
$this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['received_quantity' => '400']))
->assertSessionHasNoErrors();
expect((float) $entry->fresh()->received_quantity)->toBe(400.0);
});
test('korrektur eines ml-eingangs rechnet die eingegangene menge über die dichte um', function () {
[$admin, , , , , $entry] = corrReceived(0.8, 800);
$this->actingAs($admin, 'user');
// Formular zeigt 1.000 ml (800 g / 0,8); Korrektur auf 900 ml → 720 g.
$this->get(route('admin.inventory.stock-entries.edit', $entry))->assertOk()->assertSee('value="1.000,00"', false);
$this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, [
'ordered_unit' => 'ml', 'ordered_quantity' => '1000', 'received_quantity' => '900',
]))->assertSessionHasNoErrors();
$entry->refresh();
expect((float) $entry->received_quantity)->toBe(720.0)
->and((float) $entry->ordered_quantity)->toBe(800.0)
->and($entry->purchase_unit)->toBe('ml');
});
test('offene bestellung: bearbeiten protokolliert ohne pflichtgrund', function () {
[$admin, , , , , $entry] = corrReceived();
$entry->update(['status' => 'pending', 'received_at' => null, 'received_quantity' => null, 'batch_number' => null, 'best_before' => null, 'received_by' => null]);
$this->actingAs($admin, 'user');
$this->put(route('admin.inventory.stock-entries.update', $entry), array_diff_key(corrPayload($entry, ['ordered_quantity' => '1200']), array_flip(['received_at', 'received_quantity', 'batch_number', 'best_before', 'change_reason'])))
->assertSessionHasNoErrors()
->assertRedirect(route('admin.inventory.stock-entries.index'));
$entry->refresh();
expect((float) $entry->ordered_quantity)->toBe(1200.0)->and($entry->status)->toBe('pending');
$change = StockEntryChange::query()->firstOrFail();
expect($change->status_before)->toBe('pending')->and($change->reason)->toBeNull()
->and($change->field_changes)->toHaveKey('ordered_quantity')->and($change->field_changes)->not->toHaveKey('received_quantity');
// Speichern ohne Änderung erzeugt keine Protokollzeile.
$this->put(route('admin.inventory.stock-entries.update', $entry), array_diff_key(corrPayload($entry, ['ordered_quantity' => '1200']), array_flip(['received_at', 'received_quantity', 'batch_number', 'best_before', 'change_reason'])))
->assertSessionHasNoErrors();
expect(StockEntryChange::query()->count())->toBe(1);
});
test('verpackung: korrektur darf den artikelbestand nicht negativ machen', function () {
$admin = corrAdmin();
$this->actingAs($admin, 'user');
$item = PackagingItem::factory()->create();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$entry = StockEntry::query()->create([
'entry_type' => 'packaging', 'packaging_item_id' => $item->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id,
'unit' => 'piece', 'ordered_by' => $admin->id, 'ordered_at' => '2026-09-01', 'ordered_quantity' => 100, 'price_total' => 50,
'status' => 'received', 'received_by' => $admin->id, 'received_at' => '2026-09-05', 'received_quantity' => 100,
]);
StockDisposal::query()->create([
'disposal_type' => 'packaging', 'packaging_item_id' => $item->id, 'location_id' => $location->id,
'quantity' => 60, 'unit' => 'piece', 'reason' => 'Test', 'disposed_at' => '2026-09-06', 'user_id' => $admin->id,
]);
$base = [
'entry_type' => 'packaging', 'packaging_item_id' => (string) $item->id, 'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id, 'ordered_at' => '01.09.2026', 'ordered_quantity' => '100', 'price_total' => '50,00',
'received_at' => '05.09.2026', 'change_reason' => 'Zählfehler',
];
$this->put(route('admin.inventory.stock-entries.update', $entry), $base + ['received_quantity' => '50'])
->assertSessionHasErrors('received_quantity');
$this->put(route('admin.inventory.stock-entries.update', $entry), $base + ['received_quantity' => '60'])
->assertSessionHasNoErrors();
expect((float) $entry->fresh()->received_quantity)->toBe(60.0)
->and(app(InventoryService::class)->remainingByPackagingItem([$item->id])[$item->id])->toBe(0.0);
});