gruene-seele/app/Services/ProductionService.php
Kevin Adametz 744b36cdcb Warenwirtschaft: AP-35 + AP-39 — Produktionsmengen auf Füllgewicht + ±5 % Abwiegen
AP-35 (Mengenberechnung): Die Hersteller-Rezeptur ist in Prozent hinterlegt; die
Sollmengen skalieren jetzt auf das Füllgewicht statt fest auf ~100 g. Neuer
Faktor recipeGramScale = Füllgewicht/100. Basis = neues, optionales Produkt-Feld
"fill_weight" (g); ist es leer, gilt bei ml-Produkten (unit=1) die Füllmenge
(contents_total) als Gramm (1 g/ml). Ohne Massenbasis (z. B. Stück-Produkte)
bleibt es beim Altverhalten (Anteil = Gramm), sodass Bestandsprodukte/Tests
unverändert rechnen. Skala wird an die Maske durchgereicht (recipe_gram_scale),
damit Client und Server identisch rechnen (Service + 4 JS-Stellen).

AP-39 (Abwiegen): Die Ist-Menge je INCI muss nicht mehr aufs Gramm passen —
assertLinesMatchRecipe erlaubt ±5 % (kleiner absoluter Sockel), gebucht wird der
reale Ist-Wert. Status-Ampel in der Maske: Orange = nichts eingetragen,
Grün = eingetragen und ±5 %, Rot = >5 % daneben (Zahlendreher-Schutz).

Migration add_fill_weight_to_products auf Dev-/Live-DB ausgeführt (nullable).
Product fillable/cast + Produktformular-Feld ergänzt. 5 Tests, Suite 310 grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-27 14:36:52 +00:00

