gruene-seele/app/Models/StockEntryChange.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

136 lines
4.9 KiB
PHP

<?php
namespace App\Models;
use App\Services\Util;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Protokollzeile einer nachträglichen Korrektur an einem Einkauf/Wareneingang.
* `field_changes` (Spaltenname bewusst nicht `changes` — kollidiert mit Model::$changes) = [feld => ['old' => …, 'new' => …]] mit Rohwerten (IDs, Y-m-d, Dezimal);
* die lesbare Darstellung liefert describe().
*/
class StockEntryChange extends Model
{
protected $fillable = [
'stock_entry_id',
'user_id',
'status_before',
'reason',
'field_changes',
];
/**
* Felder, die im Protokoll verfolgt werden (Reihenfolge = Anzeige).
*
* @var array<int, string>
*/
public const TRACKED = [
'entry_type', 'ingredient_id', 'packaging_item_id', 'supplier_id', 'location_id',
'ordered_at', 'ordered_quantity', 'purchase_unit', 'density_used',
'price_per_kg', 'price_per_kg_gross', 'tax_rate_id', 'price_total',
'received_at', 'received_quantity', 'batch_number', 'best_before', 'quality_id',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return ['field_changes' => 'array'];
}
/**
* @return BelongsTo<StockEntry, $this>
*/
public function stockEntry(): BelongsTo
{
return $this->belongsTo(StockEntry::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Geänderte Felder als lesbare Zeilen [label, alt, neu].
*
* @return array<int, array{label: string, old: string, new: string}>
*/
public function describe(): array
{
$rows = [];
foreach (self::TRACKED as $field) {
if (! isset($this->field_changes[$field])) {
continue;
}
$rows[] = [
'label' => self::label($field),
'old' => $this->format($field, $this->field_changes[$field]['old'] ?? null),
'new' => $this->format($field, $this->field_changes[$field]['new'] ?? null),
];
}
return $rows;
}
public static function label(string $field): string
{
return match ($field) {
'entry_type' => __('Art'),
'ingredient_id' => __('Inhaltsstoff'),
'packaging_item_id' => __('Verpackungsartikel'),
'supplier_id' => __('Lieferant'),
'location_id' => __('Lagerort'),
'ordered_at' => __('Bestelldatum'),
'ordered_quantity' => __('Bestellte Menge'),
'purchase_unit' => __('Einheit'),
'density_used' => __('Dichte (g/ml)'),
'price_per_kg' => __('Netto-Preis pro kg'),
'price_per_kg_gross' => __('Brutto-Preis pro kg'),
'tax_rate_id' => __('Umsatzsteuer'),
'price_total' => __('Gesamtpreis netto'),
'received_at' => __('Eingangsdatum'),
'received_quantity' => __('Eingegangene Menge'),
'batch_number' => __('Charge'),
'best_before' => __('Mindesthaltbarkeit'),
'quality_id' => __('Rohstoffqualität'),
default => $field,
};
}
protected function format(string $field, mixed $value): string
{
if ($value === null || $value === '') {
return '—';
}
return match ($field) {
'entry_type' => match ($value) {
'ingredient' => __('Rohstoff'),
'packaging' => __('Verpackung'),
'shipping' => __('Versand'),
default => (string) $value,
},
'ingredient_id' => Ingredient::withTrashed()->whereKey($value)->value('name') ?? '#'.$value,
'packaging_item_id' => PackagingItem::withTrashed()->whereKey($value)->value('name') ?? '#'.$value,
'supplier_id' => Supplier::withTrashed()->whereKey($value)->value('name') ?? '#'.$value,
'location_id' => Location::withTrashed()->whereKey($value)->value('name') ?? '#'.$value,
'quality_id' => MaterialQuality::withTrashed()->whereKey($value)->value('name') ?? '#'.$value,
'tax_rate_id' => TaxRate::withTrashed()->whereKey($value)->value('name') ?? '#'.$value,
'ordered_at', 'received_at', 'best_before' => Carbon::parse($value)->format('d.m.Y'),
'ordered_quantity', 'received_quantity' => formatWawiQuantity((float) $value).' '.($this->stockEntry?->unit === 'piece' ? __('Stk.') : 'g'),
'purchase_unit' => $value === 'ml' ? 'ml' : 'g',
'density_used' => Util::formatNumber($value),
'price_per_kg', 'price_per_kg_gross', 'price_total' => Util::formatNumber($value).' €',
default => (string) $value,
};
}
}