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',
|
'quality',
|
||||||
'orderedByUser.account',
|
'orderedByUser.account',
|
||||||
'receivedByUser.account',
|
'receivedByUser.account',
|
||||||
|
'changes.user',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return view('admin.inventory.stock-entries.show', array_merge($this->formSharedData(), [
|
return view('admin.inventory.stock-entries.show', array_merge($this->formSharedData(), [
|
||||||
|
|
@ -105,16 +106,13 @@ class StockEntryController extends Controller
|
||||||
if (! auth()->user()->isAdmin()) {
|
if (! auth()->user()->isAdmin()) {
|
||||||
return redirect()->route('home');
|
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']);
|
$stockEntry->load(['ingredient', 'packagingItem']);
|
||||||
|
|
||||||
return view('admin.inventory.stock-entries.edit', array_merge($this->formSharedData(), [
|
return view('admin.inventory.stock-entries.edit', array_merge($this->formSharedData(), [
|
||||||
'model' => $stockEntry,
|
'model' => $stockEntry,
|
||||||
|
'correctionBlockers' => $stockEntry->isReceived() ? app(DeletionGuard::class)->blockersFor($stockEntry) : [],
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,17 +121,18 @@ class StockEntryController extends Controller
|
||||||
if (! $request->user()->isAdmin()) {
|
if (! $request->user()->isAdmin()) {
|
||||||
return redirect()->route('home');
|
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');
|
\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
|
public function copy(StockEntry $stockEntry): RedirectResponse
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ class UpdateStockEntryRequest extends FormRequest
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
$received = $this->isReceivedEntry();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'entry_type' => ['required', Rule::in(['ingredient', 'packaging', 'shipping'])],
|
'entry_type' => ['required', Rule::in(['ingredient', 'packaging', 'shipping'])],
|
||||||
'ingredient_id' => ['nullable', 'integer', 'exists:ingredients,id'],
|
'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' => ['nullable', 'numeric', 'min:0'],
|
||||||
'price_per_kg_gross' => ['nullable', 'numeric', 'min:0'],
|
'price_per_kg_gross' => ['nullable', 'numeric', 'min:0'],
|
||||||
'price_total' => ['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>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|
@ -47,6 +68,9 @@ class UpdateStockEntryRequest extends FormRequest
|
||||||
'quality_id.required' => __('Bitte eine Rohstoffqualität wählen.'),
|
'quality_id.required' => __('Bitte eine Rohstoffqualität wählen.'),
|
||||||
'price_per_kg.required' => __('Bitte den Netto- oder Brutto-Preis pro kg angeben.'),
|
'price_per_kg.required' => __('Bitte den Netto- oder Brutto-Preis pro kg angeben.'),
|
||||||
'price_total.required' => __('Bitte den Gesamtpreis netto 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([
|
$this->merge([
|
||||||
'ordered_quantity' => normalizeWawiQuantity($this->input('ordered_quantity')),
|
'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' => $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_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,
|
'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.'));
|
$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');
|
$net = $this->input('price_per_kg');
|
||||||
$gross = $this->input('price_per_kg_gross');
|
$gross = $this->input('price_per_kg_gross');
|
||||||
$hasNet = is_numeric($net) && (float) $net > 0;
|
$hasNet = is_numeric($net) && (float) $net > 0;
|
||||||
|
|
@ -115,11 +155,18 @@ class UpdateStockEntryRequest extends FormRequest
|
||||||
public function validatedPayload(): array
|
public function validatedPayload(): array
|
||||||
{
|
{
|
||||||
$data = $this->validated();
|
$data = $this->validated();
|
||||||
|
unset($data['change_reason']);
|
||||||
if (($data['entry_type'] ?? '') === 'ingredient') {
|
if (($data['entry_type'] ?? '') === 'ingredient') {
|
||||||
$data['packaging_item_id'] = null;
|
$data['packaging_item_id'] = null;
|
||||||
} else {
|
} else {
|
||||||
$data['ingredient_id'] = null;
|
$data['ingredient_id'] = null;
|
||||||
$data['quality_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;
|
return $data;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use Database\Factories\StockEntryFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
class StockEntry extends Model
|
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>
|
* @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\Ingredient;
|
||||||
use App\Models\StockEntry;
|
use App\Models\StockEntry;
|
||||||
|
use App\Models\StockEntryChange;
|
||||||
use App\Models\TaxRate;
|
use App\Models\TaxRate;
|
||||||
|
use App\Services\DeletionGuard;
|
||||||
|
use App\Services\InventoryService;
|
||||||
|
use App\Services\ProductionService;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
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
|
* @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)) {
|
return DB::transaction(function () use ($stockEntry, $data, $reason) {
|
||||||
$data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece';
|
$locked = StockEntry::query()->lockForUpdate()->findOrFail($stockEntry->id);
|
||||||
// N-06: Dichte-Snapshot nur bei unverändertem Rohstoff als Rückfall mitgeben —
|
$before = $this->snapshot($locked);
|
||||||
// eine fremde alte Dichte darf bei Rohstoffwechsel nicht übernommen werden.
|
|
||||||
if (($data['ordered_unit'] ?? null) === 'ml'
|
if (array_key_exists('entry_type', $data)) {
|
||||||
&& (int) ($data['ingredient_id'] ?? 0) === (int) $stockEntry->ingredient_id
|
$data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece';
|
||||||
&& (float) $stockEntry->density_used > 0) {
|
// N-06: Dichte-Snapshot nur bei unverändertem Rohstoff als Rückfall mitgeben —
|
||||||
$data['density_used'] = (float) $stockEntry->density_used;
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Änderungsprotokoll für Einkäufe/Wareneingänge (Anforderung 14.09.2026):
|
||||||
|
* Einträge — auch bereits gebuchte — dürfen nachträglich korrigiert werden
|
||||||
|
* (Tippfehler, Nachmeldung). Jede Korrektur hält Wer/Wann/Warum und die
|
||||||
|
* geänderten Felder (alt → neu) fest, damit der Bestand nachvollziehbar bleibt.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('stock_entry_changes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('stock_entry_id')->constrained('stock_entries')->cascadeOnDelete();
|
||||||
|
$table->unsignedInteger('user_id')->nullable();
|
||||||
|
$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
|
||||||
|
$table->string('status_before', 20);
|
||||||
|
$table->string('reason', 500)->nullable();
|
||||||
|
$table->json('field_changes');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['stock_entry_id', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('stock_entry_changes');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -310,10 +310,26 @@ dadurch für die Bedienung ändert:
|
||||||
- **iPad:** Das Notizfeld der Produktion wächst beim Tippen automatisch mit und lässt
|
- **iPad:** Das Notizfeld der Produktion wächst beim Tippen automatisch mit und lässt
|
||||||
sich vergrößern; die Zwischenspeichern-Buttons sind über die Seite verteilt.
|
sich vergrößern; die Zwischenspeichern-Buttons sind über die Seite verteilt.
|
||||||
|
|
||||||
|
### Neu 14.09.2026 (Einkauf: Einheit, Dichte, Korrekturen)
|
||||||
|
|
||||||
|
- **Einheit als Pflichtfeld:** Beim Rohstoff-Einkauf steht die Einheit (g/ml) als
|
||||||
|
eigenes Feld **über** der Menge und muss bewusst gewählt werden („Bitte wählen“).
|
||||||
|
Bei ml zeigt das Mengenfeld live „ml × Dichte = g“.
|
||||||
|
- **Dichte selbsterklärend:** Hilfetexte am Rohstoff trennen Schwundfaktor (Rezeptur)
|
||||||
|
und Dichte (nur ml-Einkauf); Live-Vorschau „1 ml = x g“.
|
||||||
|
- **Korrigieren mit Änderungsprotokoll:** Einkäufe lassen sich jetzt **auch nach der
|
||||||
|
Buchung** korrigieren (Liste → Stift, Detailseite → **„Korrigieren“**): Menge,
|
||||||
|
Eingangsdatum, Charge, MHD, Qualität, Preise, Lieferant, Lagerort. Der **Grund ist
|
||||||
|
Pflicht**; jede Änderung landet mit Datum, Bearbeiter und „alt → neu“ im
|
||||||
|
**Änderungsprotokoll** unten auf der Detailseite. Schutz: Die Menge darf nicht unter
|
||||||
|
den bereits verbrauchten Anteil fallen, und bei Verbrauch sind Art und
|
||||||
|
Rohstoff/Artikel gesperrt. Auch Änderungen an offenen Bestellungen werden
|
||||||
|
protokolliert (Grund dort optional).
|
||||||
|
|
||||||
### In Vorbereitung / geplant
|
### In Vorbereitung / geplant
|
||||||
|
|
||||||
- **Versand im System** (DHL-Etiketten per API, AP-32 — bis dahin gilt die
|
- **Versand im System** (DHL-Etiketten per API, AP-32 — bis dahin gilt die
|
||||||
Brückenlösung „DHL-Label aus dem Shop", s. o.), **Audit-Trail**,
|
Brückenlösung „DHL-Label aus dem Shop", s. o.), **Audit-Trail** (für Einkäufe seit 14.09. als Änderungsprotokoll vorhanden),
|
||||||
**blockbasierte Rechte**, **2FA für Admins**,
|
**blockbasierte Rechte**, **2FA für Admins**,
|
||||||
**WaWi-Einstellungsseite** (Schwellwerte/Defaults zentral).
|
**WaWi-Einstellungsseite** (Schwellwerte/Defaults zentral).
|
||||||
- **Produktentwicklung:** Menüpunkt ist als Platzhalter angelegt – das genaue
|
- **Produktentwicklung:** Menüpunkt ist als Platzhalter angelegt – das genaue
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Testleitfaden Warenwirtschaft — Version 3.1
|
# Testleitfaden Warenwirtschaft — Version 3.2
|
||||||
|
|
||||||
> **Stand:** 09.09.2026
|
> **Stand:** 14.09.2026
|
||||||
> Dieser Leitfaden führt einmal durch die Funktionen der Warenwirtschaft — in der Reihenfolge eines typischen Arbeitsablaufs, sodass jede Stelle direkt im Browser nachgeschaut und getestet werden kann.
|
> Dieser Leitfaden führt einmal durch die Funktionen der Warenwirtschaft — in der Reihenfolge eines typischen Arbeitsablaufs, sodass jede Stelle direkt im Browser nachgeschaut und getestet werden kann.
|
||||||
> Die fachlichen Erklärungen dazu stehen wie gewohnt in der App unter den **[Hinweisen zur Warenwirtschaft](/admin/inventory/notices)**.
|
> Die fachlichen Erklärungen dazu stehen wie gewohnt in der App unter den **[Hinweisen zur Warenwirtschaft](/admin/inventory/notices)**.
|
||||||
>
|
>
|
||||||
|
|
@ -423,10 +423,23 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 27. Einkauf korrigieren mit Änderungsprotokoll (14.09.2026)
|
||||||
|
|
||||||
|
**Neu:** Einkäufe/Wareneingänge sind auch **nach der Buchung** korrigierbar (Tippfehler, Lieferschein nachgetragen). Jede Korrektur hält **wer/wann/warum** und die geänderten Werte **alt → neu** fest.
|
||||||
|
|
||||||
|
**So testen:**
|
||||||
|
1. Gebuchten Eingang öffnen → **„Korrigieren“** (Liste: Stift-Symbol). Menge 900 → 850 g ändern, Grund eintragen, speichern → Rohstoffbestand sinkt um 50 g; unten auf der Detailseite erscheint das **Änderungsprotokoll** mit Grund und „900,00 g → 850,00 g“.
|
||||||
|
2. Ohne Grund speichern → Fehlermeldung, nichts geändert.
|
||||||
|
3. Aus einem Eingang 400 g verbrauchen (Produktion/Ausschuss), dann Menge auf 300 g korrigieren → abgelehnt mit Hinweis auf den Verbrauch; 400 g geht durch. Rohstoff wechseln → abgelehnt (gesperrt).
|
||||||
|
4. ml-Eingang: Formular zeigt die Mengen in ml, Korrektur wird über die gespeicherte Dichte in Gramm gebucht (Live-Vorschau am Feld).
|
||||||
|
5. Offene Bestellung bearbeiten → ebenfalls Protokollzeile, Grund dort optional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Hinweis am Rande
|
## Hinweis am Rande
|
||||||
|
|
||||||
Die Listen **Einkauf & Wareneingang**, **Produktionen** und die **Produktbestand-Historie** zeigen aus Performance-Gründen die neuesten 500 Einträge; darüber erscheint ein Link **„Alle anzeigen"**. Offene Bestellungen sind immer vollständig gelistet.
|
Die Listen **Einkauf & Wareneingang**, **Produktionen** und die **Produktbestand-Historie** zeigen aus Performance-Gründen die neuesten 500 Einträge; darüber erscheint ein Link **„Alle anzeigen"**. Offene Bestellungen sind immer vollständig gelistet.
|
||||||
|
|
||||||
## Was bewusst noch nicht dabei ist (nachgelagert)
|
## Was bewusst noch nicht dabei ist (nachgelagert)
|
||||||
|
|
||||||
Versand im System (DHL-Etiketten per API), Audit-Trail, 2FA, blockbasierte Rechte und die WaWi-Einstellungsseite folgen in der nächsten Runde.
|
Versand im System (DHL-Etiketten per API), ein systemweiter Audit-Trail (für Einkäufe siehe Abschnitt 27), 2FA, blockbasierte Rechte und die WaWi-Einstellungsseite folgen in der nächsten Runde.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,29 @@
|
||||||
@php
|
@php
|
||||||
/** @var \App\Models\StockEntry $model */
|
/** @var \App\Models\StockEntry $model */
|
||||||
$isEdit = $model->exists;
|
$isEdit = $model->exists;
|
||||||
|
// Anforderung 14.09.2026: gebuchte Eingänge korrigierbar. Wurde daraus schon
|
||||||
|
// verbraucht, sind Art und Rohstoff/Artikel gesperrt (Bestand bliebe sonst inkonsistent).
|
||||||
|
$isReceivedEdit = $isEdit && $model->isReceived();
|
||||||
|
$correctionBlockers = $correctionBlockers ?? [];
|
||||||
|
$lockItem = $isReceivedEdit && $correctionBlockers !== [];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
|
@if($isReceivedEdit)
|
||||||
|
<div class="alert alert-warning py-2 px-3">
|
||||||
|
<strong>{{ __('Korrektur eines gebuchten Eingangs.') }}</strong>
|
||||||
|
{{ __('Änderungen wirken sofort auf den Bestand und werden mit Grund im Änderungsprotokoll festgehalten.') }}
|
||||||
|
@if($lockItem)
|
||||||
|
<br>{{ __('Aus diesem Eingang wurde bereits verbraucht (:blockers) — Art und Rohstoff/Artikel sind deshalb gesperrt.', ['blockers' => implode(', ', $correctionBlockers)]) }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="entry_type">{{ __('Art') }} <span class="text-danger">*</span></label>
|
<label for="entry_type">{{ __('Art') }} <span class="text-danger">*</span></label>
|
||||||
<select name="entry_type" id="entry_type" class="form-control @error('entry_type') is-invalid @enderror" required>
|
@if($lockItem)
|
||||||
|
<input type="hidden" name="entry_type" value="{{ $model->entry_type }}">
|
||||||
|
@endif
|
||||||
|
<select name="entry_type" id="entry_type" class="form-control @error('entry_type') is-invalid @enderror" required @disabled($lockItem)>
|
||||||
@foreach ($entryTypeLabels as $value => $label)
|
@foreach ($entryTypeLabels as $value => $label)
|
||||||
<option value="{{ $value }}" @selected(old('entry_type', $model->entry_type ?? 'ingredient') === $value)>{{ $label }}</option>
|
<option value="{{ $value }}" @selected(old('entry_type', $model->entry_type ?? 'ingredient') === $value)>{{ $label }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
@ -17,9 +35,12 @@
|
||||||
|
|
||||||
<div id="stock-entry-ingredient-block" class="form-group" style="display:none;">
|
<div id="stock-entry-ingredient-block" class="form-group" style="display:none;">
|
||||||
<label for="ingredient_id">{{ __('Inhaltsstoff') }} <span class="text-danger">*</span></label>
|
<label for="ingredient_id">{{ __('Inhaltsstoff') }} <span class="text-danger">*</span></label>
|
||||||
|
@if($lockItem && $model->ingredient_id)
|
||||||
|
<input type="hidden" name="ingredient_id" value="{{ $model->ingredient_id }}">
|
||||||
|
@endif
|
||||||
<div class="light-style">
|
<div class="light-style">
|
||||||
<select name="ingredient_id" id="ingredient_id" class="w-100"
|
<select name="ingredient_id" id="ingredient_id" class="w-100"
|
||||||
data-search-url="{{ route('admin.inventory.api.ingredients.search') }}">
|
data-search-url="{{ route('admin.inventory.api.ingredients.search') }}" @disabled($lockItem)>
|
||||||
@if ($model->ingredient_id && $model->ingredient)
|
@if ($model->ingredient_id && $model->ingredient)
|
||||||
{{-- N-06: fehlt die Stammdaten-Dichte inzwischen, gilt der Dichte-Snapshot der Bestellung --}}
|
{{-- N-06: fehlt die Stammdaten-Dichte inzwischen, gilt der Dichte-Snapshot der Bestellung --}}
|
||||||
<option value="{{ $model->ingredient_id }}" data-density="{{ $model->ingredient->density ?? $model->density_used }}" selected>{{ $model->ingredient->name }}@if ($model->ingredient->inci)
|
<option value="{{ $model->ingredient_id }}" data-density="{{ $model->ingredient->density ?? $model->density_used }}" selected>{{ $model->ingredient->name }}@if ($model->ingredient->inci)
|
||||||
|
|
@ -51,9 +72,12 @@
|
||||||
|
|
||||||
<div id="stock-entry-packaging-block" class="form-group" style="display:none;">
|
<div id="stock-entry-packaging-block" class="form-group" style="display:none;">
|
||||||
<label for="packaging_item_id">{{ __('Verpackungsartikel') }} <span class="text-danger">*</span></label>
|
<label for="packaging_item_id">{{ __('Verpackungsartikel') }} <span class="text-danger">*</span></label>
|
||||||
|
@if($lockItem && $model->packaging_item_id)
|
||||||
|
<input type="hidden" name="packaging_item_id" value="{{ $model->packaging_item_id }}">
|
||||||
|
@endif
|
||||||
<div class="light-style">
|
<div class="light-style">
|
||||||
<select name="packaging_item_id" id="packaging_item_id" class="w-100"
|
<select name="packaging_item_id" id="packaging_item_id" class="w-100"
|
||||||
data-search-url="{{ route('admin.inventory.api.packaging-items.search') }}">
|
data-search-url="{{ route('admin.inventory.api.packaging-items.search') }}" @disabled($lockItem)>
|
||||||
@if ($model->packaging_item_id && $model->packagingItem)
|
@if ($model->packaging_item_id && $model->packagingItem)
|
||||||
<option value="{{ $model->packaging_item_id }}" selected>{{ $model->packagingItem->name }}</option>
|
<option value="{{ $model->packaging_item_id }}" selected>{{ $model->packagingItem->name }}</option>
|
||||||
@elseif(old('packaging_item_id'))
|
@elseif(old('packaging_item_id'))
|
||||||
|
|
@ -200,3 +224,71 @@
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if($isReceivedEdit)
|
||||||
|
@php
|
||||||
|
$receivedDisplay = $model->received_quantity !== null
|
||||||
|
? formatWawiQuantity($orderedIsMl ? $model->received_quantity / $model->density_used : $model->received_quantity)
|
||||||
|
: '';
|
||||||
|
@endphp
|
||||||
|
<hr>
|
||||||
|
<h6 class="font-weight-bold">{{ __('Wareneingang (gebucht)') }}</h6>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="received_at">{{ __('Eingangsdatum') }} <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="received_at" id="received_at" autocomplete="off" required
|
||||||
|
class="form-control datepicker-base @error('received_at') is-invalid @enderror"
|
||||||
|
value="{{ old('received_at', $model->received_at?->format('d.m.Y')) }}">
|
||||||
|
@error('received_at')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="received_quantity">{{ __('Eingegangene Menge') }} <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="received_quantity" id="received_quantity" required
|
||||||
|
class="form-control @error('received_quantity') is-invalid @enderror"
|
||||||
|
value="{{ old('received_quantity', $receivedDisplay) }}">
|
||||||
|
<small class="text-muted d-block" id="received-quantity-hint">{{ $model->unit === 'piece' ? __('Angabe in Stück.') : __('Angabe in der oben gewählten Einheit.') }}</small>
|
||||||
|
<small class="d-block font-weight-bold text-body mt-1" id="received-quantity-preview" style="display:none;"></small>
|
||||||
|
@error('received_quantity')
|
||||||
|
<div class="invalid-feedback d-block">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="received-ingredient-block" style="display:none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="batch_number">{{ __('Chargennummer') }} <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="batch_number" id="batch_number" maxlength="100"
|
||||||
|
class="form-control @error('batch_number') is-invalid @enderror"
|
||||||
|
value="{{ old('batch_number', $model->batch_number) }}">
|
||||||
|
@error('batch_number')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="best_before">{{ __('Mindesthaltbarkeit') }} <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="best_before" id="best_before" autocomplete="off"
|
||||||
|
class="form-control datepicker-base @error('best_before') is-invalid @enderror"
|
||||||
|
value="{{ old('best_before', $model->best_before?->format('d.m.Y')) }}">
|
||||||
|
@error('best_before')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($isEdit)
|
||||||
|
<hr>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="change_reason">{{ __('Grund der Änderung') }} @if($isReceivedEdit)<span class="text-danger">*</span>@endif</label>
|
||||||
|
<input type="text" name="change_reason" id="change_reason" maxlength="500" @required($isReceivedEdit)
|
||||||
|
class="form-control @error('change_reason') is-invalid @enderror"
|
||||||
|
value="{{ old('change_reason') }}" placeholder="{{ __('z. B. Tippfehler bei der Menge, Lieferschein nachgetragen') }}">
|
||||||
|
<small class="text-muted d-block">{{ __('Wird mit Datum, Bearbeiter und den geänderten Werten im Änderungsprotokoll des Eintrags gespeichert.') }}</small>
|
||||||
|
@error('change_reason')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,34 @@
|
||||||
$('#stock-entry-packaging-block').toggle(!isIng);
|
$('#stock-entry-packaging-block').toggle(!isIng);
|
||||||
$('#price-per-kg-block').toggle(isIng);
|
$('#price-per-kg-block').toggle(isIng);
|
||||||
$('#price-total-block').toggle(!isIng);
|
$('#price-total-block').toggle(!isIng);
|
||||||
|
$('#received-ingredient-block').toggle(isIng);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Korrektur eines gebuchten Eingangs: die eingegangene Menge folgt derselben
|
||||||
|
// Einheit wie die Bestellung (Hinweis + ml→g-Vorschau am Feld).
|
||||||
|
function applyReceivedUnit(isIng, unit, density, hasDensity) {
|
||||||
|
var $rq = $('#received_quantity');
|
||||||
|
if (!$rq.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var $hint = $('#received-quantity-hint');
|
||||||
|
var $preview = $('#received-quantity-preview');
|
||||||
|
$preview.hide().text('');
|
||||||
|
if (!isIng) {
|
||||||
|
$hint.text('{{ __('Angabe in Stück.') }}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (unit === 'ml' && hasDensity) {
|
||||||
|
$hint.text('{{ __('Angabe in ml — der Bestand wird über die Dichte in Gramm gebucht.') }}');
|
||||||
|
var ml = parseNumber($rq.val());
|
||||||
|
if (ml != null && ml > 0) {
|
||||||
|
$preview.text(formatQuantity(ml) + ' ml × ' + formatDensity(density) + ' g/ml = ' + formatQuantity(ml * density) + ' g').show();
|
||||||
|
}
|
||||||
|
} else if (unit === 'gram') {
|
||||||
|
$hint.text('{{ __('Angabe in Gramm.') }}');
|
||||||
|
} else {
|
||||||
|
$hint.text('{{ __('Bitte zuerst oben die Einheit wählen.') }}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initIngredientSelect2() {
|
function initIngredientSelect2() {
|
||||||
|
|
@ -95,6 +123,7 @@
|
||||||
var $hint = $('#ordered-quantity-hint');
|
var $hint = $('#ordered-quantity-hint');
|
||||||
var $preview = $('#ordered-quantity-preview');
|
var $preview = $('#ordered-quantity-preview');
|
||||||
$preview.hide().text('');
|
$preview.hide().text('');
|
||||||
|
applyReceivedUnit(isIng, $unit.val(), density, hasDensity);
|
||||||
if (!isIng) {
|
if (!isIng) {
|
||||||
$hint.text('{{ __('Bei Verpackung in Stück.') }}');
|
$hint.text('{{ __('Bei Verpackung in Stück.') }}');
|
||||||
return;
|
return;
|
||||||
|
|
@ -186,6 +215,7 @@
|
||||||
$('#ingredient_id').on('change', applyOrderedUnit);
|
$('#ingredient_id').on('change', applyOrderedUnit);
|
||||||
$('#ordered_unit').on('change', applyOrderedUnit);
|
$('#ordered_unit').on('change', applyOrderedUnit);
|
||||||
$('#ordered_quantity').on('input', applyOrderedUnit);
|
$('#ordered_quantity').on('input', applyOrderedUnit);
|
||||||
|
$('#received_quantity').on('input', applyOrderedUnit);
|
||||||
|
|
||||||
$('#price_per_kg').on('input', recalcFromNet);
|
$('#price_per_kg').on('input', recalcFromNet);
|
||||||
$('#price_per_kg_gross').on('input', recalcFromGross);
|
$('#price_per_kg_gross').on('input', recalcFromGross);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
@extends('layouts.layout-2')
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<h4 class="font-weight-bold py-2 mb-2">{{ __('Bestellung bearbeiten') }}</h4>
|
<h4 class="font-weight-bold py-2 mb-2">{{ $model->isReceived() ? __('Wareneingang korrigieren') : __('Bestellung bearbeiten') }}</h4>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="{{ route('admin.inventory.stock-entries.update', $model) }}">
|
<form method="post" action="{{ route('admin.inventory.stock-entries.update', $model) }}">
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
@method('PUT')
|
@method('PUT')
|
||||||
@include('admin.inventory.stock-entries._form')
|
@include('admin.inventory.stock-entries._form')
|
||||||
<button type="submit" class="btn btn-primary">{{ __('Speichern') }}</button>
|
<button type="submit" class="btn btn-primary">{{ __('Speichern') }}</button>
|
||||||
<a href="{{ route('admin.inventory.stock-entries.index') }}" class="btn btn-outline-secondary">{{ __('Zurück') }}</a>
|
<a href="{{ $model->isReceived() ? route('admin.inventory.stock-entries.show', $model) : route('admin.inventory.stock-entries.index') }}" class="btn btn-outline-secondary">{{ __('Zurück') }}</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,8 @@
|
||||||
<a href="{{ route('admin.inventory.stock-entries.show', $row) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Ansicht') }}">
|
<a href="{{ route('admin.inventory.stock-entries.show', $row) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Ansicht') }}">
|
||||||
<span class="far fa-eye"></span>
|
<span class="far fa-eye"></span>
|
||||||
</a>
|
</a>
|
||||||
@if(Auth::user()->isAdmin() && $row->status === 'pending')
|
@if(Auth::user()->isAdmin())
|
||||||
<a href="{{ route('admin.inventory.stock-entries.edit', $row) }}" class="btn icon-btn btn-sm btn-secondary" title="{{ __('Bearbeiten') }}">
|
<a href="{{ route('admin.inventory.stock-entries.edit', $row) }}" class="btn icon-btn btn-sm btn-secondary" title="{{ $row->status === 'pending' ? __('Bearbeiten') : __('Korrigieren (mit Protokoll)') }}">
|
||||||
<span class="far fa-edit"></span>
|
<span class="far fa-edit"></span>
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@
|
||||||
<button type="submit" class="btn btn-sm btn-outline-info">{{ __('Duplizieren') }}</button>
|
<button type="submit" class="btn btn-sm btn-outline-info">{{ __('Duplizieren') }}</button>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
@if(Auth::user()->isAdmin() && $model->isPending())
|
@if(Auth::user()->isAdmin())
|
||||||
<a href="{{ route('admin.inventory.stock-entries.edit', $model) }}" class="btn btn-sm btn-primary">{{ __('Bearbeiten') }}</a>
|
<a href="{{ route('admin.inventory.stock-entries.edit', $model) }}" class="btn btn-sm btn-primary">{{ $model->isPending() ? __('Bearbeiten') : __('Korrigieren') }}</a>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -262,6 +262,45 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- Anforderung 14.09.2026: Änderungsprotokoll nachträglicher Korrekturen --}}
|
||||||
|
@if($model->changes->isNotEmpty())
|
||||||
|
<div class="card wawi-card">
|
||||||
|
<div class="wawi-card__header">{{ __('Änderungsprotokoll') }} <span class="text-muted font-weight-normal">({{ $model->changes->count() }})</span></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm wawi-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:11rem">{{ __('Wann / Wer') }}</th>
|
||||||
|
<th>{{ __('Grund') }}</th>
|
||||||
|
<th>{{ __('Geändert (alt → neu)') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach($model->changes as $change)
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
{{ $change->created_at->format('d.m.Y H:i') }}<br>
|
||||||
|
<span class="text-muted small">{{ $change->user?->getFullName(false) ?: $change->user?->email ?? '—' }}</span>
|
||||||
|
@if($change->status_before === 'received')
|
||||||
|
<br><span class="wawi-pill wawi-pill--warning">{{ __('nach Buchung') }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>{{ $change->reason ?: '—' }}</td>
|
||||||
|
<td>
|
||||||
|
@forelse($change->describe() as $row)
|
||||||
|
<div><span class="text-muted">{{ $row['label'] }}:</span> {{ $row['old'] }} → <strong>{{ $row['new'] }}</strong></div>
|
||||||
|
@empty
|
||||||
|
<span class="text-muted">{{ __('keine Feldänderung') }}</span>
|
||||||
|
@endforelse
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
|
|
||||||
202
tests/Feature/StockEntryCorrectionTest.php
Normal file
202
tests/Feature/StockEntryCorrectionTest.php
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Ingredient;
|
||||||
|
use App\Models\Location;
|
||||||
|
use App\Models\MaterialQuality;
|
||||||
|
use App\Models\PackagingItem;
|
||||||
|
use App\Models\StockDisposal;
|
||||||
|
use App\Models\StockEntry;
|
||||||
|
use App\Models\StockEntryChange;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\Services\InventoryService;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anforderung 14.09.2026: Einkäufe/Wareneingänge nachträglich korrigierbar —
|
||||||
|
* auch gebuchte — mit Änderungsprotokoll (wer/wann/warum, alt → neu).
|
||||||
|
*/
|
||||||
|
function corrAdmin(): User
|
||||||
|
{
|
||||||
|
$user = User::query()->create(['email' => uniqid('corr_', true).'@test.example', 'password' => bcrypt('password')]);
|
||||||
|
$user->forceFill(['admin' => 7, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
|
||||||
|
|
||||||
|
return $user->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: User, 1: Ingredient, 2: Location, 3: Supplier, 4: MaterialQuality, 5: StockEntry}
|
||||||
|
*/
|
||||||
|
function corrReceived(?float $density = null, float $received = 1000): array
|
||||||
|
{
|
||||||
|
$admin = corrAdmin();
|
||||||
|
$ing = Ingredient::query()->create(['name' => 'Corr-'.uniqid('', true), 'active' => true, 'pos' => 0, 'density' => $density]);
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$quality = MaterialQuality::factory()->create();
|
||||||
|
$entry = StockEntry::query()->create([
|
||||||
|
'entry_type' => 'ingredient', 'ingredient_id' => $ing->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id,
|
||||||
|
'unit' => 'gram', 'purchase_unit' => $density ? 'ml' : 'gram', 'density_used' => $density,
|
||||||
|
'ordered_by' => $admin->id, 'ordered_at' => '2026-09-01', 'ordered_quantity' => $received, 'price_per_kg' => 10, 'price_per_kg_gross' => 10,
|
||||||
|
'status' => 'received', 'received_by' => $admin->id, 'received_at' => '2026-09-05', 'received_quantity' => $received,
|
||||||
|
'batch_number' => 'CH-1', 'best_before' => '2027-01-01', 'quality_id' => $quality->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$admin, $ing, $location, $supplier, $quality, $entry];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
function corrPayload(StockEntry $entry, array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_merge([
|
||||||
|
'entry_type' => 'ingredient', 'ingredient_id' => (string) $entry->ingredient_id, 'supplier_id' => (string) $entry->supplier_id,
|
||||||
|
'location_id' => (string) $entry->location_id, 'quality_id' => (string) $entry->quality_id, 'ordered_at' => '01.09.2026',
|
||||||
|
'ordered_quantity' => '1000', 'ordered_unit' => 'gram', 'price_per_kg' => '10,00',
|
||||||
|
'received_at' => '05.09.2026', 'received_quantity' => '1000', 'batch_number' => 'CH-1', 'best_before' => '01.01.2027',
|
||||||
|
'change_reason' => 'Tippfehler bei der Menge',
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('gebuchter eingang ist korrigierbar und wirkt sofort auf den bestand', function () {
|
||||||
|
[$admin, $ing, , , , $entry] = corrReceived();
|
||||||
|
$this->actingAs($admin, 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.stock-entries.edit', $entry))->assertOk()->assertSee('Wareneingang korrigieren');
|
||||||
|
|
||||||
|
$this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['received_quantity' => '900', 'batch_number' => 'CH-2']))
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect(route('admin.inventory.stock-entries.show', $entry));
|
||||||
|
|
||||||
|
$entry->refresh();
|
||||||
|
expect((float) $entry->received_quantity)->toBe(900.0)
|
||||||
|
->and($entry->batch_number)->toBe('CH-2')
|
||||||
|
->and($entry->status)->toBe('received')
|
||||||
|
->and(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(900.0);
|
||||||
|
|
||||||
|
$change = StockEntryChange::query()->where('stock_entry_id', $entry->id)->firstOrFail();
|
||||||
|
expect($change->user_id)->toBe($admin->id)
|
||||||
|
->and($change->status_before)->toBe('received')
|
||||||
|
->and($change->reason)->toBe('Tippfehler bei der Menge')
|
||||||
|
->and($change->field_changes['received_quantity'])->toEqual(['old' => 1000, 'new' => 900])
|
||||||
|
->and($change->field_changes['batch_number'])->toBe(['old' => 'CH-1', 'new' => 'CH-2'])
|
||||||
|
->and($change->field_changes)->not->toHaveKey('ordered_quantity');
|
||||||
|
|
||||||
|
$rows = collect($change->describe());
|
||||||
|
expect($rows->pluck('label')->all())->toBe(['Eingegangene Menge', 'Charge'])
|
||||||
|
->and($rows[0]['old'])->toBe('1.000,00 g')->and($rows[0]['new'])->toBe('900,00 g');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.stock-entries.show', $entry))
|
||||||
|
->assertOk()->assertSee('Änderungsprotokoll')->assertSee('Tippfehler bei der Menge')->assertSee('CH-1')->assertSee('CH-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('korrektur eines gebuchten eingangs braucht einen grund', function () {
|
||||||
|
[$admin, , , , , $entry] = corrReceived();
|
||||||
|
$this->actingAs($admin, 'user');
|
||||||
|
|
||||||
|
$this->from(route('admin.inventory.stock-entries.edit', $entry))
|
||||||
|
->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['received_quantity' => '900', 'change_reason' => '']))
|
||||||
|
->assertSessionHasErrors('change_reason');
|
||||||
|
|
||||||
|
expect((float) $entry->fresh()->received_quantity)->toBe(1000.0)
|
||||||
|
->and(StockEntryChange::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('korrektur darf die menge nicht unter den verbrauch senken', function () {
|
||||||
|
[$admin, $ing, $location, , , $entry] = corrReceived();
|
||||||
|
$this->actingAs($admin, 'user');
|
||||||
|
|
||||||
|
StockDisposal::query()->create([
|
||||||
|
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => $entry->id, 'location_id' => $location->id,
|
||||||
|
'quantity' => 400, 'unit' => 'gram', 'reason' => 'Test', 'disposed_at' => '2026-09-06', 'user_id' => $admin->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 300 g < 400 g Verbrauch → abgelehnt, nichts geändert, kein Protokoll.
|
||||||
|
$this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['received_quantity' => '300']))
|
||||||
|
->assertRedirect(route('admin.inventory.stock-entries.edit', $entry))
|
||||||
|
->assertSessionHasErrors('received_quantity');
|
||||||
|
expect((float) $entry->fresh()->received_quantity)->toBe(1000.0)
|
||||||
|
->and(StockEntryChange::query()->count())->toBe(0);
|
||||||
|
|
||||||
|
// Rohstoffwechsel bei Verbrauch → abgelehnt.
|
||||||
|
$other = Ingredient::query()->create(['name' => 'Corr-Other', 'active' => true, 'pos' => 0]);
|
||||||
|
$this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['ingredient_id' => (string) $other->id]))
|
||||||
|
->assertSessionHasErrors('ingredient_id');
|
||||||
|
expect((int) $entry->fresh()->ingredient_id)->toBe($ing->id);
|
||||||
|
|
||||||
|
// 400 g = genau der Verbrauch → erlaubt.
|
||||||
|
$this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, ['received_quantity' => '400']))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
expect((float) $entry->fresh()->received_quantity)->toBe(400.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('korrektur eines ml-eingangs rechnet die eingegangene menge über die dichte um', function () {
|
||||||
|
[$admin, , , , , $entry] = corrReceived(0.8, 800);
|
||||||
|
$this->actingAs($admin, 'user');
|
||||||
|
|
||||||
|
// Formular zeigt 1.000 ml (800 g / 0,8); Korrektur auf 900 ml → 720 g.
|
||||||
|
$this->get(route('admin.inventory.stock-entries.edit', $entry))->assertOk()->assertSee('value="1.000,00"', false);
|
||||||
|
|
||||||
|
$this->put(route('admin.inventory.stock-entries.update', $entry), corrPayload($entry, [
|
||||||
|
'ordered_unit' => 'ml', 'ordered_quantity' => '1000', 'received_quantity' => '900',
|
||||||
|
]))->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$entry->refresh();
|
||||||
|
expect((float) $entry->received_quantity)->toBe(720.0)
|
||||||
|
->and((float) $entry->ordered_quantity)->toBe(800.0)
|
||||||
|
->and($entry->purchase_unit)->toBe('ml');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offene bestellung: bearbeiten protokolliert ohne pflichtgrund', function () {
|
||||||
|
[$admin, , , , , $entry] = corrReceived();
|
||||||
|
$entry->update(['status' => 'pending', 'received_at' => null, 'received_quantity' => null, 'batch_number' => null, 'best_before' => null, 'received_by' => null]);
|
||||||
|
$this->actingAs($admin, 'user');
|
||||||
|
|
||||||
|
$this->put(route('admin.inventory.stock-entries.update', $entry), array_diff_key(corrPayload($entry, ['ordered_quantity' => '1200']), array_flip(['received_at', 'received_quantity', 'batch_number', 'best_before', 'change_reason'])))
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect(route('admin.inventory.stock-entries.index'));
|
||||||
|
|
||||||
|
$entry->refresh();
|
||||||
|
expect((float) $entry->ordered_quantity)->toBe(1200.0)->and($entry->status)->toBe('pending');
|
||||||
|
$change = StockEntryChange::query()->firstOrFail();
|
||||||
|
expect($change->status_before)->toBe('pending')->and($change->reason)->toBeNull()
|
||||||
|
->and($change->field_changes)->toHaveKey('ordered_quantity')->and($change->field_changes)->not->toHaveKey('received_quantity');
|
||||||
|
|
||||||
|
// Speichern ohne Änderung erzeugt keine Protokollzeile.
|
||||||
|
$this->put(route('admin.inventory.stock-entries.update', $entry), array_diff_key(corrPayload($entry, ['ordered_quantity' => '1200']), array_flip(['received_at', 'received_quantity', 'batch_number', 'best_before', 'change_reason'])))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
expect(StockEntryChange::query()->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verpackung: korrektur darf den artikelbestand nicht negativ machen', function () {
|
||||||
|
$admin = corrAdmin();
|
||||||
|
$this->actingAs($admin, 'user');
|
||||||
|
$item = PackagingItem::factory()->create();
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$entry = StockEntry::query()->create([
|
||||||
|
'entry_type' => 'packaging', 'packaging_item_id' => $item->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id,
|
||||||
|
'unit' => 'piece', 'ordered_by' => $admin->id, 'ordered_at' => '2026-09-01', 'ordered_quantity' => 100, 'price_total' => 50,
|
||||||
|
'status' => 'received', 'received_by' => $admin->id, 'received_at' => '2026-09-05', 'received_quantity' => 100,
|
||||||
|
]);
|
||||||
|
StockDisposal::query()->create([
|
||||||
|
'disposal_type' => 'packaging', 'packaging_item_id' => $item->id, 'location_id' => $location->id,
|
||||||
|
'quantity' => 60, 'unit' => 'piece', 'reason' => 'Test', 'disposed_at' => '2026-09-06', 'user_id' => $admin->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$base = [
|
||||||
|
'entry_type' => 'packaging', 'packaging_item_id' => (string) $item->id, 'supplier_id' => (string) $supplier->id,
|
||||||
|
'location_id' => (string) $location->id, 'ordered_at' => '01.09.2026', 'ordered_quantity' => '100', 'price_total' => '50,00',
|
||||||
|
'received_at' => '05.09.2026', 'change_reason' => 'Zählfehler',
|
||||||
|
];
|
||||||
|
$this->put(route('admin.inventory.stock-entries.update', $entry), $base + ['received_quantity' => '50'])
|
||||||
|
->assertSessionHasErrors('received_quantity');
|
||||||
|
$this->put(route('admin.inventory.stock-entries.update', $entry), $base + ['received_quantity' => '60'])
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
expect((float) $entry->fresh()->received_quantity)->toBe(60.0)
|
||||||
|
->and(app(InventoryService::class)->remainingByPackagingItem([$item->id])[$item->id])->toBe(0.0);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue