$data */ public function create(array $data): StockEntry { $data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece'; $data = $this->applyPurchaseUnit($data); $data = $this->resolvePrices($data); return StockEntry::query()->create($data); } /** * @param array $data */ public function update(StockEntry $stockEntry, array $data): StockEntry { if (array_key_exists('entry_type', $data)) { $data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece'; // N-06: Dichte-Snapshot nur bei unverändertem Rohstoff als Rückfall mitgeben — // eine fremde alte Dichte darf bei Rohstoffwechsel nicht übernommen werden. if (($data['ordered_unit'] ?? null) === 'ml' && (int) ($data['ingredient_id'] ?? 0) === (int) $stockEntry->ingredient_id && (float) $stockEntry->density_used > 0) { $data['density_used'] = (float) $stockEntry->density_used; } $data = $this->applyPurchaseUnit($data); } $data = $this->resolvePrices($data); $stockEntry->update($data); return $stockEntry->fresh(); } /** * AP-42: In ml erfasste Rohstoffmengen über die Dichte in Gramm umrechnen und * kanonisch als Gramm speichern (Bestand bleibt in Gramm). purchase_unit + * density_used protokollieren die Erfassungseinheit für Anzeige/Bearbeitung. * Ohne hinterlegte Dichte gilt die Menge als Gramm. * * @param array $data * @return array */ protected function applyPurchaseUnit(array $data): array { $orderedUnit = $data['ordered_unit'] ?? 'gram'; // F-15: Dichte-Snapshot der Vorlage (Kopie) als Rückfall, falls die Rohstoff-Dichte // inzwischen entfernt wurde — sonst würde die ml-Menge still als Gramm gebucht. $fallbackDensity = isset($data['density_used']) && (float) $data['density_used'] > 0 ? (float) $data['density_used'] : null; unset($data['ordered_unit']); if (($data['entry_type'] ?? '') !== 'ingredient') { $data['purchase_unit'] = null; $data['density_used'] = null; return $data; } $data['purchase_unit'] = 'gram'; $data['density_used'] = null; if ($orderedUnit === 'ml') { $density = Ingredient::query()->whereKey($data['ingredient_id'] ?? null)->value('density') ?? $fallbackDensity; if ($density !== null && (float) $density > 0) { $data['purchase_unit'] = 'ml'; $data['density_used'] = (float) $density; if (isset($data['ordered_quantity'])) { $data['ordered_quantity'] = roundWawiQuantity((float) $data['ordered_quantity'] * (float) $density); } } } return $data; } /** * Ergänzt Netto-/Brutto-Preis pro kg und den UST-Snapshot. * Es genügt, Netto oder Brutto anzugeben; der jeweils fehlende Wert wird * aus dem gewählten Steuersatz berechnet. * * @param array $data * @return array */ protected function resolvePrices(array $data): array { if (($data['entry_type'] ?? '') !== 'ingredient') { $data['price_per_kg'] = null; $data['price_per_kg_gross'] = null; $data['tax_rate_id'] = null; $data['tax_rate_percent'] = null; return $data; } $percent = null; if (! empty($data['tax_rate_id'])) { $percent = TaxRate::query()->whereKey($data['tax_rate_id'])->value('percent'); } $data['tax_rate_percent'] = $percent; $factor = 1 + ((float) ($percent ?? 0)) / 100; $net = isset($data['price_per_kg']) && $data['price_per_kg'] !== null && $data['price_per_kg'] !== '' ? (float) $data['price_per_kg'] : null; $gross = isset($data['price_per_kg_gross']) && $data['price_per_kg_gross'] !== null && $data['price_per_kg_gross'] !== '' ? (float) $data['price_per_kg_gross'] : null; if ($net !== null && $gross === null) { $gross = round($net * $factor, 4); } elseif ($gross !== null && $net === null) { $net = $factor > 0 ? round($gross / $factor, 4) : $gross; } elseif ($net !== null && $gross !== null) { $gross = round($net * $factor, 4); } $data['price_per_kg'] = $net; $data['price_per_kg_gross'] = $gross; $data['price_total'] = null; return $data; } /** * @param array $data */ public function receive(StockEntry $stockEntry, array $data): StockEntry { return DB::transaction(function () use ($stockEntry, $data) { // Prüfung 08.09.2026 F-16: Zeile sperren und „offen“ innerhalb der Sperre erneut // prüfen — zwei parallele Wareneingänge schreiben sonst nacheinander verschiedene // Mengen auf denselben Eintrag. $locked = StockEntry::query()->lockForUpdate()->findOrFail($stockEntry->id); if (! $locked->isPending()) { throw ValidationException::withMessages(['status' => __('Eintrag nicht mehr offen.')]); } $data['status'] = 'received'; $data['received_by'] = auth()->id(); // AP-42: Wurde in ml eingekauft, wird auch die gelieferte Menge in ml erfasst // und über die (beim Bestellen gespeicherte) Dichte in Gramm umgerechnet. if ($locked->purchase_unit === 'ml' && $locked->density_used !== null && isset($data['received_quantity'])) { $data['received_quantity'] = roundWawiQuantity((float) $data['received_quantity'] * (float) $locked->density_used); } $locked->update($data); return $locked->fresh(); }); } /** * @return Collection */ public function getByStatus(string $status): Collection { return StockEntry::query() ->where('status', $status) ->orderByDesc('ordered_at') ->get(); } /** * @return Collection */ public function getForIngredient(int $ingredientId): Collection { return StockEntry::query() ->where('ingredient_id', $ingredientId) ->where('status', 'received') ->orderByDesc('received_at') ->get(); } /** * Letzter Netto-kg-Preis je Lieferant für einen Rohstoff (neuester Einkauf zählt). * * @return array [supplier_id => price_per_kg_net] */ public function lastNetPricePerSupplier(int $ingredientId): array { $ranked = StockEntry::query() ->selectRaw('supplier_id, price_per_kg, ROW_NUMBER() OVER (PARTITION BY supplier_id ORDER BY ordered_at DESC, id DESC) as rn') ->where('entry_type', 'ingredient') // Audit N-2: nur tatsächliche Eingänge — offene, evtl. nie gelieferte // Bestellungen bestimmen den letzten Einkaufspreis nicht mehr. ->where('status', 'received') ->where('ingredient_id', $ingredientId) ->whereNotNull('supplier_id') ->whereNotNull('price_per_kg'); // DB::query statt StockEntry::query: der SoftDeletes-Global-Scope würde // sonst "stock_entries.deleted_at" auf dem Sub-Select-Alias referenzieren. return \DB::query() ->fromSub($ranked, 'ranked') ->where('rn', 1) ->pluck('price_per_kg', 'supplier_id') ->map(fn ($price) => (float) $price) ->all(); } /** * Liste: Pending zuerst (neuestes Bestelldatum), dann Received (neuester Eingang). * Eingegangene Einträge sind auf $receivedLimit gedeckelt (OPT-3); null = alle. * * @return Collection */ public function listForIndex(?int $receivedLimit = 500): Collection { $with = [ 'ingredient', 'packagingItem', 'supplier', 'location', 'quality', ]; $pending = StockEntry::query()->with($with) ->where('status', 'pending') ->orderByDesc('ordered_at') ->orderByDesc('id') ->get(); $received = StockEntry::query()->with($with) ->where('status', 'received') ->orderByDesc('received_at') ->orderByDesc('id') ->when($receivedLimit !== null, fn ($q) => $q->limit($receivedLimit)) ->get(); return $pending->concat($received)->values(); } }