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 */ 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); });