Warenwirtschaft: F-14 — „Enthalten in“ mit Anteil (%) und Verbrauch je Stück
Nur Hersteller-Rezeptur, alle Phasenzeilen je Produkt summiert, Skalierung über Füllgewicht und Faktor wie in der Produktion; kein roher Pivot-Wert mehr. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
1e7ebfb77b
commit
8808ff538f
4 changed files with 92 additions and 9 deletions
|
|
@ -5,9 +5,12 @@ namespace App\Http\Controllers\Admin\Inventory;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Ingredient;
|
||||
use App\Models\Location;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductIngredient;
|
||||
use App\Models\TaxRate;
|
||||
use App\Repositories\StockEntryRepository;
|
||||
use App\Services\InventoryService;
|
||||
use App\Services\ProductionService;
|
||||
use App\Services\ProductStockService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
|
@ -18,6 +21,7 @@ class RawMaterialStockController extends Controller
|
|||
protected InventoryService $inventoryService,
|
||||
protected ProductStockService $productStockService,
|
||||
protected StockEntryRepository $stockEntryRepository,
|
||||
protected ProductionService $productionService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
|
|
@ -90,11 +94,12 @@ class RawMaterialStockController extends Controller
|
|||
'products' => fn ($q) => $q->where('active', true)->where('production_relevant', true),
|
||||
]);
|
||||
|
||||
// AP-37: Jedes Produkt hat i. d. R. zwei product_ingredients-Zeilen
|
||||
// (recipe_type „manufacturer" + „product"), wodurch die belongsToMany-
|
||||
// Relation dasselbe Produkt doppelt liefert. Für die „Enthalten in"-Anzeige
|
||||
// (und den darauf basierenden Produktbestand) nach Produkt-ID deduplizieren.
|
||||
$ingredient->setRelation('products', $ingredient->products->unique('id')->values());
|
||||
// AP-37/Prüfung 08.09.2026 F-14: „Enthalten in" aus der Hersteller-Rezeptur —
|
||||
// alle Zeilen (Phasen) je Produkt summieren; Anteil (%) und Verbrauch je Stück
|
||||
// (Anteil × Füllgewicht-Skala × Faktor, wie in der Produktion) statt roher Pivot-Wert.
|
||||
$recipeUsage = $this->recipeUsageByProduct($ingredient);
|
||||
$ingredient->setRelation('products', $ingredient->products->unique('id')
|
||||
->filter(fn (Product $p) => isset($recipeUsage[$p->id]))->values());
|
||||
|
||||
// AP-27: Chargen je batch_number zusammengeführt (jeder Einkauf bleibt eigener stock_entry).
|
||||
$chargeGroups = $this->inventoryService->chargeGroupsForIngredient($ingredient->id);
|
||||
|
|
@ -133,10 +138,45 @@ class RawMaterialStockController extends Controller
|
|||
'expectedEmpty' => $this->inventoryService->expectedEmptyDate($remaining, $daily),
|
||||
'status' => $this->inventoryService->stockStatus($minAlert, $remaining, $daily, $leadDays, $openTotal > 0),
|
||||
'lastPriceBySupplier' => $lastPriceBySupplier,
|
||||
'recipeUsage' => $recipeUsage,
|
||||
'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* F-14: Anteil und Verbrauch je Stück aus der Hersteller-Rezeptur je Produkt
|
||||
* (nur aktive, produktionsrelevante Produkte — AP-47).
|
||||
*
|
||||
* @return array<int, array{percent: float, grams_per_piece: ?float, consumption_per_piece: ?float}>
|
||||
*/
|
||||
private function recipeUsageByProduct(Ingredient $ingredient): array
|
||||
{
|
||||
$rows = ProductIngredient::query()
|
||||
->with('product')
|
||||
->where('ingredient_id', $ingredient->id)
|
||||
->where('recipe_type', 'manufacturer')
|
||||
->get()
|
||||
->filter(fn (ProductIngredient $row) => $row->product !== null
|
||||
&& (bool) $row->product->active && (bool) $row->product->production_relevant);
|
||||
|
||||
$usage = [];
|
||||
foreach ($rows->groupBy('product_id') as $productId => $productRows) {
|
||||
$product = $productRows->first()->product;
|
||||
$percent = (float) $productRows->sum(fn (ProductIngredient $r) => (float) ($r->gram ?? 0));
|
||||
$withFactor = (float) $productRows->sum(fn (ProductIngredient $r) => (float) ($r->gram ?? 0) * (float) ($r->factor ?? 1.1));
|
||||
$scale = $this->productionService->recipeGramScale($product);
|
||||
$hasMassBasis = $this->productionService->hasMassBasis($product);
|
||||
|
||||
$usage[(int) $productId] = [
|
||||
'percent' => round($percent, 3),
|
||||
'grams_per_piece' => $hasMassBasis ? round($percent * $scale, 2) : null,
|
||||
'consumption_per_piece' => $hasMassBasis ? round($withFactor * $scale, 2) : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string> [days => label]
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -395,6 +395,15 @@ class ProductionService
|
|||
return $baseMass !== null ? $baseMass / 100.0 : 1.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* F-14: Hat das Produkt eine Massenbasis (Füllgewicht bzw. ml-Füllmenge), sodass
|
||||
* Rezeptur-Prozente in Gramm je Stück umgerechnet werden können?
|
||||
*/
|
||||
public function hasMassBasis(Product $product): bool
|
||||
{
|
||||
return $this->productBaseMassPerPiece($product) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Massenbasis (Gramm) je produziertem Stück: explizites Füllgewicht hat Vorrang;
|
||||
* sonst gilt bei ml-Produkten (unit = 1) die Füllmenge als Gramm (1 g/ml).
|
||||
|
|
|
|||
|
|
@ -95,25 +95,35 @@
|
|||
<tr>
|
||||
<th>{{ __('Produkt') }}</th>
|
||||
<th class="text-right">{{ __('Produktbestand') }}</th>
|
||||
<th class="text-right">{{ __('Rezeptur-Anteil (g/Stück)') }}</th>
|
||||
<th class="text-right">{{ __('Anteil (%)') }}</th>
|
||||
<th class="text-right" title="{{ __('Anteil × Füllgewicht × Überschuss-Faktor — wie in der Produktion gerechnet') }}">{{ __('Verbrauch (g/Stück)') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($ingredient->products as $product)
|
||||
@php $usage = $recipeUsage[$product->id] ?? null; @endphp
|
||||
<tr>
|
||||
<td>{{ $product->name }}</td>
|
||||
<td class="text-right">{{ \App\Services\Util::formatNumber($productStock[$product->id] ?? 0, 0) }} {{ __('Stück') }}</td>
|
||||
<td class="text-right">
|
||||
@if($product->pivot->gram !== null && $product->pivot->gram !== '')
|
||||
{{ \App\Services\Util::formatNumber($product->pivot->gram, 2) }} g
|
||||
@if($usage)
|
||||
{{ \App\Services\Util::formatNumber($usage['percent'], 2) }} %
|
||||
@else
|
||||
<span class="text-muted">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-right">
|
||||
@if($usage && $usage['consumption_per_piece'] !== null)
|
||||
{{ \App\Services\Util::formatNumber($usage['consumption_per_piece'], 2) }} g
|
||||
<span class="text-muted small">({{ __('netto') }} {{ \App\Services\Util::formatNumber($usage['grams_per_piece'], 2) }} g)</span>
|
||||
@else
|
||||
<span class="text-muted" title="{{ __('Kein Füllgewicht hinterlegt') }}">—</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="3"><div class="wawi-empty">{{ __('Dieser Rohstoff wird in keiner aktiven Rezeptur verwendet.') }}</div></td>
|
||||
<td colspan="4"><div class="wawi-empty">{{ __('Dieser Rohstoff wird in keiner aktiven Rezeptur verwendet.') }}</div></td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -495,3 +495,27 @@ test('F-11: admins sehen den letzten einkaufspreis weiterhin', function () {
|
|||
->assertSuccessful()
|
||||
->assertSee('123,45');
|
||||
});
|
||||
|
||||
test('F-14: enthalten-in zeigt anteil in prozent und verbrauch je stück inkl. faktor, phasen summiert', function () {
|
||||
$user = ap10MakeUser(1);
|
||||
$ing = ap10MakeIngredient('F14-Anteil');
|
||||
|
||||
// 50-g-Produkt, 20 % mit Faktor 1,1 → netto 10 g, Verbrauch 11 g je Stück.
|
||||
$single = Product::query()->create(['name' => 'F14-Einfach', 'title' => 'x', 'active' => true, 'production_relevant' => true, 'fill_weight' => 50]);
|
||||
ProductIngredient::query()->create(['product_id' => $single->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 20, 'factor' => 1.1, 'recipe_type' => 'manufacturer']);
|
||||
// INCI-Zeile desselben Produkts darf nicht mitzählen.
|
||||
ProductIngredient::query()->create(['product_id' => $single->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 99, 'factor' => 1.0, 'recipe_type' => 'product']);
|
||||
|
||||
// Zwei Phasen 6 % + 4 % bei 100 g → 10 %, Verbrauch 10 g (Faktor 1,0).
|
||||
$phased = Product::query()->create(['name' => 'F14-Phasen', 'title' => 'x', 'active' => true, 'production_relevant' => true, 'fill_weight' => 100]);
|
||||
ProductIngredient::query()->create(['product_id' => $phased->id, 'ingredient_id' => $ing->id, 'pos' => 0, 'gram' => 6, 'factor' => 1.0, 'recipe_type' => 'manufacturer']);
|
||||
ProductIngredient::query()->create(['product_id' => $phased->id, 'ingredient_id' => $ing->id, 'pos' => 1, 'gram' => 4, 'factor' => 1.0, 'recipe_type' => 'manufacturer']);
|
||||
|
||||
$this->actingAs($user, 'user');
|
||||
$html = $this->get(route('admin.inventory.raw-material-stock.show', $ing))->assertSuccessful()->getContent();
|
||||
|
||||
expect($html)->toContain('20,00 %')->toContain('11,00 g')->toContain('netto 10,00 g')
|
||||
->toContain('10,00 %')->toContain('10,00 g')
|
||||
->not->toContain('99,00');
|
||||
expect(substr_count($html, 'F14-Einfach'))->toBe(1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue