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>
This commit is contained in:
parent
be1d8fdc39
commit
736cb087ae
14 changed files with 783 additions and 37 deletions
|
|
@ -92,6 +92,7 @@ class StockEntryController extends Controller
|
|||
'quality',
|
||||
'orderedByUser.account',
|
||||
'receivedByUser.account',
|
||||
'changes.user',
|
||||
]);
|
||||
|
||||
return view('admin.inventory.stock-entries.show', array_merge($this->formSharedData(), [
|
||||
|
|
@ -105,16 +106,13 @@ class StockEntryController extends Controller
|
|||
if (! auth()->user()->isAdmin()) {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
if (! $stockEntry->isPending()) {
|
||||
\Session::flash('alert-error', __('Nur offene Bestellungen können bearbeitet werden.'));
|
||||
|
||||
return redirect()->route('admin.inventory.stock-entries.show', $stockEntry);
|
||||
}
|
||||
|
||||
// Anforderung 14.09.2026: auch gebuchte Eingänge sind korrigierbar (mit Protokoll).
|
||||
$stockEntry->load(['ingredient', 'packagingItem']);
|
||||
|
||||
return view('admin.inventory.stock-entries.edit', array_merge($this->formSharedData(), [
|
||||
'model' => $stockEntry,
|
||||
'correctionBlockers' => $stockEntry->isReceived() ? app(DeletionGuard::class)->blockersFor($stockEntry) : [],
|
||||
]));
|
||||
}
|
||||
|
||||
|
|
@ -123,17 +121,18 @@ class StockEntryController extends Controller
|
|||
if (! $request->user()->isAdmin()) {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
if (! $stockEntry->isPending()) {
|
||||
\Session::flash('alert-error', __('Nur offene Bestellungen können bearbeitet werden.'));
|
||||
|
||||
return redirect()->route('admin.inventory.stock-entries.show', $stockEntry);
|
||||
try {
|
||||
$this->stockEntryRepository->update($stockEntry, $request->validatedPayload(), $request->input('change_reason'));
|
||||
} catch (ValidationException $e) {
|
||||
return redirect()->route('admin.inventory.stock-entries.edit', $stockEntry)->withErrors($e->errors())->withInput();
|
||||
}
|
||||
|
||||
$this->stockEntryRepository->update($stockEntry, $request->validatedPayload());
|
||||
|
||||
\Session::flash('alert-save', '1');
|
||||
|
||||
return redirect()->route('admin.inventory.stock-entries.index');
|
||||
return $stockEntry->isReceived()
|
||||
? redirect()->route('admin.inventory.stock-entries.show', $stockEntry)
|
||||
: redirect()->route('admin.inventory.stock-entries.index');
|
||||
}
|
||||
|
||||
public function copy(StockEntry $stockEntry): RedirectResponse
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ class UpdateStockEntryRequest extends FormRequest
|
|||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$received = $this->isReceivedEntry();
|
||||
|
||||
return [
|
||||
'entry_type' => ['required', Rule::in(['ingredient', 'packaging', 'shipping'])],
|
||||
'ingredient_id' => ['nullable', 'integer', 'exists:ingredients,id'],
|
||||
|
|
@ -35,9 +37,28 @@ class UpdateStockEntryRequest extends FormRequest
|
|||
'price_per_kg' => ['nullable', 'numeric', 'min:0'],
|
||||
'price_per_kg_gross' => ['nullable', 'numeric', 'min:0'],
|
||||
'price_total' => ['nullable', 'numeric', 'min:0'],
|
||||
// Anforderung 14.09.2026: Korrektur gebuchter Eingänge inkl. Eingangsdaten,
|
||||
// Grund ist bei gebuchten Eingängen Pflicht (Änderungsprotokoll).
|
||||
'received_at' => [$received ? 'required' : 'nullable', 'date'],
|
||||
'received_quantity' => [$received ? 'required' : 'nullable', 'numeric', 'min:0.001', 'max:'.InventoryService::QUANTITY_MAX],
|
||||
'batch_number' => ['nullable', 'string', 'max:100'],
|
||||
'best_before' => ['nullable', 'date'],
|
||||
'change_reason' => [$received ? 'required' : 'nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function stockEntry(): ?StockEntry
|
||||
{
|
||||
$entry = $this->route('stock_entry') ?? $this->route('stockEntry');
|
||||
|
||||
return $entry instanceof StockEntry ? $entry : null;
|
||||
}
|
||||
|
||||
protected function isReceivedEntry(): bool
|
||||
{
|
||||
return (bool) $this->stockEntry()?->isReceived();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
|
@ -47,6 +68,9 @@ class UpdateStockEntryRequest extends FormRequest
|
|||
'quality_id.required' => __('Bitte eine Rohstoffqualität wählen.'),
|
||||
'price_per_kg.required' => __('Bitte den Netto- oder Brutto-Preis pro kg angeben.'),
|
||||
'price_total.required' => __('Bitte den Gesamtpreis netto angeben.'),
|
||||
'change_reason.required' => __('Bitte den Grund der Korrektur angeben — er wird im Änderungsprotokoll gespeichert.'),
|
||||
'received_at.required' => __('Bitte das Eingangsdatum angeben.'),
|
||||
'received_quantity.required' => __('Bitte die eingegangene Menge angeben.'),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +78,8 @@ class UpdateStockEntryRequest extends FormRequest
|
|||
{
|
||||
$this->merge([
|
||||
'ordered_quantity' => normalizeWawiQuantity($this->input('ordered_quantity')),
|
||||
'received_quantity' => $this->filled('received_quantity') ? normalizeWawiQuantity($this->input('received_quantity')) : null,
|
||||
'change_reason' => $this->filled('change_reason') ? trim((string) $this->input('change_reason')) : null,
|
||||
'price_per_kg' => $this->filled('price_per_kg') ? reFormatWawiNumber($this->input('price_per_kg')) : null,
|
||||
'price_per_kg_gross' => $this->filled('price_per_kg_gross') ? reFormatWawiNumber($this->input('price_per_kg_gross')) : null,
|
||||
'price_total' => $this->filled('price_total') ? reFormatWawiNumber($this->input('price_total')) : null,
|
||||
|
|
@ -91,6 +117,20 @@ class UpdateStockEntryRequest extends FormRequest
|
|||
$validator->errors()->add('ordered_quantity', __('Die Menge ergibt umgerechnet weniger als 0,001 g — bitte mindestens 0,001 g erfassen.'));
|
||||
}
|
||||
}
|
||||
if ($this->isReceivedEntry()) {
|
||||
if (empty($this->input('batch_number'))) {
|
||||
$validator->errors()->add('batch_number', __('Bitte eine Chargennummer angeben.'));
|
||||
}
|
||||
if (empty($this->input('best_before'))) {
|
||||
$validator->errors()->add('best_before', __('Bitte die Mindesthaltbarkeit angeben.'));
|
||||
}
|
||||
if ($this->input('ordered_unit') === 'ml' && is_numeric($this->input('received_quantity'))) {
|
||||
$density = Ingredient::query()->whereKey($this->input('ingredient_id'))->value('density') ?? $this->stockEntry()?->density_used;
|
||||
if ($density !== null && (float) $density > 0 && roundWawiQuantity((float) $this->input('received_quantity') * (float) $density) <= 0) {
|
||||
$validator->errors()->add('received_quantity', __('Die Menge ergibt umgerechnet weniger als 0,001 g — bitte mindestens 0,001 g erfassen.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
$net = $this->input('price_per_kg');
|
||||
$gross = $this->input('price_per_kg_gross');
|
||||
$hasNet = is_numeric($net) && (float) $net > 0;
|
||||
|
|
@ -115,11 +155,18 @@ class UpdateStockEntryRequest extends FormRequest
|
|||
public function validatedPayload(): array
|
||||
{
|
||||
$data = $this->validated();
|
||||
unset($data['change_reason']);
|
||||
if (($data['entry_type'] ?? '') === 'ingredient') {
|
||||
$data['packaging_item_id'] = null;
|
||||
} else {
|
||||
$data['ingredient_id'] = null;
|
||||
$data['quality_id'] = null;
|
||||
$data['batch_number'] = null;
|
||||
$data['best_before'] = null;
|
||||
}
|
||||
if (! $this->isReceivedEntry()) {
|
||||
// Offene Bestellung: Eingangsfelder gehören nicht ins Formular.
|
||||
unset($data['received_at'], $data['received_quantity'], $data['batch_number'], $data['best_before']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use Database\Factories\StockEntryFactory;
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StockEntry extends Model
|
||||
|
|
@ -61,6 +62,16 @@ class StockEntry extends Model
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Änderungsprotokoll (nachträgliche Korrekturen), neueste zuerst.
|
||||
*
|
||||
* @return HasMany<StockEntryChange, $this>
|
||||
*/
|
||||
public function changes(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockEntryChange::class)->orderByDesc('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Ingredient, $this>
|
||||
*/
|
||||
|
|
|
|||
136
app/Models/StockEntryChange.php
Normal file
136
app/Models/StockEntryChange.php
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,11 @@ 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;
|
||||
|
|
@ -24,27 +28,149 @@ class StockEntryRepository
|
|||
}
|
||||
|
||||
/**
|
||||
* 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): StockEntry
|
||||
public function update(StockEntry $stockEntry, array $data, ?string $reason = null): StockEntry
|
||||
{
|
||||
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) $stockEntry->ingredient_id
|
||||
&& (float) $stockEntry->density_used > 0) {
|
||||
$data['density_used'] = (float) $stockEntry->density_used;
|
||||
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)]),
|
||||
]);
|
||||
}
|
||||
$data = $this->applyPurchaseUnit($data);
|
||||
}
|
||||
|
||||
$data = $this->resolvePrices($data);
|
||||
if (! array_key_exists('received_quantity', $data) || $data['received_quantity'] === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$stockEntry->update($data);
|
||||
$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 $stockEntry->fresh();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue