gruene-seele/app/Services/ProductionService.php
Kevin Adametz 859b1f083d Warenwirtschaft: F-03 — Entwurf sichert Chargenzeilen verlustfrei
Entwürfe speichern batch_key roh (stock_entry_id nullable), keine Auflösung
und keine Filterung unfertiger Zeilen mehr beim Zwischenspeichern. Eine nicht
mehr verfügbare Charge wird im Formular markiert statt alle Zeilen zu verwerfen.
Ergebniszeilen dürfen im Entwurf ohne Stückzahl bleiben. Auflösung, Prüfung und
Buchung erst beim Abschluss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 10:53:36 +00:00

769 lines
34 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 = [], bool $draft = false): Production
{
return DB::transaction(function () use ($data, $ingredientLines, $userId, $outputs, $draft) {
$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 ($draft) {
// AP-43/F-03: Entwurf — keine Rezeptur-/Mengen-/Bestandsprüfung; alle
// Zeilen mit Rohstoff + Charge werden roh gesichert (auch ohne Menge).
$ingredientLines = $this->draftLines($ingredientLines);
} elseif ($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 = $draft ? false : $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,
'status' => $draft ? Production::STATUS_DRAFT : Production::STATUS_COMPLETED,
]);
foreach ($ingredientLines as $line) {
$production->productionIngredients()->create($this->ingredientLineAttributes($line));
}
// AP-43: Für den Entwurf nur die tatsächlich angegebenen Ergebnis-Zeilen
// sichern (kein Planungs-Fallback), sonst wie gehabt.
if (! $draft || $outputs !== []) {
$this->persistOutputs($production, $product, $producedQty, $outputs, $draft);
}
// AP-43: Entwürfe buchen keinen Bestand (Roh-/Produkt-/Verpackung).
if (! $draft) {
// 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);
}
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 = [], bool $draft = false): Production
{
return DB::transaction(function () use ($production, $data, $ingredientLines, $userId, $outputs, $draft) {
// Audit M-9: Produktionszeile sperren — parallele Updates derselben
// Produktion lesen sonst denselben Buchungs-Snapshot und buchen doppelt.
$locked = Production::query()->lockForUpdate()->findOrFail($production->id);
// AP-43: Eine bereits abgeschlossene (gebuchte) Produktion darf nicht
// zurück in den Entwurf — die Buchungen würden sonst verwaisen.
if ($draft && ! $locked->isDraft()) {
throw ValidationException::withMessages([
'status' => __('Eine abgeschlossene Produktion kann nicht mehr als Entwurf gespeichert werden.'),
]);
}
$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 ($draft) {
// AP-43/F-03: Entwurf — keine Rezeptur-/Mengen-/Bestandsprüfung, Zeilen roh sichern.
$ingredientLines = $this->draftLines($ingredientLines);
} elseif ($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 = $draft ? false : $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,
'status' => $draft ? Production::STATUS_DRAFT : Production::STATUS_COMPLETED,
]);
$production->productionIngredients()->delete();
foreach ($ingredientLines as $line) {
$production->productionIngredients()->create($this->ingredientLineAttributes($line));
}
$production->outputs()->delete();
if (! $draft || $outputs !== []) {
$this->persistOutputs($production, $product, $producedQty, $outputs, $draft);
}
// AP-43: Entwürfe buchen keinen Bestand. Ein zuvor gelöschter Verpackungs-
// Snapshot bleibt für den Entwurf leer.
$production->productionPackagings()->delete();
if (! $draft) {
// AP-45: Verpackung folgt dem Produktionsergebnis, daher nach den Outputs.
$this->syncPackagingSnapshot($production);
$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, bool $draft = false): 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.')]);
}
// F-03: Im Entwurf darf die Stückzahl noch fehlen (0); beim Abschluss ist sie Pflicht.
if ($quantity < 1 && ! $draft) {
throw ValidationException::withMessages(['outputs' => __('Die produzierte Stückzahl muss je Ergebniszeile mindestens 1 sein.')]);
}
$quantity = max(0, $quantity);
$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.'),
]);
}
}
}
/**
* 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
{
$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' => (int) $packagingItemId,
'quantity_used' => max(1, (int) round($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')
// Prüfung 08.09.2026 F-01: Entwürfe sind nicht bestandswirksam — wie in
// InventoryService::remainingByIngredient(), sonst blockiert ein Entwurf die Charge.
->where('productions.status', '!=', Production::STATUS_DRAFT)
->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);
// AP-40: aktueller Rohstoffbestand je INCI (für Bestands-/Differenz-Spalte).
// InventoryService wird lazy aufgelöst — es hängt selbst von ProductionService
// ab, eine Konstruktor-Injektion ergäbe eine zirkuläre Abhängigkeit.
$ingredientIds = $product->manufacturer_ingredients->pluck('id')->map(fn ($id) => (int) $id)->all();
$stockByIngredient = app(InventoryService::class)->remainingByIngredient($ingredientIds);
$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,
// AP-40: aktueller Bestand des Rohstoffs (global, in Gramm).
'current_stock' => round((float) ($stockByIngredient[(int) $ing->id] ?? 0.0), 2),
'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;
}
/**
* AP-43/F-03: Entwurfszeilen verlustfrei sichern — jede Zeile mit Rohstoff und
* Charge (batch_key) bzw. Wareneingang bleibt erhalten, auch ohne Menge.
* Die Auflösung auf Wareneingänge und die fachliche Prüfung folgen beim Abschluss.
*
* @param array<int, array<string, mixed>> $ingredientLines
* @return array<int, array<string, mixed>>
*/
private function draftLines(array $ingredientLines): array
{
return array_values(array_filter($ingredientLines, function (array $line): bool {
return (int) ($line['ingredient_id'] ?? 0) > 0
&& ((int) ($line['stock_entry_id'] ?? 0) > 0 || trim((string) ($line['batch_key'] ?? '')) !== '');
}));
}
/**
* @param array<string, mixed> $line
* @return array<string, mixed>
*/
private function ingredientLineAttributes(array $line): array
{
$stockEntryId = (int) ($line['stock_entry_id'] ?? 0);
$batchKey = trim((string) ($line['batch_key'] ?? ''));
return [
'ingredient_id' => (int) $line['ingredient_id'],
'stock_entry_id' => $stockEntryId > 0 ? $stockEntryId : null,
'batch_key' => $stockEntryId > 0 ? null : ($batchKey !== '' ? $batchKey : null),
'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null),
];
}
}