Warenwirtschaft: F-02 — getrennter Produktkatalog für Ergebniszeilen
Planung oben bleibt auf produktionsrelevante Produkte gefiltert (AP-47); Ergebniszeilen bieten alle aktiven Einzelprodukte plus bereits gespeicherte Output-Produkte an (Bearbeiten/Kopieren verliert keine Zuordnung mehr). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
9e745d1441
commit
73512897ee
3 changed files with 89 additions and 9 deletions
|
|
@ -10,6 +10,7 @@ use App\Models\Production;
|
||||||
use App\Repositories\ProductionRepository;
|
use App\Repositories\ProductionRepository;
|
||||||
use App\Services\ProductionService;
|
use App\Services\ProductionService;
|
||||||
use App\Services\ProductStockService;
|
use App\Services\ProductStockService;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -46,7 +47,7 @@ class ProductionController extends Controller
|
||||||
$defaultProductId = (int) $request->query('product_id') ?: null;
|
$defaultProductId = (int) $request->query('product_id') ?: null;
|
||||||
|
|
||||||
return view('admin.inventory.productions.create', [
|
return view('admin.inventory.productions.create', [
|
||||||
'products' => Product::query()->where('active', 1)->where('is_set', false)->where('production_relevant', true)->orderBy('name')->get(['id', 'name', 'batch_prefix']),
|
...$this->productCatalogs(null),
|
||||||
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
|
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
|
||||||
'defaultLocationId' => $defaultLocationId,
|
'defaultLocationId' => $defaultLocationId,
|
||||||
'defaultProductId' => $defaultProductId,
|
'defaultProductId' => $defaultProductId,
|
||||||
|
|
@ -159,10 +160,7 @@ class ProductionController extends Controller
|
||||||
|
|
||||||
return view('admin.inventory.productions.edit', [
|
return view('admin.inventory.productions.edit', [
|
||||||
'model' => $production,
|
'model' => $production,
|
||||||
'products' => Product::query()
|
...$this->productCatalogs($production),
|
||||||
->where(fn ($q) => $q->where('active', 1)->where('is_set', false)->where('production_relevant', true))
|
|
||||||
->orWhere('id', $production->product_id)
|
|
||||||
->orderBy('name')->get(['id', 'name', 'batch_prefix']),
|
|
||||||
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
|
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
|
||||||
'defaultLocationId' => $defaultLocationId,
|
'defaultLocationId' => $defaultLocationId,
|
||||||
]);
|
]);
|
||||||
|
|
@ -219,12 +217,44 @@ class ProductionController extends Controller
|
||||||
|
|
||||||
return view('admin.inventory.productions.create', [
|
return view('admin.inventory.productions.create', [
|
||||||
'model' => $production,
|
'model' => $production,
|
||||||
'products' => Product::query()->where('active', 1)->where('is_set', false)->where('production_relevant', true)->orderBy('name')->get(['id', 'name', 'batch_prefix']),
|
...$this->productCatalogs($production),
|
||||||
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
|
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
|
||||||
'defaultLocationId' => $defaultLocationId,
|
'defaultLocationId' => $defaultLocationId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüfung 08.09.2026 F-02: Zwei getrennte Kataloge.
|
||||||
|
* - `products`: Planungsprodukt oben — nur produktionsrelevante Produkte (AP-47),
|
||||||
|
* plus das bereits geplante Produkt einer bestehenden Produktion.
|
||||||
|
* - `outputProducts`: Ergebniszeilen — alle aktiven Einzelprodukte, unabhängig von
|
||||||
|
* der Produktionsrelevanz (z. B. 5-ml-Variante aus der 50-ml-Masse), plus alle
|
||||||
|
* bereits gespeicherten Ergebnisprodukte, damit sie beim Bearbeiten/Kopieren
|
||||||
|
* nicht still verloren gehen.
|
||||||
|
*
|
||||||
|
* @return array{products: Collection<int, Product>, outputProducts: Collection<int, Product>}
|
||||||
|
*/
|
||||||
|
private function productCatalogs(?Production $production): array
|
||||||
|
{
|
||||||
|
$columns = ['id', 'name', 'batch_prefix'];
|
||||||
|
|
||||||
|
$products = Product::query()
|
||||||
|
->where(fn ($q) => $q->where('active', 1)->where('is_set', false)->where('production_relevant', true))
|
||||||
|
->when($production !== null, fn ($q) => $q->orWhere('id', $production->product_id))
|
||||||
|
->orderBy('name')
|
||||||
|
->get($columns);
|
||||||
|
|
||||||
|
$existingOutputIds = $production?->outputs?->pluck('product_id')->map(fn ($id) => (int) $id)->all() ?? [];
|
||||||
|
|
||||||
|
$outputProducts = Product::query()
|
||||||
|
->where(fn ($q) => $q->where('active', 1)->where('is_set', false))
|
||||||
|
->when($existingOutputIds !== [], fn ($q) => $q->orWhereIn('id', $existingOutputIds))
|
||||||
|
->orderBy('name')
|
||||||
|
->get($columns);
|
||||||
|
|
||||||
|
return ['products' => $products, 'outputProducts' => $outputProducts];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anforderung 15.07.2026: Produktionen löschbar (Soft-Delete).
|
* Anforderung 15.07.2026: Produktionen löschbar (Soft-Delete).
|
||||||
* Der gebuchte Produktbestand wird per Gegenbuchung zurückgeführt;
|
* Der gebuchte Produktbestand wird per Gegenbuchung zurückgeführt;
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@
|
||||||
<div class="form-row align-items-center mb-1 output-row">
|
<div class="form-row align-items-center mb-1 output-row">
|
||||||
<div class="col-12 col-sm-5 mb-1">
|
<div class="col-12 col-sm-5 mb-1">
|
||||||
<select name="outputs[__INDEX__][product_id]" class="form-control form-control-sm output-product" required>
|
<select name="outputs[__INDEX__][product_id]" class="form-control form-control-sm output-product" required>
|
||||||
@foreach($products as $p)
|
@foreach(($outputProducts ?? $products) as $p)
|
||||||
<option value="{{ $p->id }}" data-batch-prefix="{{ $p->batch_prefix }}">{{ $p->name }}</option>
|
<option value="{{ $p->id }}" data-batch-prefix="{{ $p->batch_prefix }}">{{ $p->name }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -194,12 +194,62 @@ test('AP-47: produktions-auswahl zeigt nur produktionsrelevante produkte', funct
|
||||||
psProduct(['name' => 'PS47-Sichtbar', 'no_recipe_required' => true, 'production_relevant' => true], $ing);
|
psProduct(['name' => 'PS47-Sichtbar', 'no_recipe_required' => true, 'production_relevant' => true], $ing);
|
||||||
psProduct(['name' => 'PS47-Versteckt', 'no_recipe_required' => true, 'production_relevant' => false], $ing);
|
psProduct(['name' => 'PS47-Versteckt', 'no_recipe_required' => true, 'production_relevant' => false], $ing);
|
||||||
|
|
||||||
$this->get(route('admin.inventory.productions.create'))
|
$html = $this->get(route('admin.inventory.productions.create'))
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('PS47-Sichtbar')
|
->assertSee('PS47-Sichtbar')
|
||||||
->assertDontSee('PS47-Versteckt');
|
->getContent();
|
||||||
|
|
||||||
|
// Planung oben: nur produktionsrelevante Produkte.
|
||||||
|
expect(psSelectHtml($html, 'id="product_id"'))->toContain('PS47-Sichtbar')->not->toContain('PS47-Versteckt')
|
||||||
|
// F-02: Ergebniszeilen: auch nicht-produktionsrelevante Produkte (5 ml aus der 50-ml-Masse).
|
||||||
|
->and(psSelectHtml($html, 'class="form-control form-control-sm output-product"'))->toContain('PS47-Sichtbar')->toContain('PS47-Versteckt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('F-02: bearbeiten behält ein nicht mehr produktionsrelevantes ergebnisprodukt und speichert unverändert', function () {
|
||||||
|
$user = psUser();
|
||||||
|
$this->actingAs($user, 'user');
|
||||||
|
$ing = psIngredient('PS-F02');
|
||||||
|
$big = psProduct(['name' => 'PS-F02-50ml', 'no_recipe_required' => true, 'production_relevant' => true], $ing);
|
||||||
|
$small = psProduct(['name' => 'PS-F02-5ml', 'no_recipe_required' => true, 'production_relevant' => false], $ing);
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
|
||||||
|
$production = app(ProductionService::class)->store(
|
||||||
|
['product_id' => $big->id, 'location_id' => $location->id, 'produced_at' => '2026-09-01', 'quantity' => 50, 'notes' => null],
|
||||||
|
[],
|
||||||
|
$user->id,
|
||||||
|
[
|
||||||
|
['product_id' => $big->id, 'quantity' => 40, 'batch_number' => 'B-50'],
|
||||||
|
['product_id' => $small->id, 'quantity' => 50, 'batch_number' => 'B-5'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$html = $this->get(route('admin.inventory.productions.edit', $production))->assertSuccessful()->getContent();
|
||||||
|
expect(psSelectHtml($html, 'id="product_id"'))->not->toContain('PS-F02-5ml')
|
||||||
|
->and(psSelectHtml($html, 'class="form-control form-control-sm output-product"'))->toContain('PS-F02-5ml');
|
||||||
|
|
||||||
|
$this->put(route('admin.inventory.productions.update', $production), [
|
||||||
|
'product_id' => $big->id, 'location_id' => $location->id, 'produced_at' => '2026-09-01', 'quantity' => 50,
|
||||||
|
'outputs' => [
|
||||||
|
['product_id' => $big->id, 'quantity' => 40, 'batch_number' => 'B-50'],
|
||||||
|
['product_id' => $small->id, 'quantity' => 50, 'batch_number' => 'B-5'],
|
||||||
|
],
|
||||||
|
])->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
expect($production->fresh()->outputs()->where('product_id', $small->id)->value('quantity'))->toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert das HTML eines <select>, identifiziert über ein eindeutiges Attribut im Start-Tag.
|
||||||
|
*/
|
||||||
|
function psSelectHtml(string $html, string $attribute): string
|
||||||
|
{
|
||||||
|
$start = strpos($html, $attribute);
|
||||||
|
expect($start)->not->toBeFalse();
|
||||||
|
$end = strpos($html, '</select>', $start);
|
||||||
|
|
||||||
|
return substr($html, $start, $end - $start);
|
||||||
|
}
|
||||||
|
|
||||||
test('AP-47: enthalten-in blendet nicht-produktionsrelevante produkte aus', function () {
|
test('AP-47: enthalten-in blendet nicht-produktionsrelevante produkte aus', function () {
|
||||||
$user = psUser();
|
$user = psUser();
|
||||||
$this->actingAs($user, 'user');
|
$this->actingAs($user, 'user');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue