Warenwirtschaft: Anforderungsrunde 12.06. — Plan V5.0 + AP-26/AP-25/AP-22

Neue Anforderungen (docs/) interpretiert und als Entwicklungsplan V5.0
(AP-20 bis AP-28) aufgenommen; erste drei Pakete umgesetzt:

AP-26 Ausschuss-Gründe konfigurierbar:
- Stammdaten-Tabelle disposal_reasons + CRUD unter Einstellungen → Allgemein
- StockDisposalController liest aktive DB-Gründe statt hartkodierter Liste
- Seeder übernimmt die bisherigen 6 Gründe idempotent

AP-25 Lieferbestand — Datum statt Tage:
- "Nicht vorrätig" wird über Datepicker "Wieder lieferbar ab" gepflegt;
  Resttage-Hinweis zählt täglich automatisch herunter
- Interne Bestellliste wieder kaufbar: Hinweis erscheint zusätzlich zu
  den Mengen-Buttons (VP entscheidet selbst)

AP-22 Produktbestand-Erweiterungen:
- Default-Sortierung nach Dringlichkeit, Status-Kopf toggelt
- Alle vier Status-Kacheln als Filter klickbar
- Neue Spalte "Verbrauch/Monat" (Ø Abgänge der letzten 6 Monate)
- Produkt-Flag "Im Produktbestand anzeigen" (products.show_in_product_stock)

Tests: 77 grün (DisposalReasonSettings 8, ProductOutOfStock 8,
ProductStock 13 + Regression). Hinweise-Doku + Plan-Protokoll fortgeschrieben;
nächster Schritt laut Plan: AP-21 (INCI-Erweiterungen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 16:28:45 +00:00
parent a8f6fef38e
commit e53201f229
32 changed files with 1377 additions and 94 deletions

View file

@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Http\Requests\Inventory\StoreDisposalReasonRequest;
use App\Http\Requests\Inventory\UpdateDisposalReasonRequest;
use App\Models\DisposalReason;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
class DisposalReasonController extends Controller
{
public function create(): View
{
return view('admin.inventory.disposal-reasons.form', [
'model' => new DisposalReason(['active' => true, 'pos' => 0]),
]);
}
public function store(StoreDisposalReasonRequest $request): RedirectResponse
{
DisposalReason::create($request->validated());
\Session::flash('alert-save', '1');
return redirect()->route('admin.inventory.general');
}
public function edit(DisposalReason $disposalReason): View
{
return view('admin.inventory.disposal-reasons.form', [
'model' => $disposalReason,
]);
}
public function update(UpdateDisposalReasonRequest $request, DisposalReason $disposalReason): RedirectResponse
{
$disposalReason->update($request->validated());
\Session::flash('alert-save', '1');
return redirect()->route('admin.inventory.general');
}
public function destroy(DisposalReason $disposalReason): RedirectResponse
{
$disposalReason->delete();
\Session::flash('alert-success', __('Eintrag gelöscht'));
return redirect()->route('admin.inventory.general');
}
}

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Models\DeliveryTime;
use App\Models\DisposalReason;
use App\Models\TaxRate;
use Illuminate\Contracts\View\View;
@ -14,6 +15,7 @@ class GeneralSettingController extends Controller
return view('admin.inventory.general.index', [
'taxRates' => TaxRate::query()->orderBy('pos')->orderBy('percent')->get(),
'deliveryTimes' => DeliveryTime::query()->orderBy('pos')->orderBy('label')->get(),
'disposalReasons' => DisposalReason::query()->orderBy('pos')->orderBy('label')->get(),
]);
}
}

View file

