Warenwirtschaft: AP-45 + AP-47 — Verpackung aus Produktionsergebnis, Produktionsrelevant-Flag

AP-45: Verpackungsverbrauch (Etikett/Tiegel) wird jetzt aus dem tatsächlichen
Produktionsergebnis gebucht — je Ergebnisprodukt dessen eigene Stückliste ×
Ergebnismenge, über alle Outputs summiert — statt aus der geplanten Stückzahl.
syncPackagingSnapshot liest die production_outputs und läuft daher nach
persistOutputs (store + update umgestellt).

AP-47: Neues Produkt-Kennzeichen „produktionsrelevant" (Boolean, Default true).
Nur so markierte Produkte erscheinen in der Produktions-Auswahl
(ProductionController create/edit/copy) und in der „Enthalten in"-Box des
Rohstoffbestands (blendet z. B. Aloe-Handelsware und Zweitgrößen aus).
Checkbox im Produktformular (mit Hidden-Fallback 0). Migration Default true,
damit Bestandsdaten unverändert bleiben.

Migrationen add_production_relevant auf Dev-/Live-DB ausgeführt. 4 Tests, Suite 314 grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-08-28 08:38:59 +00:00
parent e6007efc41
commit d51860af39
7 changed files with 132 additions and 17 deletions

View file

@ -46,7 +46,7 @@ class ProductionController extends Controller
$defaultProductId = (int) $request->query('product_id') ?: null;
return view('admin.inventory.productions.create', [
'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name', 'batch_prefix']),
'products' => Product::query()->where('active', 1)->where('is_set', false)->where('production_relevant', true)->orderBy('name')->get(['id', 'name', 'batch_prefix']),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
'defaultProductId' => $defaultProductId,
@ -124,7 +124,7 @@ class ProductionController extends Controller
return view('admin.inventory.productions.edit', [
'model' => $production,
'products' => Product::query()
->where(fn ($q) => $q->where('active', 1)->where('is_set', false))
->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(),
@ -190,7 +190,7 @@ class ProductionController extends Controller
return view('admin.inventory.productions.create', [
'model' => $production,
'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name', 'batch_prefix']),
'products' => Product::query()->where('active', 1)->where('is_set', false)->where('production_relevant', true)->orderBy('name')->get(['id', 'name', 'batch_prefix']),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
]);

View file

@ -85,7 +85,9 @@ class RawMaterialStockController extends Controller
'materialQuality',
'taxRate',
'suppliers' => fn ($q) => $q->orderByPivot('preferred', 'desc')->orderBy('name'),
'products' => fn ($q) => $q->where('active', true),
// AP-47: nur produktionsrelevante Produkte in „Enthalten in" (blendet z. B.
// Aloe-Handelsware und Zweitgrößen aus).
'products' => fn ($q) => $q->where('active', true)->where('production_relevant', true),
]);
// AP-37: Jedes Produkt hat i. d. R. zwei product_ingredients-Zeilen

View file

@ -195,6 +195,7 @@ class Product extends Model
'action' => 'array',
'wp_number' => 'int',
'fill_weight' => 'int',
'production_relevant' => 'bool',
'single_commission' => 'bool',
'amount_commission' => 'bool',
'exclude_stats_sales' => 'bool',
@ -235,6 +236,7 @@ class Product extends Model
'contents',
'contents_total',
'fill_weight',
'production_relevant',
'unit',
'number',
'wp_number',

View file

@ -72,10 +72,12 @@ class ProductionService
]);
}
$this->syncPackagingSnapshot($production, $product, $producedQty);
$this->persistOutputs($production, $product, $producedQty, $outputs);
// AP-45: Verpackungsverbrauch aus dem tatsächlichen Produktionsergebnis
// buchen (je Ergebnisprodukt), daher nach persistOutputs.
$this->syncPackagingSnapshot($production);
$production->setRelation('product', $product);
$this->productStockService->recordProductionStock($production, $userId);
@ -141,12 +143,13 @@ class ProductionService
]);
}
$production->productionPackagings()->delete();
$this->syncPackagingSnapshot($production, $product, $producedQty);
$production->outputs()->delete();
$this->persistOutputs($production, $product, $producedQty, $outputs);
// AP-45: Verpackung folgt dem Produktionsergebnis, daher nach den Outputs.
$production->productionPackagings()->delete();
$this->syncPackagingSnapshot($production);
$production->setRelation('product', $product);
$this->productStockService->recordProductionStock($production, $userId);
@ -271,17 +274,32 @@ class ProductionService
}
}
private function syncPackagingSnapshot(Production $production, Product $product, int $producedQty): void
/**
* AP-45 (Kundenentscheid 26.08.2026): Verpackung (Etikett/Tiegel) wird aus dem
* tatsächlichen Produktionsergebnis gebucht — je Ergebnisprodukt dessen eigene
* Stückliste × Ergebnismenge, über alle Outputs summiert — nicht aus der
* geplanten Stückzahl. Muss nach persistOutputs laufen.
*/
private function syncPackagingSnapshot(Production $production): void
{
foreach ($product->packagings as $bom) {
$perUnit = (float) ($bom->pivot->quantity ?? 1);
$pieces = (int) round($perUnit * $producedQty);
if ($pieces < 1) {
$pieces = 1;
$outputs = $production->outputs()->with('product.packagings')->get();
$piecesByPackaging = [];
foreach ($outputs as $output) {
$outputProduct = $output->product;
if ($outputProduct === null) {
continue;
}
foreach ($outputProduct->packagings as $bom) {
$perUnit = (float) ($bom->pivot->quantity ?? 1);
$piecesByPackaging[$bom->id] = ($piecesByPackaging[$bom->id] ?? 0) + $perUnit * (int) $output->quantity;
}
}
foreach ($piecesByPackaging as $packagingItemId => $pieces) {
$production->productionPackagings()->create([
'packaging_item_id' => $bom->id,
'quantity_used' => $pieces,
'packaging_item_id' => (int) $packagingItemId,
'quantity_used' => max(1, (int) round($pieces)),
]);
}
}

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* AP-47 (Kundenwunsch 26.08.2026): Kennzeichen „produktionsrelevant". Nur so
* markierte Produkte erscheinen in der Produktions-Auswahl und in der
* „Enthalten in"-Box des Rohstoffbestands (z. B. blendet es Aloe-Handelsware
* und Zweitgrößen aus). Default true, damit Bestandsdaten unverändert bleiben.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->boolean('production_relevant')->default(true)->after('fill_weight');
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropColumn('production_relevant');
});
}
};

