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