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>
136 lines
4.9 KiB
PHP
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,
|
|
};
|
|
}
|
|
}
|