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:
parent
e6007efc41
commit
d51860af39
7 changed files with 132 additions and 17 deletions
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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']) }}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue