- AP-09 Produktbestand inkl. Bewegungshistorie (product_stock_movements, ProductStockService) - AP-10 Rohstoffbestand-Ansicht je Lager (RawMaterialStockController) - AP-11 Bestandsschwellen / Out-of-Stock-Handling fuer Produkte und Shop - AP-12 Ausgang/Ausschuss (stock_disposals, StockDisposalController, InventoryService) - Set-Produkte (product_set_items) inkl. Aufloesung - Produktentwicklung & Hinweise-Verwaltung (Notices) - AP-13 Entwicklungskonzept Shop-Bestandsabzug im Plan dokumentiert - Feature-Tests fuer neue Module + aktualisierter Entwicklungsplan Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
8.8 KiB
PHP
278 lines
8.8 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Admin\Inventory;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Http\Requests\Inventory\ReceiveStockEntryRequest;
|
||
use App\Http\Requests\Inventory\StoreStockEntryRequest;
|
||
use App\Http\Requests\Inventory\UpdateStockEntryRequest;
|
||
use App\Models\Ingredient;
|
||
use App\Models\Location;
|
||
use App\Models\MaterialQuality;
|
||
use App\Models\PackagingItem;
|
||
use App\Models\StockEntry;
|
||
use App\Models\Supplier;
|
||
use App\Models\TaxRate;
|
||
use App\Repositories\StockEntryRepository;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\RedirectResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\View\View;
|
||
|
||
class StockEntryController extends Controller
|
||
{
|
||
public function __construct(
|
||
protected StockEntryRepository $stockEntryRepository
|
||
) {}
|
||
|
||
public function index(): View
|
||
{
|
||
return view('admin.inventory.stock-entries.index', array_merge($this->formSharedData(), [
|
||
'values' => $this->stockEntryRepository->listForIndex(),
|
||
]));
|
||
}
|
||
|
||
public function create(Request $request): View|RedirectResponse
|
||
{
|
||
if (! auth()->user()->isAdmin()) {
|
||
return redirect()->route('home');
|
||
}
|
||
|
||
$model = new StockEntry([
|
||
'ordered_at' => now()->toDateString(),
|
||
'entry_type' => 'ingredient',
|
||
]);
|
||
|
||
$ingredientId = (int) $request->query('ingredient_id');
|
||
if ($ingredientId > 0) {
|
||
$ingredient = Ingredient::query()->find($ingredientId);
|
||
if ($ingredient) {
|
||
$model->entry_type = 'ingredient';
|
||
$model->ingredient_id = $ingredient->id;
|
||
$model->setRelation('ingredient', $ingredient);
|
||
}
|
||
}
|
||
|
||
return view('admin.inventory.stock-entries.create', array_merge($this->formSharedData(), [
|
||
'model' => $model,
|
||
]));
|
||
}
|
||
|
||
public function store(StoreStockEntryRequest $request): RedirectResponse
|
||
{
|
||
if (! $request->user()->isAdmin()) {
|
||
return redirect()->route('home');
|
||
}
|
||
|
||
$data = $request->validatedPayload();
|
||
$data['ordered_by'] = (int) auth()->id();
|
||
|
||
$this->stockEntryRepository->create($data);
|
||
|
||
\Session::flash('alert-save', '1');
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.index');
|
||
}
|
||
|
||
public function show(StockEntry $stockEntry): View
|
||
{
|
||
$stockEntry->load([
|
||
'ingredient',
|
||
'packagingItem.packagingMaterial',
|
||
'supplier',
|
||
'location',
|
||
'quality',
|
||
'orderedByUser.account',
|
||
'receivedByUser.account',
|
||
]);
|
||
|
||
return view('admin.inventory.stock-entries.show', array_merge($this->formSharedData(), [
|
||
'model' => $stockEntry,
|
||
'materialQualities' => MaterialQuality::query()->orderBy('pos')->orderBy('name')->get(),
|
||
]));
|
||
}
|
||
|
||
public function edit(StockEntry $stockEntry): View|RedirectResponse
|
||
{
|
||
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);
|
||
}
|
||
|
||
$stockEntry->load(['ingredient', 'packagingItem']);
|
||
|
||
return view('admin.inventory.stock-entries.edit', array_merge($this->formSharedData(), [
|
||
'model' => $stockEntry,
|
||
]));
|
||
}
|
||
|
||
public function update(UpdateStockEntryRequest $request, StockEntry $stockEntry): RedirectResponse
|
||
{
|
||
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);
|
||
}
|
||
|
||
$this->stockEntryRepository->update($stockEntry, $request->validatedPayload());
|
||
|
||
\Session::flash('alert-save', '1');
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.index');
|
||
}
|
||
|
||
public function copy(StockEntry $stockEntry): RedirectResponse
|
||
{
|
||
if (! auth()->user()->isAdmin()) {
|
||
return redirect()->route('home');
|
||
}
|
||
|
||
$data = $stockEntry->only([
|
||
'entry_type',
|
||
'ingredient_id',
|
||
'packaging_item_id',
|
||
'supplier_id',
|
||
'location_id',
|
||
'ordered_quantity',
|
||
'price_per_kg',
|
||
'price_per_kg_gross',
|
||
'price_total',
|
||
'tax_rate_id',
|
||
'tax_rate_percent',
|
||
'quality_id',
|
||
]);
|
||
$data['ordered_at'] = now()->toDateString();
|
||
$data['ordered_by'] = (int) auth()->id();
|
||
$data['status'] = 'pending';
|
||
|
||
$copy = $this->stockEntryRepository->create($data);
|
||
|
||
\Session::flash('alert-warning', __('Kopie angelegt – bitte Menge/Charge prüfen und speichern.'));
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.edit', $copy);
|
||
}
|
||
|
||
public function destroy(StockEntry $stockEntry): RedirectResponse
|
||
{
|
||
if (! auth()->user()->isAdmin()) {
|
||
return redirect()->route('home');
|
||
}
|
||
if (! $stockEntry->isPending()) {
|
||
\Session::flash('alert-error', __('Nur offene Bestellungen können gelöscht werden.'));
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.index');
|
||
}
|
||
|
||
$stockEntry->delete();
|
||
|
||
\Session::flash('alert-success', __('Eintrag gelöscht'));
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.index');
|
||
}
|
||
|
||
public function receive(ReceiveStockEntryRequest $request, StockEntry $stockEntry): RedirectResponse
|
||
{
|
||
if (! $stockEntry->isPending()) {
|
||
\Session::flash('alert-error', __('Eintrag nicht mehr offen.'));
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.show', $stockEntry);
|
||
}
|
||
|
||
$data = $request->validated();
|
||
if ($stockEntry->entry_type !== 'ingredient') {
|
||
$data['quality_id'] = null;
|
||
$data['best_before'] = null;
|
||
}
|
||
|
||
$this->stockEntryRepository->receive($stockEntry, $data);
|
||
|
||
\Session::flash('alert-save', '1');
|
||
|
||
return redirect()->route('admin.inventory.stock-entries.show', $stockEntry->fresh());
|
||
}
|
||
|
||
public function searchIngredients(Request $request): JsonResponse
|
||
{
|
||
$term = trim((string) $request->query('q', ''));
|
||
if (mb_strlen($term) < 1) {
|
||
return response()->json(['results' => []]);
|
||
}
|
||
|
||
$rows = Ingredient::query()
|
||
->where('active', true)
|
||
->where(function ($query) use ($term) {
|
||
$query->where('name', 'like', '%'.$term.'%')
|
||
->orWhere('inci', 'like', '%'.$term.'%');
|
||
})
|
||
->orderBy('name')
|
||
->limit(30)
|
||
->get(['id', 'name', 'inci']);
|
||
|
||
$results = $rows->map(function (Ingredient $i) {
|
||
$text = $i->name;
|
||
if ($i->inci) {
|
||
$text .= ' ('.$i->inci.')';
|
||
}
|
||
|
||
return ['id' => $i->id, 'text' => $text];
|
||
})->values()->all();
|
||
|
||
return response()->json(['results' => $results]);
|
||
}
|
||
|
||
public function searchPackagingItems(Request $request): JsonResponse
|
||
{
|
||
$term = trim((string) $request->query('q', ''));
|
||
$entryType = $request->query('entry_type');
|
||
|
||
$categoryMap = [
|
||
'packaging' => 'packaging',
|
||
'shipping' => 'shipping',
|
||
];
|
||
|
||
$query = PackagingItem::query()
|
||
->where('active', true);
|
||
|
||
if ($entryType && isset($categoryMap[$entryType])) {
|
||
$query->where('category', $categoryMap[$entryType]);
|
||
}
|
||
|
||
if (mb_strlen($term) >= 1) {
|
||
$query->where('name', 'like', '%'.$term.'%');
|
||
}
|
||
|
||
$rows = $query->orderBy('name')->limit(30)->get(['id', 'name']);
|
||
|
||
$results = $rows->map(fn (PackagingItem $p) => [
|
||
'id' => $p->id,
|
||
'text' => $p->name,
|
||
])->values()->all();
|
||
|
||
return response()->json(['results' => $results]);
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
protected function formSharedData(): array
|
||
{
|
||
return [
|
||
'suppliers' => Supplier::query()->where('active', true)->orderBy('name')->get(),
|
||
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
|
||
'materialQualities' => MaterialQuality::query()->orderBy('pos')->orderBy('name')->get(),
|
||
'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(),
|
||
'entryTypeLabels' => [
|
||
'ingredient' => __('Rohstoff'),
|
||
'packaging' => __('Produktverpackung'),
|
||
'shipping' => __('Versandverpackung'),
|
||
],
|
||
];
|
||
}
|
||
}
|