Kundenfeedback 09.09.2026: Das Kästchen „Produktionsrelevant" oben im Produktformular wurde nicht gefunden. Es sitzt jetzt in der Karte „Warenwirtschaft" direkt unter „Im Produktbestand anzeigen", heißt „In der Produktion anzeigen (Produkt wird selbst hergestellt)" und erklärt im Hilfetext die Wirkung (Zukaufartikel wie Aloe abwählen: keine Anzeige in der Produkt-Auswahl der Produktion und in „Enthalten in"; Produktionsergebnis, Produktbestand und Verkauf unberührt). Feld hält jetzt auch old()-Werte nach Validierungsfehlern. In-App-Doku (Hinweise, Leitfaden §13/§20) entsprechend umformuliert; Test prüft Label und Platzierung. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
143 lines
7.8 KiB
PHP
143 lines
7.8 KiB
PHP
<?php
|
||
|
||
use App\Models\Location;
|
||
use App\Models\PackagingItem;
|
||
use App\Models\PackagingMaterial;
|
||
use App\Models\Product;
|
||
use App\Models\StockEntry;
|
||
use App\Models\StorageRoom;
|
||
use App\Models\StorageShelf;
|
||
use App\Models\Supplier;
|
||
use App\Services\ProductionService;
|
||
use App\User;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Tests\TestCase;
|
||
|
||
uses(TestCase::class, RefreshDatabase::class);
|
||
|
||
/**
|
||
* Prüfung 08.09.2026 F-08: Füllgewicht mit Nachkommastellen (Dezimalspalte, deutsche Eingabe, Validierung).
|
||
*/
|
||
function fwUser(): User
|
||
{
|
||
$user = User::query()->create(['email' => uniqid('fw_', true).'@test.example', 'password' => bcrypt('password')]);
|
||
$user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
|
||
|
||
return $user->fresh();
|
||
}
|
||
|
||
test('F-08: füllgewicht 39,5 wird mit nachkommastellen gespeichert und skaliert die rezeptur exakt', function () {
|
||
$this->actingAs(fwUser(), 'user');
|
||
|
||
$this->post(route('admin_product_store'), ['id' => 'new', 'name' => 'FW-Dezimal', 'fill_weight' => '39,5'])
|
||
->assertRedirect()->assertSessionHasNoErrors();
|
||
|
||
$product = Product::query()->where('name', 'FW-Dezimal')->firstOrFail();
|
||
expect($product->fill_weight)->toBe(39.5)
|
||
->and(app(ProductionService::class)->recipeGramScale($product))->toBe(0.395);
|
||
|
||
// Punkt mit 1–2 Nachkommastellen gilt als Dezimaltrenner (WaWi-Konvention), leer = null.
|
||
$this->post(route('admin_product_store'), ['id' => $product->id, 'name' => 'FW-Dezimal', 'fill_weight' => '4.5'])->assertSessionHasNoErrors();
|
||
expect($product->fresh()->fill_weight)->toBe(4.5);
|
||
|
||
$this->post(route('admin_product_store'), ['id' => $product->id, 'name' => 'FW-Dezimal', 'fill_weight' => ''])->assertSessionHasNoErrors();
|
||
expect($product->fresh()->fill_weight)->toBeNull();
|
||
});
|
||
|
||
test('F-08: ungültiges oder negatives füllgewicht wird abgelehnt', function () {
|
||
$this->actingAs(fwUser(), 'user');
|
||
$product = Product::query()->create(['name' => 'FW-Invalid', 'title' => 'x', 'active' => true, 'fill_weight' => 10]);
|
||
|
||
// Der Produkt-Controller rendert bei Fehlern das Formular direkt (kein Redirect).
|
||
foreach (['abc', '-5', '0'] as $bad) {
|
||
$this->post(route('admin_product_store'), ['id' => $product->id, 'name' => 'FW-Invalid', 'fill_weight' => $bad])
|
||
->assertOk()
|
||
->assertSee('is-invalid', false);
|
||
}
|
||
expect($product->fresh()->fill_weight)->toBe(10.0);
|
||
});
|
||
|
||
test('F-07: produktionsformular liefert die verpackungs-stückliste je ergebnisprodukt für die vorschau', function () {
|
||
$this->actingAs(fwUser(), 'user');
|
||
$supplier = Supplier::factory()->create();
|
||
$material = PackagingMaterial::factory()->create();
|
||
$pk = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'F07-Tiegel', 'category' => 'packaging']);
|
||
$small = Product::query()->create(['name' => 'F07-5ml', 'title' => 'x', 'active' => true, 'no_recipe_required' => true, 'production_relevant' => false]);
|
||
$small->packagings()->sync([$pk->id => ['quantity' => 2, 'pos' => 0]]);
|
||
Location::factory()->create();
|
||
|
||
$html = $this->get(route('admin.inventory.productions.create'))->assertOk()->getContent();
|
||
$start = strpos($html, "data-packaging-bom='") + strlen("data-packaging-bom='");
|
||
$bom = json_decode(substr($html, $start, strpos($html, "'", $start) - $start), true);
|
||
|
||
expect($bom)->toHaveKey((string) $small->id)
|
||
->and($bom[$small->id][0]['name'])->toBe('F07-Tiegel')
|
||
->and($bom[$small->id][0]['per_unit'])->toEqual(2)
|
||
->and($html)->toContain('renderPackagingPreview');
|
||
});
|
||
|
||
test('AP-52: verpackungsvorschau liefert bestand und lagerort je verpackungsartikel; spalten heißen einheitlich Lagerort', function () {
|
||
$user = fwUser();
|
||
$this->actingAs($user, 'user');
|
||
$supplier = Supplier::factory()->create();
|
||
$material = PackagingMaterial::factory()->create();
|
||
$room = StorageRoom::query()->create(['label' => 'Lager 1', 'pos' => 0, 'active' => true]);
|
||
$shelf = StorageShelf::query()->create(['label' => 'Regal B', 'pos' => 0, 'active' => true]);
|
||
$pk = PackagingItem::factory()->create([
|
||
'packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'AP52-Tiegel', 'category' => 'packaging',
|
||
'storage_room_id' => $room->id, 'storage_shelf_id' => $shelf->id,
|
||
]);
|
||
$location = Location::factory()->create();
|
||
// 120 Stück Wareneingang, 20 bereits verbraucht → Bestand 100.
|
||
StockEntry::query()->create([
|
||
'entry_type' => 'packaging', 'packaging_item_id' => $pk->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id, 'unit' => 'piece',
|
||
'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', 'ordered_quantity' => 120, 'price_per_kg' => 1, 'status' => 'received',
|
||
'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 120,
|
||
]);
|
||
$product = Product::query()->create(['name' => 'AP52-Produkt', 'title' => 'x', 'active' => true, 'no_recipe_required' => true]);
|
||
$product->packagings()->sync([$pk->id => ['quantity' => 1, 'pos' => 0]]);
|
||
app(ProductionService::class)->store(
|
||
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 20],
|
||
[], $user->id, [['product_id' => $product->id, 'quantity' => 20, 'batch_number' => null]]
|
||
);
|
||
|
||
$html = $this->get(route('admin.inventory.productions.create'))->assertOk()->getContent();
|
||
$start = strpos($html, "data-packaging-info='") + strlen("data-packaging-info='");
|
||
$info = json_decode(substr($html, $start, strpos($html, "'", $start) - $start), true);
|
||
|
||
expect($info[$pk->id]['stock'])->toEqual(100)
|
||
->and($info[$pk->id]['storage_label'])->toBe('Lager 1 | Regal B')
|
||
->and($html)->toContain('packagingRow(')
|
||
->and(substr_count($html, '<th>Lagerort</th>'))->toBe(2)
|
||
->and($html)->not->toContain('<th>Ablage</th>');
|
||
});
|
||
|
||
test('AP-47: checkbox „produktionsrelevant" im produktformular wird gespeichert und steuert die produktions-auswahl', function () {
|
||
$this->actingAs(fwUser(), 'user');
|
||
|
||
// Neuanlage ohne Häkchen (Hidden-Fallback 0) => Zukaufartikel wie Aloe verschwindet aus der Planung.
|
||
$this->post(route('admin_product_store'), ['id' => 'new', 'name' => 'AP47-Aloe-Zukauf', 'active' => 1, 'production_relevant' => '0'])
|
||
->assertRedirect()->assertSessionHasNoErrors();
|
||
$product = Product::query()->where('name', 'AP47-Aloe-Zukauf')->firstOrFail();
|
||
expect($product->production_relevant)->toBeFalse();
|
||
|
||
// 09.09.2026: Häkchen sitzt in der Karte „Warenwirtschaft" und ist ausformuliert.
|
||
$edit = $this->get(route('admin_product_edit', $product->id))->assertOk()->getContent();
|
||
expect($edit)->toContain('In der Produktion anzeigen (Produkt wird selbst hergestellt)')
|
||
->and(strpos($edit, 'id="production_relevant"'))->toBeGreaterThan(strpos($edit, 'id="show_in_product_stock"'));
|
||
|
||
$html = $this->get(route('admin.inventory.productions.create'))->assertSuccessful()->getContent();
|
||
$planningSelect = substr($html, strpos($html, 'id="product_id"'));
|
||
$planningSelect = substr($planningSelect, 0, strpos($planningSelect, '</select>'));
|
||
expect($planningSelect)->not->toContain('AP47-Aloe-Zukauf');
|
||
|
||
// Häkchen wieder setzen => wieder wählbar.
|
||
$this->post(route('admin_product_store'), ['id' => $product->id, 'name' => 'AP47-Aloe-Zukauf', 'active' => 1, 'production_relevant' => '1'])
|
||
->assertSessionHasNoErrors();
|
||
expect($product->fresh()->production_relevant)->toBeTrue();
|
||
|
||
$html = $this->get(route('admin.inventory.productions.create'))->assertSuccessful()->getContent();
|
||
$planningSelect = substr($html, strpos($html, 'id="product_id"'));
|
||
$planningSelect = substr($planningSelect, 0, strpos($planningSelect, '</select>'));
|
||
expect($planningSelect)->toContain('AP47-Aloe-Zukauf');
|
||
});
|