gruene-seele/tests/Feature/IngredientDensityTest.php
Kevin Adametz 5d82875d2f Warenwirtschaft: N-06 — Dichte-Snapshot der ml-Bestellung gilt auch im Bearbeiten
Formular, Update-Validierung und Repository nutzen bei unverändertem Rohstoff den
Snapshot als Rückfall; bei Rohstoffwechsel wird keine fremde Dichte übernommen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:48:02 +00:00

255 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\MaterialQuality;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Repositories\StockEntryRepository;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* AP-42: Rohstoffe werden in Gramm geführt, dürfen aber in ml erfasst werden.
* Über die Dichte (g/ml) ergeben sich die Gramm (100 ml Propolis × 0,79 = 79 g).
*/
function densAdmin(): User
{
$user = User::query()->create(['email' => uniqid('dens_', 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}
*/
function densSetup(?float $density): array
{
$admin = densAdmin();
$ingredient = Ingredient::query()->create(['name' => 'INCI-'.uniqid('', true), 'active' => true, 'pos' => 0, 'density' => $density]);
return [$admin, $ingredient, Location::factory()->create(), Supplier::factory()->create(), MaterialQuality::factory()->create()];
}
test('AP-42: ml-erfassung wird über die dichte in gramm gespeichert', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.79);
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient',
'ingredient_id' => (string) $ingredient->id,
'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id,
'quality_id' => (string) $quality->id,
'ordered_at' => '2026-08-01',
'ordered_quantity' => '100',
'ordered_unit' => 'ml',
'price_per_kg' => '10,00',
])->assertRedirect(route('admin.inventory.stock-entries.index'));
$entry = StockEntry::query()->latest('id')->firstOrFail();
expect((float) $entry->ordered_quantity)->toBe(79.0)
->and($entry->purchase_unit)->toBe('ml')
->and((float) $entry->density_used)->toBe(0.79);
});
test('AP-42: gramm-erfassung bleibt unverändert', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.79);
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient',
'ingredient_id' => (string) $ingredient->id,
'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id,
'quality_id' => (string) $quality->id,
'ordered_at' => '2026-08-01',
'ordered_quantity' => '100',
'ordered_unit' => 'gram',
'price_per_kg' => '10,00',
])->assertRedirect();
$entry = StockEntry::query()->latest('id')->firstOrFail();
expect((float) $entry->ordered_quantity)->toBe(100.0)
->and($entry->purchase_unit)->toBe('gram')
->and($entry->density_used)->toBeNull();
});
test('AP-42: ml-erfassung ohne hinterlegte dichte wird abgelehnt', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(null);
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient',
'ingredient_id' => (string) $ingredient->id,
'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id,
'quality_id' => (string) $quality->id,
'ordered_at' => '2026-08-01',
'ordered_quantity' => '100',
'ordered_unit' => 'ml',
'price_per_kg' => '10,00',
])->assertSessionHasErrors('ordered_quantity');
expect(StockEntry::query()->count())->toBe(0);
});
test('AP-42: wareneingang in ml wird über die dichte in gramm gebucht', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.80);
$this->actingAs($admin, 'user');
// Bestellung in ml (250 ml × 0,80 = 200 g)
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient',
'ingredient_id' => (string) $ingredient->id,
'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id,
'quality_id' => (string) $quality->id,
'ordered_at' => '2026-08-01',
'ordered_quantity' => '250',
'ordered_unit' => 'ml',
'price_per_kg' => '10,00',
])->assertRedirect();
$entry = StockEntry::query()->latest('id')->firstOrFail();
// Wareneingang: 250 ml geliefert => 200 g gebucht
$this->put(route('admin.inventory.stock-entries.receive', $entry), [
'received_at' => '2026-08-05',
'received_quantity' => '250',
'batch_number' => 'DENS-1',
'best_before' => '2030-12-31',
])->assertRedirect();
expect((float) $entry->fresh()->received_quantity)->toBe(200.0);
});
test('F-15: kopie einer ml-bestellung bleibt ml-bestellung und bucht den wareneingang über die dichte', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.79);
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01',
'ordered_quantity' => '100', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00',
])->assertRedirect();
$original = StockEntry::query()->latest('id')->firstOrFail();
$this->post(route('admin.inventory.stock-entries.copy', $original))->assertRedirect();
$copy = StockEntry::query()->latest('id')->firstOrFail();
expect($copy->id)->not->toBe($original->id)
->and($copy->purchase_unit)->toBe('ml')
->and((float) $copy->density_used)->toBe(0.79)
->and((float) $copy->ordered_quantity)->toBe(79.0); // kanonisch Gramm = 100 ml
// Wareneingang „100“ (ml) → 79 g, nicht 100 g.
$this->put(route('admin.inventory.stock-entries.receive', $copy), [
'received_at' => '2026-08-05', 'received_quantity' => '100', 'batch_number' => 'F15-1', 'best_before' => '2030-12-31',
])->assertRedirect();
expect((float) $copy->fresh()->received_quantity)->toBe(79.0);
});
test('F-15: kopie nutzt den dichte-snapshot, wenn die rohstoff-dichte inzwischen fehlt', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.80);
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01',
'ordered_quantity' => '250', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00',
])->assertRedirect();
$original = StockEntry::query()->latest('id')->firstOrFail();
$ingredient->forceFill(['density' => null])->save();
$this->post(route('admin.inventory.stock-entries.copy', $original))->assertRedirect();
$copy = StockEntry::query()->latest('id')->firstOrFail();
expect($copy->purchase_unit)->toBe('ml')
->and((float) $copy->density_used)->toBe(0.80)
->and((float) $copy->ordered_quantity)->toBe(200.0);
});
test('F-16: wareneingang prueft „offen“ innerhalb der sperre — zweite buchung wird abgelehnt', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(null);
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01',
'ordered_quantity' => '500', 'ordered_unit' => 'gram', 'price_per_kg' => '10,00',
])->assertRedirect();
$entry = StockEntry::query()->latest('id')->firstOrFail();
$repo = app(StockEntryRepository::class);
$repo->receive($entry, ['received_at' => '2026-08-05', 'received_quantity' => 500, 'batch_number' => 'F16-1']);
// Zweiter Request mit veraltetem Modell (noch „pending“ im Speicher) muss scheitern.
expect(fn () => $repo->receive($entry, ['received_at' => '2026-08-06', 'received_quantity' => 999, 'batch_number' => 'F16-2']))
->toThrow(ValidationException::class);
expect((float) $entry->fresh()->received_quantity)->toBe(500.0)
->and($entry->fresh()->batch_number)->toBe('F16-1');
});
test('N-06: ml-kopie ohne stammdatendichte übersteht bearbeiten, speichern und wareneingang', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.79);
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01',
'ordered_quantity' => '100', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00',
])->assertRedirect();
$original = StockEntry::query()->latest('id')->firstOrFail();
$ingredient->forceFill(['density' => null])->save();
$this->post(route('admin.inventory.stock-entries.copy', $original))->assertRedirect();
$copy = StockEntry::query()->latest('id')->firstOrFail();
// Bearbeitungsformular: ml bleibt wählbar (Dichte-Snapshot als data-density), Menge 100,00 ml.
$this->get(route('admin.inventory.stock-entries.edit', $copy))->assertOk()
->assertSee('data-density="0.79', false)
->assertSee('value="100,00"', false);
// Unverändert speichern (wie die Kopiermeldung es verlangt).
$this->put(route('admin.inventory.stock-entries.update', $copy), [
'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-02',
'ordered_quantity' => '100', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00',
])->assertRedirect()->assertSessionHasNoErrors();
$copy->refresh();
expect($copy->purchase_unit)->toBe('ml')
->and((float) $copy->density_used)->toBe(0.79)
->and((float) $copy->ordered_quantity)->toBe(79.0);
$this->put(route('admin.inventory.stock-entries.receive', $copy), [
'received_at' => '2026-08-05', 'received_quantity' => '100', 'batch_number' => 'N06-1', 'best_before' => '2030-12-31',
])->assertRedirect();
expect((float) $copy->fresh()->received_quantity)->toBe(79.0);
});
test('N-06: bei rohstoffwechsel wird keine fremde alte dichte übernommen', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.79);
$other = Ingredient::query()->create(['name' => 'INCI-ohne-Dichte', 'active' => true, 'pos' => 0, 'density' => null]);
$this->actingAs($admin, 'user');
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01',
'ordered_quantity' => '100', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00',
])->assertRedirect();
$entry = StockEntry::query()->latest('id')->firstOrFail();
$this->from(route('admin.inventory.stock-entries.edit', $entry))->put(route('admin.inventory.stock-entries.update', $entry), [
'entry_type' => 'ingredient', 'ingredient_id' => (string) $other->id, 'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-02',
'ordered_quantity' => '100', 'ordered_unit' => 'ml', 'price_per_kg' => '10,00',
])->assertSessionHasErrors('ordered_quantity');
});