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\Http\Controllers\Controller;
|
||||||
use App\Models\Ingredient;
|
use App\Models\Ingredient;
|
||||||
use App\Models\Location;
|
use App\Models\Location;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\ProductIngredient;
|
||||||
use App\Models\TaxRate;
|
use App\Models\TaxRate;
|
||||||
use App\Repositories\StockEntryRepository;
|
use App\Repositories\StockEntryRepository;
|
||||||
use App\Services\InventoryService;
|
use App\Services\InventoryService;
|
||||||
|
use App\Services\ProductionService;
|
||||||
use App\Services\ProductStockService;
|
use App\Services\ProductStockService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
@ -18,6 +21,7 @@ class RawMaterialStockController extends Controller
|
||||||
protected InventoryService $inventoryService,
|
protected InventoryService $inventoryService,
|
||||||
protected ProductStockService $productStockService,
|
protected ProductStockService $productStockService,
|
||||||
protected StockEntryRepository $stockEntryRepository,
|
protected StockEntryRepository $stockEntryRepository,
|
||||||
|
protected ProductionService $productionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): View
|
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),
|
'products' => fn ($q) => $q->where('active', true)->where('production_relevant', true),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// AP-37: Jedes Produkt hat i. d. R. zwei product_ingredients-Zeilen
|
// AP-37/Prüfung 08.09.2026 F-14: „Enthalten in" aus der Hersteller-Rezeptur —
|
||||||
// (recipe_type „manufacturer" + „product"), wodurch die belongsToMany-
|
// alle Zeilen (Phasen) je Produkt summieren; Anteil (%) und Verbrauch je Stück
|
||||||
// Relation dasselbe Produkt doppelt liefert. Für die „Enthalten in"-Anzeige
|
// (Anteil × Füllgewicht-Skala × Faktor, wie in der Produktion) statt roher Pivot-Wert.
|
||||||
// (und den darauf basierenden Produktbestand) nach Produkt-ID deduplizieren.
|
$recipeUsage = $this->recipeUsageByProduct($ingredient);
|
||||||
$ingredient->setRelation('products', $ingredient->products->unique('id')->values());
|
$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).
|
// AP-27: Chargen je batch_number zusammengeführt (jeder Einkauf bleibt eigener stock_entry).
|
||||||
$chargeGroups = $this->inventoryService->chargeGroupsForIngredient($ingredient->id);
|
$chargeGroups = $this->inventoryService->chargeGroupsForIngredient($ingredient->id);
|
||||||
|
|
@ -133,10 +138,45 @@ class RawMaterialStockController extends Controller
|
||||||
'expectedEmpty' => $this->inventoryService->expectedEmptyDate($remaining, $daily),
|
'expectedEmpty' => $this->inventoryService->expectedEmptyDate($remaining, $daily),
|
||||||
'status' => $this->inventoryService->stockStatus($minAlert, $remaining, $daily, $leadDays, $openTotal > 0),
|
'status' => $this->inventoryService->stockStatus($minAlert, $remaining, $daily, $leadDays, $openTotal > 0),
|
||||||
'lastPriceBySupplier' => $lastPriceBySupplier,
|
'lastPriceBySupplier' => $lastPriceBySupplier,
|
||||||
|
'recipeUsage' => $recipeUsage,
|
||||||
'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(),
|
'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]
|
* @return array<int, string> [days => label]
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,15 @@ class ProductionService
|
||||||
return $baseMass !== null ? $baseMass / 100.0 : 1.0;
|
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;
|
* 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).
|
* sonst gilt bei ml-Produkten (unit = 1) die Füllmenge als Gramm (1 g/ml).
|
||||||
|
|
|
||||||
|
|
@ -95,25 +95,35 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ __('Produkt') }}</th>
|
<th>{{ __('Produkt') }}</th>
|
||||||
<th class="text-right">{{ __('Produktbestand') }}</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@forelse($ingredient->products as $product)
|
@forelse($ingredient->products as $product)
|
||||||
|
@php $usage = $recipeUsage[$product->id] ?? null; @endphp
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ $product->name }}</td>
|
<td>{{ $product->name }}</td>
|
||||||
<td class="text-right">{{ \App\Services\Util::formatNumber($productStock[$product->id] ?? 0, 0) }} {{ __('Stück') }}</td>
|
<td class="text-right">{{ \App\Services\Util::formatNumber($productStock[$product->id] ?? 0, 0) }} {{ __('Stück') }}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
@if($product->pivot->gram !== null && $product->pivot->gram !== '')
|
@if($usage)
|
||||||
{{ \App\Services\Util::formatNumber($product->pivot->gram, 2) }} g
|
{{ \App\Services\Util::formatNumber($usage['percent'], 2) }} %
|
||||||
@else
|
@else
|
||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
@endforelse
|
@endforelse
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -495,3 +495,27 @@ test('F-11: admins sehen den letzten einkaufspreis weiterhin', function () {
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('123,45');
|
->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