$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); } /** * Bearbeiten/Korrigieren. Anforderung 14.09.2026: auch gebuchte Eingänge dürfen * korrigiert werden (Tippfehler, Nachmeldung); jede Änderung landet mit Grund im * Änderungsprotokoll (stock_entry_changes). Der Bestand ergibt sich weiterhin * aus received_quantity − Verbrauch, eine Mengenkorrektur wirkt also sofort. * * @param array $data */ public function update(StockEntry $stockEntry, array $data, ?string $reason = null): StockEntry { return DB::transaction(function () use ($stockEntry, $data, $reason) { $locked = StockEntry::query()->lockForUpdate()->findOrFail($stockEntry->id); $before = $this->snapshot($locked); 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) $locked->ingredient_id && (float) $locked->density_used > 0) { $data['density_used'] = (float) $locked->density_used; } $data = $this->applyPurchaseUnit($data); // Gebuchter Eingang: die eingegangene Menge wird in derselben Einheit wie die // Bestellung erfasst und bei ml über dieselbe Dichte in Gramm umgerechnet. if (isset($data['received_quantity']) && ($data['purchase_unit'] ?? null) === 'ml' && (float) ($data['density_used'] ?? 0) > 0) { $data['received_quantity'] = roundWawiQuantity((float) $data['received_quantity'] * (float) $data['density_used']); } } $data = $this->resolvePrices($data); if ($locked->isReceived()) { $this->assertReceivedCorrectionAllowed($locked, $data); } $locked->update($data); $locked->refresh(); $diff = $this->diff($before, $this->snapshot($locked)); if ($diff !== [] || ($reason !== null && $reason !== '')) { $locked->changes()->create([ 'user_id' => auth()->id(), 'status_before' => $before['status'], 'reason' => $reason !== '' ? $reason : null, 'field_changes' => $diff, ]); } return $locked; }); } /** * Korrektur eines gebuchten Eingangs darf den Bestand nicht negativ machen und * keinen Rohstoff/Artikel wechseln, aus dem bereits verbraucht wurde. * * @param array $data */ protected function assertReceivedCorrectionAllowed(StockEntry $locked, array $data): void { $blockers = app(DeletionGuard::class)->blockersFor($locked); if ($blockers !== []) { $typeChanged = array_key_exists('entry_type', $data) && $data['entry_type'] !== $locked->entry_type; $itemChanged = (array_key_exists('ingredient_id', $data) && (int) $data['ingredient_id'] !== (int) $locked->ingredient_id) || (array_key_exists('packaging_item_id', $data) && (int) $data['packaging_item_id'] !== (int) $locked->packaging_item_id); if ($typeChanged || $itemChanged) { throw ValidationException::withMessages([ 'ingredient_id' => __('Aus diesem Eingang wurde bereits verbraucht (:blockers) — Art und Rohstoff/Artikel können nicht mehr geändert werden.', ['blockers' => implode(', ', $blockers)]), ]); } } if (! array_key_exists('received_quantity', $data) || $data['received_quantity'] === null) { return; } $new = (float) $data['received_quantity']; if ($locked->entry_type === 'ingredient') { $used = app(ProductionService::class)->consumedByStockEntry([$locked->id])[$locked->id] ?? 0.0; if (roundWawiQuantity($new - $used) < 0) { throw ValidationException::withMessages([ 'received_quantity' => __('Aus diesem Eingang wurden bereits :used g verbraucht — die eingegangene Menge kann nicht darunter liegen.', ['used' => formatWawiQuantity($used)]), ]); } return; } // Verpackung: Verbrauch hängt am Artikel, nicht am Eingang — Gesamtbestand darf nicht negativ werden. $itemId = (int) $locked->packaging_item_id; if ($itemId > 0) { $remaining = app(InventoryService::class)->remainingByPackagingItem([$itemId])[$itemId] ?? 0.0; $delta = $new - (float) $locked->received_quantity; if (roundWawiQuantity($remaining + $delta) < 0) { throw ValidationException::withMessages([ 'received_quantity' => __('Der Bestand des Artikels würde negativ (aktuell :remaining Stk. verfügbar) — die Menge kann höchstens um :max Stk. gesenkt werden.', ['remaining' => formatWawiQuantity($remaining), 'max' => formatWawiQuantity(max(0.0, $remaining))]), ]); } } } /** * Protokollierte Felder als vergleichbare Rohwerte. * * @return array */ protected function snapshot(StockEntry $entry): array { $out = ['status' => $entry->status]; foreach (StockEntryChange::TRACKED as $field) { $value = $entry->getAttribute($field); if ($value instanceof \DateTimeInterface) { $value = $value->format('Y-m-d'); } elseif (is_numeric($value) && in_array($field, ['ordered_quantity', 'received_quantity', 'density_used', 'price_per_kg', 'price_per_kg_gross', 'price_total'], true)) { $value = (float) $value; } elseif ($value !== null && str_ends_with($field, '_id')) { $value = (int) $value; } elseif ($value !== null) { $value = (string) $value; } $out[$field] = $value; } return $out; } /** * @param array $before * @param array $after * @return array */ protected function diff(array $before, array $after): array { $diff = []; foreach (StockEntryChange::TRACKED as $field) { if (($before[$field] ?? null) !== ($after[$field] ?? null)) { $diff[$field] = ['old' => $before[$field] ?? null, 'new' => $after[$field] ?? null]; } } return $diff; } /** * 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(); } }