@ -22,27 +22,40 @@ class ProductStockController extends Controller
$products = Product::query()
->where('active', true)
->where('is_set', false)
->where('show_in_product_stock', true)
->whereNull('main_product_id')
->with('images')
->orderBy('pos')
->orderBy('name')
->get();
$stock = $this->productStockService->currentStockByProduct($products->pluck('id')->all());
$ids = $products->pluck('id')->all();
$stock = $this->productStockService->currentStockByProduct($ids);
$consumption = $this->productStockService->monthlyConsumptionByProduct($ids);
$rows = $products->map(function (Product $product) use ($stock) {
$statusRank = ['critical' => 0, 'warning' => 1, 'ok' => 2];
$rows = $products->map(function (Product $product) use ($stock, $consumption) {
$current = $stock[$product->id] ?? 0;
return [
'product' => $product,
'stock' => $current,
'monthly_consumption' => $consumption[$product->id] ?? 0.0,
'status' => $this->productStockService->productStatus(
$current,
$product->min_product_stock,
$product->critical_product_stock,
),
];
});
})
// AP-22: Default-Sortierung nach Dringlichkeit (kritisch → niedrig → ok), innerhalb nach Bestand/Name.
->sortBy([
fn (array $a, array $b) => ($statusRank[$a['status']] <=> $statusRank[$b['status']])
?: ($a['stock'] <=> $b['stock'])
?: strcasecmp($a['product']->name, $b['product']->name),
])
->values();
return view('admin.inventory.product-stock.index', [
'rows' => $rows,

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Http\Requests\Inventory\StoreStockDisposalRequest;
use App\Models\DisposalReason;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\StockDisposal;
@ -109,17 +110,17 @@ class StockDisposalController extends Controller
}
/**
* Aktive Ausschuss-Gründe aus den Einstellungen (Warenwirtschaft Allgemein).
*
* @return array<int, string>
*/
protected function reasons(): array
{
return [
__('Bruch / Beschädigung'),
__('Verfall / MHD überschritten'),
__('Qualitätsmangel'),
__('Schwund / Inventurdifferenz'),
__('Muster / Testverbrauch'),
__('Sonstiges'),
];
return DisposalReason::query()
->active()
->orderBy('pos')
->orderBy('label')
->pluck('label')
->all();
}
}

View file

@ -319,21 +319,23 @@ class OrderController extends Controller
$qty = isset($cartItem->qty) ? $cartItem->qty : 0;
$rowId = isset($cartItem->rowId) ? $cartItem->rowId : '';
// AP-25: „Nicht vorrätig" ist nur ein Hinweis — der VP entscheidet selbst,
// ob er die Ware später bekommt. Die Mengen-Buttons bleiben immer aktiv.
$controls = '';
if ($product->isOutOfStock()) {
$controls = '<div class="product-stock-hint">'.e($product->outOfStockNotice()).'</div>';
} else {
$controls = '<div class="no-line-break input-group-min-w">
<div class="input-group d-inline-flex w-auto">
<span class="input-group-prepend">
<button type="button" class="btn btn-secondary icon-btn md-btn-extra remove-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">-</button>
</span>
<input type="text" class="form-control text-center input-extra table-input-event-onchange" name="product_qty_'.$product->id.'" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'" value="'.$qty.'">
<span class="input-group-append">
<button type="button" class="btn btn-secondary icon-btn md-btn-extra add-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">+</button>
</span>
</div>
</div>';
$controls .= '<div class="product-stock-hint">'.e($product->outOfStockNotice()).'</div>';
}
$controls .= '<div class="no-line-break input-group-min-w">
<div class="input-group d-inline-flex w-auto">
<span class="input-group-prepend">
<button type="button" class="btn btn-secondary icon-btn md-btn-extra remove-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">-</button>
</span>
<input type="text" class="form-control text-center input-extra table-input-event-onchange" name="product_qty_'.$product->id.'" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'" value="'.$qty.'">
<span class="input-group-append">
<button type="button" class="btn btn-secondary icon-btn md-btn-extra add-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">+</button>
</span>
</div>
</div>';
return '<strong>'.$product->name.'</strong><br>'.$controls;
})

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\Inventory;
use Illuminate\Foundation\Http\FormRequest;
class StoreDisposalReasonRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'label' => ['required', 'string', 'max:100'],
'active' => ['sometimes', 'boolean'],
'pos' => ['nullable', 'integer', 'min:0', 'max:255'],
];
}
protected function prepareForValidation(): void
{
$this->merge([
'active' => $this->boolean('active'),
'pos' => $this->input('pos', '') === '' ? 0 : $this->input('pos'),
]);
}
}

View file

@ -0,0 +1,5 @@
<?php
namespace App\Http\Requests\Inventory;
class UpdateDisposalReasonRequest extends StoreDisposalReasonRequest {}