684 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services;
use App\Models\Product;
use App\Models\Production;
use App\Models\ProductionIngredient;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ProductionService
{
public function __construct(
protected ProductStockService $productStockService,
) {}
/**
* @param array<string, mixed> $data
* @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used: float|int|string}> $ingredientLines
* @param array<int, array{product_id: int, quantity: int, batch_number?: ?string}> $outputs AP-28: Produktionsergebnis; leer ⇒ Fallback auf geplantes Produkt + Stückzahl
*/
public function store(array $data, array $ingredientLines, int $userId, array $outputs = []): Production
{
return DB::transaction(function () use ($data, $ingredientLines, $userId, $outputs) {
$product = Product::query()
->with(['manufacturer_ingredients', 'packagings'])
->findOrFail($data['product_id']);
$locationId = (int) $data['location_id'];
$producedQty = (int) $data['quantity'];
if ($producedQty < 1) {
throw ValidationException::withMessages(['quantity' => __('Die Stückzahl muss mindestens 1 sein.')]);
}
$this->assertNotASet($product);
if ($product->no_recipe_required) {
$ingredientLines = [];
} else {
$this->assertManufacturerRecipe($product);
$requiredGrams = $this->requiredGramsByIngredient($product, $producedQty);
$this->assertLinesMatchRecipe($requiredGrams, $ingredientLines);
foreach ($ingredientLines as $line) {
$this->assertStockEntryMatchesLine($line, $locationId);
}
}
$mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines);
$production = Production::query()->create([
'product_id' => $product->id,
'location_id' => $locationId,
'produced_by' => $userId,
'produced_at' => $data['produced_at'],
'quantity' => $producedQty,
'notes' => $data['notes'] ?? null,
'mhd_warning' => $mhdWarning,
]);
foreach ($ingredientLines as $line) {
$production->productionIngredients()->create([
'ingredient_id' => (int) $line['ingredient_id'],
'stock_entry_id' => (int) $line['stock_entry_id'],
'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null),
]);
}
$this->syncPackagingSnapshot($production, $product, $producedQty);
$this->persistOutputs($production, $product, $producedQty, $outputs);
$production->setRelation('product', $product);
$this->productStockService->recordProductionStock($production, $userId);
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings', 'outputs']);
});
}
/**
* @param array<string, mixed> $data
* @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used: float|int|string}> $ingredientLines
* @param array<int, array{product_id: int, quantity: int, batch_number?: ?string}> $outputs AP-28: Produktionsergebnis; leer ⇒ Fallback auf geplantes Produkt + Stückzahl
*/
public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId, array $outputs = []): Production
{
return DB::transaction(function () use ($production, $data, $ingredientLines, $userId, $outputs) {
// Audit M-9: Produktionszeile sperren — parallele Updates derselben
// Produktion lesen sonst denselben Buchungs-Snapshot und buchen doppelt.
Production::query()->lockForUpdate()->findOrFail($production->id);
$product = Product::query()
->with(['manufacturer_ingredients', 'packagings'])
->findOrFail($data['product_id']);
$locationId = (int) $data['location_id'];
$producedQty = (int) $data['quantity'];
if ($producedQty < 1) {
throw ValidationException::withMessages(['quantity' => __('Die Stückzahl muss mindestens 1 sein.')]);
}
$this->assertNotASet($product);
if ($product->no_recipe_required) {
$ingredientLines = [];
} else {
$this->assertManufacturerRecipe($product);
$requiredGrams = $this->requiredGramsByIngredient($product, $producedQty);
$this->assertLinesMatchRecipe($requiredGrams, $ingredientLines);
foreach ($ingredientLines as $line) {
$this->assertStockEntryMatchesLine($line, $locationId);
}
}
$mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines);
$production->update([
'product_id' => $product->id,
'location_id' => $locationId,
'produced_at' => $data['produced_at'],
'quantity' => $producedQty,
'notes' => $data['notes'] ?? null,
'mhd_warning' => $mhdWarning,
]);
$production->productionIngredients()->delete();
foreach ($ingredientLines as $line) {
$production->productionIngredients()->create([
'ingredient_id' => (int) $line['ingredient_id'],
'stock_entry_id' => (int) $line['stock_entry_id'],
'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null),
]);
}
$production->productionPackagings()->delete();
$this->syncPackagingSnapshot($production, $product, $producedQty);
$production->outputs()->delete();
$this->persistOutputs($production, $product, $producedQty, $outputs);
$production->setRelation('product', $product);
$this->productStockService->recordProductionStock($production, $userId);
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings', 'outputs']);
});
}
/**
* AP-28: Produktionsergebnis-Zeilen persistieren. Ohne übergebene Outputs
* (Service-Aufrufer/Altverhalten) wird eine Zeile aus der Planung erzeugt —
* gleiche Semantik wie der Backfill der Bestandsdaten.
*
* @param array<int, array{product_id: int, quantity: int, batch_number?: ?string}> $outputs
*/
private function persistOutputs(Production $production, Product $plannedProduct, int $plannedQty, array $outputs): void
{
if ($outputs === []) {
$producedAt = $production->produced_at instanceof Carbon
? $production->produced_at
: Carbon::parse((string) $production->produced_at);
$outputs = [[
'product_id' => (int) $plannedProduct->id,
'quantity' => $plannedQty,
'batch_number' => $plannedProduct->suggestedBatchNumber($producedAt),
]];
}
$productIds = array_map(fn (array $output) => (int) $output['product_id'], $outputs);
$products = Product::query()->whereIn('id', $productIds)->get()->keyBy('id');
foreach (array_values($outputs) as $index => $output) {
$productId = (int) $output['product_id'];
$quantity = (int) $output['quantity'];
/** @var Product|null $outputProduct */
$outputProduct = $products[$productId] ?? null;
if ($outputProduct === null) {
throw ValidationException::withMessages(['outputs' => __('Unbekanntes Produkt im Produktionsergebnis.')]);
}
if ($outputProduct->is_set) {
throw ValidationException::withMessages(['outputs' => __('Sets können nicht produziert werden. Bitte ein Einzelprodukt im Produktionsergebnis wählen.')]);
}
if ($quantity < 1) {
throw ValidationException::withMessages(['outputs' => __('Die produzierte Stückzahl muss je Ergebniszeile mindestens 1 sein.')]);
}
$batchNumber = trim((string) ($output['batch_number'] ?? ''));
$production->outputs()->create([
'product_id' => $productId,
'quantity' => $quantity,
'batch_number' => $batchNumber !== '' ? $batchNumber : null,
'pos' => $index,
]);
}
}
/**
* Sets sind Bündel aus Einzelprodukten und werden nicht produziert.
*/
public function assertNotASet(Product $product): void
{
if ($product->is_set) {
throw ValidationException::withMessages([
'product_id' => __('Sets können nicht produziert werden. Bitte ein Einzelprodukt wählen.'),
]);
}
}
/**
* Produktion basiert ausschließlich auf der Hersteller-Rezeptur (kein Fallback auf die Produkt-Rezeptur).
*/
public function assertManufacturerRecipe(Product $product): void
{
$product->loadMissing('manufacturer_ingredients');
if ($product->manufacturer_ingredients->isEmpty()) {
throw ValidationException::withMessages([
'product_id' => __('Für dieses Produkt ist keine Hersteller-Rezeptur gepflegt. Eine Produktion ist erst möglich, wenn eine Hersteller-Rezeptur hinterlegt wurde.'),
]);
}
}
/**
* @param array<int, float> $requiredGrams
* @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used?: mixed}> $ingredientLines
*/
private function assertLinesMatchRecipe(array $requiredGrams, array $ingredientLines): void
{
$sums = [];
foreach ($ingredientLines as $line) {
$iid = (int) $line['ingredient_id'];
$used = $this->parseQuantity($line['quantity_used'] ?? null);
$sums[$iid] = ($sums[$iid] ?? 0) + $used;
}
foreach ($requiredGrams as $iid => $req) {
$sum = $sums[$iid] ?? 0;
// AP-39 (Kundenentscheid 26.08.2026): Abwiegen muss nicht aufs Gramm
// passen — gebucht wird der reale Ist-Wert. Toleranz ±5 % (mit kleinem
// absolutem Sockel gegen Float-/Kleinmengen-Effekte); größere
// Abweichungen fängt der Zahlendreher-Schutz weiterhin ab.
$tolerance = max((float) $req * 0.05, 0.05);
if (abs($sum - $req) > $tolerance) {
throw ValidationException::withMessages([
'ingredient_lines' => __('Die Ist-Menge je INCI darf höchstens 5 % vom Soll abweichen (INCI :id: Soll :req g, Ist :sum g).', [
'id' => $iid,
'req' => number_format($req, 2, ',', '.'),
'sum' => number_format($sum, 2, ',', '.'),
]),
]);
}
}
foreach ($sums as $iid => $_sum) {
if (! isset($requiredGrams[$iid])) {
throw ValidationException::withMessages([
'ingredient_lines' => __('Unerwarteter Inhaltsstoff in den Chargen-Zeilen.'),
]);
}
}
}
private function syncPackagingSnapshot(Production $production, Product $product, int $producedQty): void
{
foreach ($product->packagings as $bom) {
$perUnit = (float) ($bom->pivot->quantity ?? 1);
$pieces = (int) round($perUnit * $producedQty);
if ($pieces < 1) {
$pieces = 1;
}
$production->productionPackagings()->create([
'packaging_item_id' => $bom->id,
'quantity_used' => $pieces,
]);
}
}
/**
* @return array<int, float>
*/
public function requiredGramsByIngredient(Product $product, int $producedQuantity): array
{
$required = [];
$scale = $this->recipeGramScale($product);
foreach ($product->manufacturer_ingredients as $ing) {
$gram = $ing->pivot->gram;
if ($gram === null || $gram === '') {
throw ValidationException::withMessages([
'product_id' => __('Für „:name“ fehlt der Anteil (%) in der Hersteller-Rezeptur.', ['name' => $ing->name]),
]);
}
$factor = (float) ($ing->pivot->factor ?? 1.1);
// Audit M-8: aufsummieren statt überschreiben — kommt derselbe Rohstoff
// mehrfach vor (z. B. in zwei Phasen), zählt die Gesamtmenge.
// AP-35: Anteil (%) × Füllgewicht-Skala × Overage-Faktor × Stückzahl.
$required[(int) $ing->id] = ($required[(int) $ing->id] ?? 0.0) + (float) $gram * $scale * $factor * $producedQuantity;
}
return $required;
}
/**
* AP-35 (Kundenentscheid 26.08.2026): Faktor, mit dem der Rezeptur-Anteil (%)
* in Gramm je Stück umgerechnet wird = Füllgewicht / 100. Ohne bekannte
* Massenbasis bleibt es beim Altverhalten (Anteil = Gramm), Faktor 1,0.
*/
public function recipeGramScale(Product $product): float
{
$baseMass = $this->productBaseMassPerPiece($product);
return $baseMass !== null ? $baseMass / 100.0 : 1.0;
}
/**
* 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).
* Stück-/Kapsel-Produkte (unit = 2) o. Ä. haben ohne Füllgewicht keine Basis.
*/
private function productBaseMassPerPiece(Product $product): ?float
{
if ($product->fill_weight !== null && (float) $product->fill_weight > 0) {
return (float) $product->fill_weight;
}
if ((int) $product->unit === 1 && $product->contents_total !== null && (int) $product->contents_total > 0) {
return (float) $product->contents_total;
}
return null;
}
/**
* @param array{ingredient_id: int, stock_entry_id: int, quantity_used?: mixed} $line
*/
public function assertStockEntryMatchesLine(array $line, int $locationId): void
{
// Audit H-2: Service-seitiger Guard zusätzlich zur Request-Validierung.
if ($this->parseQuantity($line['quantity_used'] ?? null) <= 0) {
throw ValidationException::withMessages([
'ingredient_lines' => __('Die Menge je Chargen-Zeile muss größer als 0 sein.'),
]);
}
$entry = StockEntry::query()->findOrFail((int) $line['stock_entry_id']);
if ($entry->status !== 'received') {
throw ValidationException::withMessages([
'ingredient_lines' => __('Wareneingang :id ist nicht als eingegangen gebucht.', ['id' => $entry->id]),
]);
}
if ((int) $entry->ingredient_id !== (int) $line['ingredient_id']) {
throw ValidationException::withMessages(['ingredient_lines' => __('Charge passt nicht zum Inhaltsstoff.')]);
}
if ((int) $entry->location_id !== $locationId) {
throw ValidationException::withMessages(['ingredient_lines' => __('Charge gehört zu einem anderen Lagerort.')]);
}
}
/**
* @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used?: mixed}> $ingredientLines
*/
public function computeMhdWarning(Product $product, string $producedAt, array $ingredientLines): bool
{
if ($product->shelf_life_type !== 'fixed' || ! $product->shelf_life_months) {
return false;
}
$productEnd = Carbon::parse($producedAt)->addMonths((int) $product->shelf_life_months)->startOfDay();
foreach ($ingredientLines as $line) {
$entry = StockEntry::query()->find((int) $line['stock_entry_id']);
if (! $entry || ! $entry->best_before) {
continue;
}
if ($entry->best_before->lt($productEnd)) {
return true;
}
}
return false;
}
/**
* Verbrauchte Menge je Wareneingang: Produktionsverbrauch **plus** chargen-
* bezogener Ausschuss (Audit H-1 — vorher zählte nur die Produktion, wodurch
* entsorgte Chargen weiter als verfügbar galten). Optional ohne eine
* bestimmte Produktion.
*
* @param array<int, int> $stockEntryIds
* @return array<int, float>
*/
public function consumedByStockEntry(array $stockEntryIds, ?int $excludeProductionId = null): array
{
if ($stockEntryIds === []) {
return [];
}
$rows = ProductionIngredient::query()
->join('productions', 'productions.id', '=', 'production_ingredients.production_id')
->whereNull('productions.deleted_at')
->selectRaw('production_ingredients.stock_entry_id as stock_entry_id, SUM(production_ingredients.quantity_used) as used')
->whereIn('production_ingredients.stock_entry_id', $stockEntryIds)
->when($excludeProductionId !== null, fn ($q) => $q->where('production_ingredients.production_id', '!=', $excludeProductionId))
->groupBy('production_ingredients.stock_entry_id')
->pluck('used', 'stock_entry_id');
$result = [];
foreach ($rows as $stockEntryId => $used) {
$result[(int) $stockEntryId] = (float) $used;
}
$disposed = StockDisposal::query()
->where('disposal_type', 'ingredient')
->whereNotNull('stock_entry_id')
->whereIn('stock_entry_id', $stockEntryIds)
->selectRaw('stock_entry_id, SUM(quantity) as used')
->groupBy('stock_entry_id')
->pluck('used', 'stock_entry_id');
foreach ($disposed as $stockEntryId => $used) {
$result[(int) $stockEntryId] = ($result[(int) $stockEntryId] ?? 0.0) + (float) $used;
}
return $result;
}
/**
* Wareneingänge eines Inhaltsstoffs am Standort mit Restbestand > 0 (FEFO-Reihenfolge).
*
* @return Collection<int, StockEntry>
*/
public function availableStockEntriesForIngredient(int $ingredientId, int $locationId, ?int $excludeProductionId = null): Collection
{
$entries = StockEntry::query()
->with('supplier')
->where('status', 'received')
->where('entry_type', 'ingredient')
->where('ingredient_id', $ingredientId)
->where('location_id', $locationId)
->orderByRaw('best_before is null, best_before asc')
->orderBy('id')
->get();
$consumed = $this->consumedByStockEntry($entries->pluck('id')->all(), $excludeProductionId);
return $entries
->map(function (StockEntry $entry) use ($consumed) {
$received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0;
$entry->setAttribute('remaining_quantity', round($received - ($consumed[(int) $entry->id] ?? 0.0), 2));
return $entry;
})
->filter(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity') > 0.0)
->values();
}
public function stockEntryLabel(StockEntry $entry): string
{
$parts = [];
$parts[] = $entry->supplier?->name ?: __('Lieferant unbekannt');
$parts[] = $entry->batch_number ?: ('#'.$entry->id);
if ($entry->best_before) {
$parts[] = $entry->best_before->format('d.m.Y');
}
return implode(' - ', $parts);
}
/**
* AP-27: Verfügbare Chargen je batch_number zusammengeführt (FEFO-Reihenfolge bleibt erhalten).
* Grundlage für das Charge-Dropdown der Produktion: eine Option je Charge mit Summen-Rest.
*
* @param Collection<int, StockEntry> $entries FEFO-sortierte Einträge mit remaining_quantity
* @return array<int, array{key: string, label: string, batch_number: ?string, best_before: ?string, remaining: float, purchases: int}>
*/
public function batchOptionsForEntries(Collection $entries): array
{
return $entries
->groupBy(fn (StockEntry $entry) => $entry->batchGroupKey())
->map(function ($group, string $key) {
/** @var StockEntry $first */
$first = $group->first();
$suppliers = $group->map(fn (StockEntry $e) => $e->supplier?->name)->filter()->unique();
$bestBefore = $group->pluck('best_before')->filter()->min();
$labelParts = [];
$labelParts[] = $suppliers->isNotEmpty() ? $suppliers->implode(', ') : __('Lieferant unbekannt');
$labelParts[] = $first->batch_number ?: ('#'.$first->id);
if ($bestBefore) {
$labelParts[] = $bestBefore->format('d.m.Y');
}
return [
'key' => $key,
'label' => implode(' - ', $labelParts),
'batch_number' => $first->batch_number,
'best_before' => $bestBefore?->format('Y-m-d'),
'remaining' => round($group->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity')), 2),
'purchases' => $group->count(),
];
})
->values()
->all();
}
/**
* AP-27: Löst Chargen-Zeilen (batch_key) in Einzel-Wareneingänge auf.
* Der Verbrauch wird FEFO auf die Einträge der Charge verteilt; ein etwaiger
* Überhang wird dem letzten Eintrag zugerechnet (kein Restbestands-Hardstop,
* konsistent zum bisherigen Verhalten je Einzel-Eintrag).
* Zeilen im alten Format (stock_entry_id) werden unverändert durchgereicht.
*
* @param array<int, array{ingredient_id: int, stock_entry_id?: int, batch_key?: string, quantity_used?: mixed}> $ingredientLines
* @return array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used: float|int|string}>
*/
public function expandBatchLines(array $ingredientLines, int $locationId, ?int $excludeProductionId = null): array
{
$expanded = [];
$entriesByIngredient = [];
foreach ($ingredientLines as $line) {
$batchKey = trim((string) ($line['batch_key'] ?? ''));
if ($batchKey === '') {
$expanded[] = $line;
continue;
}
$ingredientId = (int) $line['ingredient_id'];
$entriesByIngredient[$ingredientId] ??= $this->availableStockEntriesForIngredient($ingredientId, $locationId, $excludeProductionId);
$batchEntries = $entriesByIngredient[$ingredientId]
->filter(fn (StockEntry $entry) => $entry->batchGroupKey() === $batchKey)
->values();
if ($batchEntries->isEmpty()) {
throw ValidationException::withMessages([
'ingredient_lines' => __('Charge :key ist am gewählten Lagerort nicht (mehr) verfügbar.', [
'key' => explode(':', $batchKey, 3)[2] ?? $batchKey,
]),
]);
}
$needed = round($this->parseQuantity($line['quantity_used'] ?? null), 2);
$lastIndex = $batchEntries->count() - 1;
foreach ($batchEntries as $index => $entry) {
$capacity = (float) $entry->getAttribute('remaining_quantity');
$take = $index === $lastIndex ? $needed : round(min($needed, $capacity), 2);
if ($take > 0) {
$expanded[] = [
'ingredient_id' => $ingredientId,
'stock_entry_id' => (int) $entry->id,
'quantity_used' => $take,
];
// Audit H-3: Restmenge im Cache dekrementieren — sonst rechnet
// eine zweite Zeile derselben Charge erneut gegen die volle
// Kapazität und belastet denselben Einkauf doppelt.
$entry->setAttribute('remaining_quantity', round($capacity - $take, 2));
}
$needed = round($needed - $take, 2);
if ($needed <= 0) {
break;
}
}
}
return $expanded;
}
/**
* @return array<string, mixed>
*/
public function buildRecipePayload(Product $product, int $locationId, int $productionQuantity, ?int $excludeProductionId = null): array
{
$product->loadMissing(['manufacturer_ingredients', 'packagings.packagingMaterial', 'recipePhases']);
// AP-28: Zusatzspalten der INCI-Tabelle (Alternativname, Qualität, Regal) ohne N+1 laden.
$product->manufacturer_ingredients->load(['materialQuality', 'storageRoom', 'storageShelf', 'storageSlot']);
$qty = max(1, $productionQuantity);
$recipeRequired = ! (bool) $product->no_recipe_required;
$hasRecipe = $product->manufacturer_ingredients->isNotEmpty();
// AP-35: Umrechnungsfaktor Anteil (%) → Gramm je Stück (Füllgewicht / 100).
$scale = $this->recipeGramScale($product);
$ingredients = [];
foreach ($product->manufacturer_ingredients as $ing) {
$gram = $ing->pivot->gram;
$factor = (float) ($ing->pivot->factor ?? 1.1);
$hasGram = $gram !== null && $gram !== '';
$req = $hasGram ? (float) $gram * $scale * $factor * $qty : null;
$available = $this->availableStockEntriesForIngredient((int) $ing->id, $locationId, $excludeProductionId);
$ingredients[] = [
'id' => $ing->id,
'name' => $ing->name,
'alt_name' => $ing->alt_name,
'quality' => $ing->materialQuality?->name,
'storage_label' => $ing->storageLabel(),
'phase_id' => $ing->pivot->recipe_phase_id !== null ? (int) $ing->pivot->recipe_phase_id : null,
'gram' => $hasGram ? (float) $gram : null,
'factor' => $factor,
'required_grams_total' => $req,
'stock_entries' => $available
->map(function (StockEntry $se) {
return [
'id' => $se->id,
'label' => $this->stockEntryLabel($se),
'batch_number' => $se->batch_number,
'best_before' => $se->best_before?->format('Y-m-d'),
'remaining' => (float) $se->getAttribute('remaining_quantity'),
];
})->values()->all(),
// AP-27: Dropdown-Optionen je Charge zusammengeführt (Summen-Rest, FEFO-Reihenfolge).
'batches' => $this->batchOptionsForEntries($available),
];
}
$packagings = [];
foreach ($product->packagings as $pk) {
$perUnit = (float) ($pk->pivot->quantity ?? 1);
$packagings[] = [
'id' => $pk->id,
'name' => $pk->name,
'quantity_per_product' => $perUnit,
'total_pieces' => (int) round($perUnit * $qty),
'weight_grams' => $pk->weight_grams !== null ? (float) $pk->weight_grams : null,
'material_name' => $pk->packagingMaterial?->name,
];
}
return [
'product' => [
'id' => $product->id,
'name' => $product->name,
'shelf_life_type' => $product->shelf_life_type,
'shelf_life_months' => $product->shelf_life_months,
// AP-28: für die Chargen-Nr.-Vorbelegung im Produktionsergebnis.
'batch_prefix' => $product->batch_prefix,
],
'recipe_required' => $recipeRequired,
'has_recipe' => $hasRecipe,
'location_id' => $locationId,
'production_quantity' => $qty,
// AP-35: Skala Anteil (%) → Gramm/Stück, damit die Maske dieselbe
// Sollmenge rechnet wie der Server (Füllgewicht / 100; 1,0 ohne Basis).
'recipe_gram_scale' => $scale,
// AP-28: Phasen der Hersteller-Rezeptur (AP-23) für die gruppierte Chargen-Erfassung.
'phases' => $product->recipePhases
->map(fn ($phase) => [
'id' => (int) $phase->id,
'name' => $phase->name,
'note' => $phase->note,
])->values()->all(),
'ingredients' => $ingredients,
'packagings' => $packagings,
];
}
private function parseQuantity(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
// Audit N-7: Zahlen (interne Aufrufer) direkt; Strings konsequent im
// deutschen Format parsen — vorher wich „1.500" (Server: 1,5) von der
// UI-Ampel (1500) ab.
if (is_int($value) || is_float($value)) {
return (float) $value;
}
$n = reFormatWawiNumber((string) $value);
return $n !== null && $n !== '' && is_numeric($n) ? (float) $n : 0.0;
}
}