View file

@ -544,6 +544,15 @@
{{ Form::text('base_price', $product->getBasePriceFormattedFull(), ['placeholder' => __(''), 'class' => 'form-control', 'id' => 'base_price', 'readonly']) }}
</div>
<div class="form-group col-sm-3 d-flex align-items-end">
{{-- AP-47: nur produktionsrelevante Produkte erscheinen in der Produktions-Auswahl
und in der „Enthalten in"-Box des Rohstoffbestands. --}}
<label class="custom-control custom-checkbox mb-2">
{!! Form::hidden('production_relevant', 0) !!}
{!! Form::checkbox('production_relevant', 1, $product->production_relevant ?? true, ['class' => 'custom-control-input']) !!}
<span class="custom-control-label">{{ __('Produktionsrelevant') }}</span>
</label>
</div>
<div class="form-group col-sm-4">
<label class="form-label" for="number">{{ __('Artikelnummer') }}</label>
{{ Form::text('number', $product->number, ['placeholder' => __('number'), 'class' => 'form-control', 'id' => 'number']) }}

View file

@ -2,6 +2,8 @@
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\PackagingItem;
use App\Models\PackagingMaterial;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Models\Production;
@ -156,3 +158,57 @@ test('AP-40: recipeJson-payload enthält aktuellen bestand je INCI (für differe
expect($line['current_stock'])->toBe(800.0)
->and($line['required_grams_total'])->toBe(1000.0);
});
test('AP-45: verpackung wird je ergebnisprodukt aus dem produktionsergebnis gebucht', function () {
$user = psUser();
$supplier = Supplier::factory()->create();
$location = Location::factory()->create();
$material = PackagingMaterial::factory()->create();
$pkA = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'PS-Tiegel-A', 'category' => 'packaging']);
$pkB = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'PS-Tiegel-B', 'category' => 'packaging']);
// Beide Ergebnisprodukte ohne Rezepturpflicht (kein Rohstoff-Abwiegen nötig).
$planned = Product::query()->create(['name' => uniqid('PS-Plan-', true), 'title' => 'x', 'active' => true, 'no_recipe_required' => true]);
$planned->packagings()->sync([$pkA->id => ['quantity' => 1, 'pos' => 0]]);
$other = Product::query()->create(['name' => uniqid('PS-Other-', true), 'title' => 'y', 'active' => true, 'no_recipe_required' => true]);
$other->packagings()->sync([$pkB->id => ['quantity' => 2, 'pos' => 0]]);
// Eine Charge, Ergebnis: 40× Plan (Tiegel A) + 50× Other (Tiegel B).
$production = app(ProductionService::class)->store([
'product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 40,
], [], $user->id, [
['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => 'PS-A1'],
['product_id' => $other->id, 'quantity' => 50, 'batch_number' => 'PS-B1'],
]);
$byItem = $production->productionPackagings->keyBy('packaging_item_id');
expect($byItem[$pkA->id]->quantity_used)->toBe(40) // 1 × 40 (Ergebnis Plan)
->and($byItem[$pkB->id]->quantity_used)->toBe(100); // 2 × 50 (Ergebnis Other)
});
test('AP-47: produktions-auswahl zeigt nur produktionsrelevante produkte', function () {
$user = psUser();
$this->actingAs($user, 'user');
$ing = psIngredient('PS-Rel');
psProduct(['name' => 'PS47-Sichtbar', 'no_recipe_required' => true, 'production_relevant' => true], $ing);
psProduct(['name' => 'PS47-Versteckt', 'no_recipe_required' => true, 'production_relevant' => false], $ing);
$this->get(route('admin.inventory.productions.create'))
->assertSuccessful()
->assertSee('PS47-Sichtbar')
->assertDontSee('PS47-Versteckt');
});
test('AP-47: enthalten-in blendet nicht-produktionsrelevante produkte aus', function () {
$user = psUser();
$this->actingAs($user, 'user');
$ing = psIngredient('PS-EnthaltenRel');
psProduct(['name' => 'PS47-EnthaltenSichtbar', 'production_relevant' => true], $ing);
psProduct(['name' => 'PS47-EnthaltenVersteckt', 'production_relevant' => false], $ing);
$this->get(route('admin.inventory.raw-material-stock.show', $ing))
->assertSuccessful()
->assertSee('PS47-EnthaltenSichtbar')
->assertDontSee('PS47-EnthaltenVersteckt');
});