gruene-seele/tests/Feature/IngredientDensityTest.php
Kevin Adametz 731b2461e2 Warenwirtschaft: F-16 — fachliche Vorbedingungen innerhalb der Sperre prüfen
- Statusservice: Vorbedingung (Ausgangsstatus, Zahlungsfreigabe) wird nach
  lockForUpdate erneut geprüft (Board übergibt sie als Closure).
- Wareneingang: Transaktion + Sperre, „offen“ wird innerhalb der Sperre geprüft.
- Ausschuss: betroffene Wareneingänge sperren und Restbestand in der
  Transaktion erneut prüfen.

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

198 lines
8.6 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');
});