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>
376 lines
15 KiB
PHP
376 lines
15 KiB
PHP
<?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();
|
||
}
|
||
}
|