gruene-seele/tests/Feature/IngredientDensityTest.php
Kevin Adametz be1d8fdc39 Warenwirtschaft: Einheit (g/ml) als Pflichtfeld über der bestellten Menge
Die Einheit hing bisher als Anhang rechts am Mengenfeld — auf breiten
Monitoren außer Sicht, und „g" war stiller Default. Jetzt:

- Eigenes Pflichtfeld „Einheit der bestellten Menge" direkt VOR der Menge
  mit „Bitte wählen"; beim Neuanlegen muss bewusst Gramm oder Milliliter
  gewählt werden, beim Bearbeiten ist die gespeicherte Einheit vorbelegt.
- Hinweis unter der Einheit sagt, ob ml möglich ist (Dichte hinterlegt,
  Wert wird genannt) oder erst gepflegt werden muss; ml bleibt ohne Dichte
  gesperrt. Mengen-Hinweis und ml→g-Live-Vorschau folgen der Auswahl.
- Server: Store-/UpdateStockEntryRequest weisen Rohstoff-Einkäufe ohne
  Einheit ab (Verpackung bleibt ohne Einheit). Bestehende Tests ergänzt,
  neuer Test für die Pflicht.
- In-App-Doku (Hinweise, Testleitfaden) nachgezogen.

Suite: 390 grün.

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

283 lines
14 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\PackagingItem;
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');
});
test('einheit ist pflichtfeld: rohstoff-einkauf ohne einheit wird abgelehnt', function () {
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(0.79);
$this->actingAs($admin, 'user');
$base = [
'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-09-14',
'ordered_quantity' => '100', 'price_per_kg' => '10,00',
];
// Kein stiller Gramm-Default mehr: leer und fehlend werden beide abgewiesen.
$this->from(route('admin.inventory.stock-entries.create'))->post(route('admin.inventory.stock-entries.store'), $base + ['ordered_unit' => ''])
->assertRedirect(route('admin.inventory.stock-entries.create'))
->assertSessionHasErrors('ordered_unit');
$this->from(route('admin.inventory.stock-entries.create'))->post(route('admin.inventory.stock-entries.store'), $base)
->assertSessionHasErrors('ordered_unit');
expect(StockEntry::query()->count())->toBe(0);
// Verpackung kennt keine Einheit — dort bleibt das Feld ohne Wirkung.
$packaging = PackagingItem::factory()->create();
$this->post(route('admin.inventory.stock-entries.store'), [
'entry_type' => 'packaging', 'packaging_item_id' => (string) $packaging->id, 'supplier_id' => (string) $supplier->id,
'location_id' => (string) $location->id, 'ordered_at' => '2026-09-14', 'ordered_quantity' => '10', 'price_total' => '5,00',
])->assertSessionHasNoErrors();
expect(StockEntry::query()->count())->toBe(1);
});