gruene-seele/app/Repositories/StockEntryRepository.php
Kevin Adametz 736cb087ae Warenwirtschaft: Einkäufe auch nach Buchung korrigierbar, mit Änderungsprotokoll
Bisher ließen sich nur offene Bestellungen bearbeiten; ein Tippfehler in
einem gebuchten Wareneingang war nicht korrigierbar (Anforderung 14.09.2026).

- Bearbeiten/Korrigieren für gebuchte Eingänge (Admin): Bestell- und
  Eingangsdaten (Menge, Datum, Charge, MHD, Qualität, Preise, Lieferant,
  Lagerort). Menge wirkt sofort auf den Bestand; ml-Eingänge werden in ml
  korrigiert und über die gespeicherte Dichte in Gramm gebucht.
- Schutz: Menge nicht unter den bereits verbrauchten Anteil (Rohstoff je
  Eingang, Verpackung je Artikel); bei Verbrauch sind Art und Rohstoff/
  Artikel gesperrt (Formular + Server).
- Änderungsprotokoll (stock_entry_changes): wer/wann/Grund + geänderte
  Felder alt → neu; Anzeige auf der Detailseite. Grund bei gebuchten
  Eingängen Pflicht, bei offenen optional. Speichern ohne Änderung
  erzeugt keine Zeile.
- Tests (6 neue), In-App-Doku (Hinweise + Leitfaden 3.2) nachgezogen.

Migration: 2026_09_14_110000_create_stock_entry_changes_table.
Suite: 396 grün.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 14:51:27 +00:00

376 lines
15 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\Repositories;
use App\Models\Ingredient;
use App\Models\StockEntry;
use App\Models\StockEntryChange;
use App\Models\TaxRate;
use App\Services\DeletionGuard;
use App\Services\InventoryService;
use App\Services\ProductionService;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class StockEntryRepository
{
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed>
*/
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<string, mixed> $before
* @param array<string, mixed> $after
* @return array<string, array{old: mixed, new: mixed}>
*/
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<string, mixed> $data
* @return array<string, mixed>
*/
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<string, mixed> $data
* @return array<string, mixed>
*/
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<string, mixed> $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<int, StockEntry>
*/
public function getByStatus(string $status): Collection
{
return StockEntry::query()
->where('status', $status)
->orderByDesc('ordered_at')
->get();
}
/**
* @return Collection<int, StockEntry>
*/
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<int, float> [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<int, StockEntry>
*/
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();
}
}