From 3ee2d756e90e5025aaec431f71475a7b286513b0 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 3 Jun 2026 11:04:22 +0000 Subject: [PATCH] Warenwirtschaft: AP-09 bis AP-13 (Produktbestand, Set-Produkte, Ausschuss, Konzepte) - 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 --- .cursor/mcp.json | 7 - .devcontainer/devcontainer.json | 14 +- .../Admin/Inventory/NoticeController.php | 21 + .../ProductDevelopmentController.php | 14 + .../Inventory/ProductStockController.php | 142 ++++++ .../Admin/Inventory/ProductionController.php | 15 +- .../Inventory/RawMaterialStockController.php | 173 +++++++ .../Inventory/StockDisposalController.php | 125 +++++ .../Admin/Inventory/StockEntryController.php | 22 +- app/Http/Controllers/ProductController.php | 61 ++- app/Http/Controllers/User/OrderController.php | 430 +++++++++--------- .../StoreProductStockMovementRequest.php | 39 ++ .../Inventory/StoreProductionRequest.php | 14 +- .../Inventory/StoreStockDisposalRequest.php | 95 ++++ app/Models/Product.php | 153 +++++++ app/Models/ProductStockMovement.php | 70 +++ app/Models/StockDisposal.php | 87 ++++ app/Providers/AppServiceProvider.php | 15 + app/Repositories/ProductRepository.php | 88 +++- app/Services/InventoryService.php | 309 +++++++++++++ app/Services/ProductStockService.php | 146 ++++++ app/Services/ProductionService.php | 298 +++++++----- ...d_no_recipe_required_to_products_table.php | 22 + ...05204_add_set_fields_to_products_table.php | 33 ++ ..._105204_create_product_set_items_table.php | 35 ++ ..._out_of_stock_fields_to_products_table.php | 23 + ...add_stock_thresholds_to_products_table.php | 29 ++ ...5_create_product_stock_movements_table.php | 40 ++ ...03_124056_create_stock_disposals_table.php | 47 ++ ...ntwicklungsplan-aktualisiert-02-06-2026.md | 121 ++++- resources/docs/hinweise.md | 124 +++++ .../admin/inventory/notices/index.blade.php | 44 ++ .../product-development/index.blade.php | 19 + .../inventory/product-stock/history.blade.php | 110 +++++ .../inventory/product-stock/index.blade.php | 144 ++++++ .../productions/_form_fields.blade.php | 71 +++ .../inventory/productions/_scripts.blade.php | 213 +++++++++ .../inventory/productions/create.blade.php | 181 +------- .../inventory/productions/edit.blade.php | 190 +------- .../raw-material-stock/index.blade.php | 156 +++++++ .../raw-material-stock/show.blade.php | 256 +++++++++++ .../stock-disposals/create.blade.php | 194 ++++++++ .../inventory/stock-disposals/index.blade.php | 76 ++++ .../inventory/stock-entries/_form.blade.php | 6 +- .../inventory/stock-entries/show.blade.php | 10 +- .../views/admin/modal/show_product.blade.php | 5 + resources/views/admin/product/edit.blade.php | 127 ++++++ resources/views/admin/product/form.blade.php | 221 ++++++++- resources/views/admin/product/index.blade.php | 160 ++++--- .../layouts/includes/layout-sidenav.blade.php | 68 ++- resources/views/user/order/list.blade.php | 268 ++++++----- .../web/shop/_shop_products_inner.blade.php | 6 +- .../views/web/shop/show_product.blade.php | 5 + routes/web.php | 16 + tests/Feature/InventoryNoticesTest.php | 41 ++ tests/Feature/ProductOutOfStockTest.php | 135 ++++++ tests/Feature/ProductPhase51Test.php | 2 +- tests/Feature/ProductSetTest.php | 272 +++++++++++ tests/Feature/ProductStockTest.php | 191 ++++++++ .../ProductionManufacturerRecipeTest.php | 375 +++++++++++++++ tests/Feature/ProductionPhase5Test.php | 4 + tests/Feature/RawMaterialStockTest.php | 269 +++++++++++ tests/Feature/StockDisposalTest.php | 222 +++++++++ 63 files changed, 5968 insertions(+), 901 deletions(-) create mode 100644 app/Http/Controllers/Admin/Inventory/NoticeController.php create mode 100644 app/Http/Controllers/Admin/Inventory/ProductDevelopmentController.php create mode 100644 app/Http/Controllers/Admin/Inventory/ProductStockController.php create mode 100644 app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php create mode 100644 app/Http/Controllers/Admin/Inventory/StockDisposalController.php create mode 100644 app/Http/Requests/Inventory/StoreProductStockMovementRequest.php create mode 100644 app/Http/Requests/Inventory/StoreStockDisposalRequest.php create mode 100644 app/Models/ProductStockMovement.php create mode 100644 app/Models/StockDisposal.php create mode 100644 app/Services/InventoryService.php create mode 100644 app/Services/ProductStockService.php create mode 100644 database/migrations/2026_06_03_102214_add_no_recipe_required_to_products_table.php create mode 100644 database/migrations/2026_06_03_105204_add_set_fields_to_products_table.php create mode 100644 database/migrations/2026_06_03_105204_create_product_set_items_table.php create mode 100644 database/migrations/2026_06_03_111226_add_out_of_stock_fields_to_products_table.php create mode 100644 database/migrations/2026_06_03_122635_add_stock_thresholds_to_products_table.php create mode 100644 database/migrations/2026_06_03_122635_create_product_stock_movements_table.php create mode 100644 database/migrations/2026_06_03_124056_create_stock_disposals_table.php create mode 100644 resources/docs/hinweise.md create mode 100644 resources/views/admin/inventory/notices/index.blade.php create mode 100644 resources/views/admin/inventory/product-development/index.blade.php create mode 100644 resources/views/admin/inventory/product-stock/history.blade.php create mode 100644 resources/views/admin/inventory/product-stock/index.blade.php create mode 100644 resources/views/admin/inventory/productions/_form_fields.blade.php create mode 100644 resources/views/admin/inventory/productions/_scripts.blade.php create mode 100644 resources/views/admin/inventory/raw-material-stock/index.blade.php create mode 100644 resources/views/admin/inventory/raw-material-stock/show.blade.php create mode 100644 resources/views/admin/inventory/stock-disposals/create.blade.php create mode 100644 resources/views/admin/inventory/stock-disposals/index.blade.php create mode 100644 tests/Feature/InventoryNoticesTest.php create mode 100644 tests/Feature/ProductOutOfStockTest.php create mode 100644 tests/Feature/ProductSetTest.php create mode 100644 tests/Feature/ProductStockTest.php create mode 100644 tests/Feature/ProductionManufacturerRecipeTest.php create mode 100644 tests/Feature/RawMaterialStockTest.php create mode 100644 tests/Feature/StockDisposalTest.php diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 4b03da9..25352be 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -6,13 +6,6 @@ "artisan", "boost:mcp" ] - }, - "sequential-thinking": { - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-sequential-thinking" - ] } } } \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index bb26e0b..d3017d2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -30,16 +30,6 @@ }, "mounts": [ "source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached", - "source=/Users/pandora/Library/Mobile Documents/iCloud~md~obsidian/Documents/DEV-Vault/gruene-seele,target=/var/www/html/docs,type=bind", - ], - // WICHTIG: Nur noch den Vite-Port weiterleiten - "forwardPorts": [ - 5179 - ], - "portsAttributes": { - "5179": { - "label": "Vite Dev Server", - "onAutoForward": "notify" - } - } + "source=/Users/pandora/Library/Mobile Documents/iCloud~md~obsidian/Documents/DEV-Vault/gruene-seele,target=/var/www/html/docs,type=bind" + ] } \ No newline at end of file diff --git a/app/Http/Controllers/Admin/Inventory/NoticeController.php b/app/Http/Controllers/Admin/Inventory/NoticeController.php new file mode 100644 index 0000000..264bcab --- /dev/null +++ b/app/Http/Controllers/Admin/Inventory/NoticeController.php @@ -0,0 +1,21 @@ + Str::markdown($markdown), + ]); + } +} diff --git a/app/Http/Controllers/Admin/Inventory/ProductDevelopmentController.php b/app/Http/Controllers/Admin/Inventory/ProductDevelopmentController.php new file mode 100644 index 0000000..012ad0c --- /dev/null +++ b/app/Http/Controllers/Admin/Inventory/ProductDevelopmentController.php @@ -0,0 +1,14 @@ +where('active', true) + ->where('is_set', false) + ->whereNull('main_product_id') + ->with('images') + ->orderBy('pos') + ->orderBy('name') + ->get(); + + $stock = $this->productStockService->currentStockByProduct($products->pluck('id')->all()); + + $rows = $products->map(function (Product $product) use ($stock) { + $current = $stock[$product->id] ?? 0; + + return [ + 'product' => $product, + 'stock' => $current, + 'status' => $this->productStockService->productStatus( + $current, + $product->min_product_stock, + $product->critical_product_stock, + ), + ]; + }); + + return view('admin.inventory.product-stock.index', [ + 'rows' => $rows, + 'reasons' => $this->manualReasons(), + ]); + } + + public function storeMovement(StoreProductStockMovementRequest $request, Product $product): RedirectResponse + { + $data = $request->validated(); + + $this->productStockService->recordMovement( + $product, + $data['direction'], + (int) $data['quantity'], + $data['reason'], + 'manual', + $data['note'] ?? null, + (int) $request->user()->id, + ); + + \Session::flash('alert-save', '1'); + + return redirect()->route('admin.inventory.product-stock.index'); + } + + public function history(Request $request): View + { + $query = ProductStockMovement::query() + ->with(['product', 'user.account']) + ->latest('created_at') + ->latest('id'); + + if (($productId = (int) $request->query('product_id')) > 0) { + $query->where('product_id', $productId); + } + + if (in_array($request->query('direction'), ['in', 'out'], true)) { + $query->where('direction', $request->query('direction')); + } + + if (($reason = trim((string) $request->query('reason'))) !== '') { + $query->where('reason', $reason); + } + + $month = (int) $request->query('month'); + $year = (int) $request->query('year'); + if ($year > 0) { + $query->whereYear('created_at', $year); + if ($month >= 1 && $month <= 12) { + $query->whereMonth('created_at', $month); + } + } + + $movements = $query->limit(500)->get(); + + return view('admin.inventory.product-stock.history', [ + 'movements' => $movements, + 'products' => Product::query()->orderBy('name')->get(['id', 'name']), + 'reasonOptions' => ProductStockMovement::query()->distinct()->orderBy('reason')->pluck('reason')->filter()->values(), + 'filters' => [ + 'product_id' => (int) $request->query('product_id'), + 'direction' => $request->query('direction'), + 'reason' => $request->query('reason'), + 'month' => $month, + 'year' => $year, + ], + 'years' => $this->yearOptions(), + ]); + } + + /** + * @return array + */ + protected function manualReasons(): array + { + return [ + __('Initialbestand'), + __('Korrektur'), + __('Inventur'), + __('Retoure'), + __('Testervergabe'), + __('Verlust / Bruch'), + __('Sonstiges'), + ]; + } + + /** + * @return array + */ + protected function yearOptions(): array + { + $current = (int) now()->year; + + return range($current, $current - 5); + } +} diff --git a/app/Http/Controllers/Admin/Inventory/ProductionController.php b/app/Http/Controllers/Admin/Inventory/ProductionController.php index a8d8a03..96ee367 100644 --- a/app/Http/Controllers/Admin/Inventory/ProductionController.php +++ b/app/Http/Controllers/Admin/Inventory/ProductionController.php @@ -29,15 +29,18 @@ class ProductionController extends Controller ]); } - public function create(): View + public function create(Request $request): View { $defaultLocationId = Location::query()->where('name', 'like', '%öln%')->value('id') ?? Location::query()->where('active', true)->first()?->id; + $defaultProductId = (int) $request->query('product_id') ?: null; + return view('admin.inventory.productions.create', [ - 'products' => Product::query()->where('active', 1)->orderBy('name')->get(['id', 'name']), + 'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name']), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'defaultLocationId' => $defaultLocationId, + 'defaultProductId' => $defaultProductId, 'model' => null, ]); } @@ -99,7 +102,8 @@ class ProductionController extends Controller return view('admin.inventory.productions.edit', [ 'model' => $production, - 'products' => Product::query()->where('active', 1) + 'products' => Product::query() + ->where(fn ($q) => $q->where('active', 1)->where('is_set', false)) ->orWhere('id', $production->product_id) ->orderBy('name')->get(['id', 'name']), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), @@ -148,7 +152,7 @@ class ProductionController extends Controller return view('admin.inventory.productions.create', [ 'model' => $production, - 'products' => Product::query()->where('active', 1)->orderBy('name')->get(['id', 'name']), + 'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name']), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'defaultLocationId' => $defaultLocationId, ]); @@ -158,12 +162,13 @@ class ProductionController extends Controller { $locationId = (int) $request->query('location_id', 0); $quantity = (int) $request->query('quantity', 1); + $excludeProductionId = (int) $request->query('exclude_production', 0) ?: null; if ($locationId < 1) { return response()->json(['message' => __('location_id erforderlich')], 422); } return response()->json( - $this->productionService->buildRecipePayload($product, $locationId, max(1, $quantity)) + $this->productionService->buildRecipePayload($product, $locationId, max(1, $quantity), $excludeProductionId) ); } } diff --git a/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php b/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php new file mode 100644 index 0000000..47fa42a --- /dev/null +++ b/app/Http/Controllers/Admin/Inventory/RawMaterialStockController.php @@ -0,0 +1,173 @@ +where('active', true) + ->with('materialQuality') + ->orderBy('pos') + ->orderBy('name') + ->get(); + + $ids = $ingredients->pluck('id')->all(); + $remaining = $this->inventoryService->remainingByIngredient($ids); + $daily = $this->inventoryService->dailyConsumptionByIngredient($ids); + $openOrders = $this->inventoryService->openOrderQuantityByIngredient($ids); + + $rows = $ingredients->map(function (Ingredient $ingredient) use ($remaining, $daily, $openOrders) { + $rem = $remaining[$ingredient->id] ?? 0.0; + $perDay = $daily[$ingredient->id] ?? null; + $open = $openOrders[$ingredient->id] ?? 0.0; + $minAlert = $ingredient->min_stock_alert !== null ? (float) $ingredient->min_stock_alert : null; + $leadDays = $ingredient->delivery_time_days !== null ? (int) $ingredient->delivery_time_days : null; + + return [ + 'ingredient' => $ingredient, + 'remaining' => $rem, + 'daily' => $perDay, + 'open_order' => $open, + 'days_until_empty' => $this->inventoryService->daysUntilEmpty($rem, $perDay), + 'expected_empty' => $this->inventoryService->expectedEmptyDate($rem, $perDay), + 'status' => $this->inventoryService->stockStatus($minAlert, $rem, $perDay, $leadDays, $open > 0), + ]; + }); + + return view('admin.inventory.raw-material-stock.index', [ + 'rows' => $rows, + 'criticalCount' => $rows->where('status', 'critical')->count(), + 'horizonOptions' => $this->horizonOptions(), + 'defaultHorizon' => 90, + ]); + } + + public function show(Ingredient $ingredient): View + { + $ingredient->load([ + 'materialQuality', + 'taxRate', + 'suppliers' => fn ($q) => $q->orderByPivot('preferred', 'desc')->orderBy('name'), + 'products' => fn ($q) => $q->where('active', true), + ]); + + $entries = StockEntry::query() + ->with(['supplier', 'location']) + ->where('status', 'received') + ->where('entry_type', 'ingredient') + ->where('ingredient_id', $ingredient->id) + ->orderByRaw('best_before is null, best_before asc') + ->orderBy('id') + ->get(); + + $consumed = $this->productionService->consumedByStockEntry($entries->pluck('id')->all()); + + $charges = $entries->map(function (StockEntry $entry) use ($consumed) { + $received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0; + $entry->setAttribute('remaining_quantity', round($received - ($consumed[(int) $entry->id] ?? 0.0), 2)); + + return $entry; + })->filter(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity') > 0.0) + ->values(); + + $remaining = array_sum(array_map( + fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity'), + $charges->all() + )); + + $remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id); + + $openOrders = StockEntry::query() + ->with(['supplier', 'location']) + ->where('status', 'pending') + ->where('entry_type', 'ingredient') + ->where('ingredient_id', $ingredient->id) + ->orderBy('ordered_at') + ->orderBy('id') + ->get(); + $openTotal = round((float) $openOrders->sum('ordered_quantity'), 2); + + $daily = ($this->inventoryService->dailyConsumptionByIngredient([$ingredient->id]))[$ingredient->id] ?? null; + $minAlert = $ingredient->min_stock_alert !== null ? (float) $ingredient->min_stock_alert : null; + $leadDays = $ingredient->delivery_time_days !== null ? (int) $ingredient->delivery_time_days : null; + + $lastPriceBySupplier = $this->lastNetPricePerSupplier($ingredient->id); + + $productStock = $this->productStockService->currentStockByProduct($ingredient->products->pluck('id')->all()); + + return view('admin.inventory.raw-material-stock.show', [ + 'ingredient' => $ingredient, + 'productStock' => $productStock, + 'charges' => $charges, + 'remaining' => round($remaining, 2), + 'remainingByLocation' => $remainingByLocation, + 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), + 'openOrders' => $openOrders, + 'openTotal' => $openTotal, + 'daily' => $daily, + 'daysUntilEmpty' => $this->inventoryService->daysUntilEmpty($remaining, $daily), + 'expectedEmpty' => $this->inventoryService->expectedEmptyDate($remaining, $daily), + 'status' => $this->inventoryService->stockStatus($minAlert, $remaining, $daily, $leadDays, $openTotal > 0), + 'lastPriceBySupplier' => $lastPriceBySupplier, + 'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(), + ]); + } + + /** + * Letzter Netto-kg-Preis je Lieferant für diesen Rohstoff (für die Bestell-/Lieferantenliste). + * + * @return array [supplier_id => price_per_kg_net] + */ + protected function lastNetPricePerSupplier(int $ingredientId): array + { + $entries = StockEntry::query() + ->where('entry_type', 'ingredient') + ->where('ingredient_id', $ingredientId) + ->whereNotNull('supplier_id') + ->whereNotNull('price_per_kg') + ->orderBy('ordered_at', 'desc') + ->orderBy('id', 'desc') + ->get(['supplier_id', 'price_per_kg']); + + $result = []; + foreach ($entries as $entry) { + $supplierId = (int) $entry->supplier_id; + if (! isset($result[$supplierId])) { + $result[$supplierId] = (float) $entry->price_per_kg; + } + } + + return $result; + } + + /** + * @return array [days => label] + */ + protected function horizonOptions(): array + { + return [ + 30 => __('Verbrauch (nächster Monat)'), + 90 => __('Verbrauch (nächste 3 Monate)'), + 180 => __('Verbrauch (nächste 6 Monate)'), + 365 => __('Verbrauch (nächste 12 Monate)'), + ]; + } +} diff --git a/app/Http/Controllers/Admin/Inventory/StockDisposalController.php b/app/Http/Controllers/Admin/Inventory/StockDisposalController.php new file mode 100644 index 0000000..63a670d --- /dev/null +++ b/app/Http/Controllers/Admin/Inventory/StockDisposalController.php @@ -0,0 +1,125 @@ +with(['ingredient', 'packagingItem', 'location', 'stockEntry', 'user']) + ->latest('disposed_at') + ->latest('id'); + + if (in_array($request->query('type'), ['ingredient', 'packaging'], true)) { + $query->where('disposal_type', $request->query('type')); + } + + return view('admin.inventory.stock-disposals.index', [ + 'values' => $query->limit(500)->get(), + 'typeFilter' => $request->query('type'), + ]); + } + + public function create(Request $request): View|RedirectResponse + { + if (! auth()->user()->isAdmin()) { + return redirect()->route('home'); + } + + $prefill = [ + 'disposal_type' => 'ingredient', + 'ingredient_id' => null, + 'ingredient_label' => null, + ]; + + $ingredientId = (int) $request->query('ingredient_id'); + if ($ingredientId > 0 && ($ingredient = Ingredient::query()->find($ingredientId))) { + $prefill['ingredient_id'] = $ingredient->id; + $prefill['ingredient_label'] = $ingredient->inci ? $ingredient->name.' ('.$ingredient->inci.')' : $ingredient->name; + } + + return view('admin.inventory.stock-disposals.create', [ + 'locations' => Location::query()->where('active', true)->orderBy('name')->get(), + 'reasons' => $this->reasons(), + 'prefill' => $prefill, + ]); + } + + public function store(StoreStockDisposalRequest $request): RedirectResponse + { + $data = $request->validatedPayload(); + $data['user_id'] = (int) $request->user()->id; + + StockDisposal::query()->create($data); + + \Session::flash('alert-save', '1'); + + return redirect()->route('admin.inventory.stock-disposals.index'); + } + + public function ingredientCharges(Ingredient $ingredient): JsonResponse + { + $remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id); + + $charges = StockEntry::query() + ->where('status', 'received') + ->where('entry_type', 'ingredient') + ->where('ingredient_id', $ingredient->id) + ->with('location') + ->orderBy('best_before') + ->get(['id', 'location_id', 'batch_number', 'best_before', 'received_quantity']); + + $results = $charges->map(function (StockEntry $charge) { + $label = $charge->batch_number ? __('Charge').' '.$charge->batch_number : __('Charge #:id', ['id' => $charge->id]); + if ($charge->location) { + $label .= ' · '.$charge->location->name; + } + if ($charge->best_before) { + $label .= ' · MHD '.$charge->best_before->format('d.m.Y'); + } + + return [ + 'id' => $charge->id, + 'location_id' => $charge->location_id, + 'text' => $label, + ]; + })->values()->all(); + + return response()->json([ + 'charges' => $results, + 'remaining_by_location' => $remainingByLocation, + ]); + } + + /** + * @return array + */ + protected function reasons(): array + { + return [ + __('Bruch / Beschädigung'), + __('Verfall / MHD überschritten'), + __('Qualitätsmangel'), + __('Schwund / Inventurdifferenz'), + __('Muster / Testverbrauch'), + __('Sonstiges'), + ]; + } +} diff --git a/app/Http/Controllers/Admin/Inventory/StockEntryController.php b/app/Http/Controllers/Admin/Inventory/StockEntryController.php index 4fb5ad7..fb3f33d 100644 --- a/app/Http/Controllers/Admin/Inventory/StockEntryController.php +++ b/app/Http/Controllers/Admin/Inventory/StockEntryController.php @@ -32,17 +32,29 @@ class StockEntryController extends Controller ])); } - public function create(): View|RedirectResponse + 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' => new StockEntry([ - 'ordered_at' => now()->toDateString(), - 'entry_type' => 'ingredient', - ]), + 'model' => $model, ])); } diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index b9d6f6f..0a434f0 100755 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -9,6 +9,7 @@ use App\Models\Product; use App\Models\ProductImage; use App\Models\ProductIngredient; use App\Repositories\ProductRepository; +use Illuminate\Database\Eloquent\Collection; use Request; use Validator; @@ -47,7 +48,7 @@ class ProductController extends Controller $model->active = true; } else { $model = Product::findOrFail($id); - $model->load(['packagings.packagingMaterial']); + $model->load(['packagings.packagingMaterial', 'setItems']); } $country_for_prices = Country::where('own_eur', '=', true)->orWhere('currency', '=', true)->get(); @@ -56,11 +57,27 @@ class ProductController extends Controller 'country_for_prices' => $country_for_prices, 'ingredient_catalog' => Ingredient::query()->where('active', true)->with('materialQuality')->orderBy('name')->get(['id', 'name', 'inci', 'effect', 'default_factor', 'material_quality_id']), 'packaging_catalog' => PackagingItem::query()->where('active', true)->with('packagingMaterial')->orderBy('name')->get(), + 'set_product_catalog' => $this->setProductCatalog($model), ]; return view('admin.product.edit', $data); } + /** + * Auswählbare Set-Bestandteile: aktive Einzelprodukte (keine Sets, nicht das Produkt selbst). + * + * @return Collection + */ + protected function setProductCatalog(Product $model) + { + return Product::query() + ->where('active', true) + ->singleProducts() + ->when($model->id, fn ($q) => $q->where('id', '!=', $model->id)) + ->orderBy('name') + ->get(['id', 'name', 'number']); + } + public function store() { @@ -87,15 +104,18 @@ class ProductController extends Controller $model = new Product; } else { $model = Product::findOrFail($data['id']); - $model->load(['packagings.packagingMaterial']); + $model->load(['packagings.packagingMaterial', 'setItems']); } $country_for_prices = Country::where('own_eur', '=', true)->orWhere('currency', '=', true)->get(); + $this->validateSetItems($validator, Request::all(), $model); + $data = [ 'product' => $model, 'country_for_prices' => $country_for_prices, 'ingredient_catalog' => Ingredient::query()->where('active', true)->with('materialQuality')->orderBy('name')->get(['id', 'name', 'inci', 'effect', 'default_factor', 'material_quality_id']), 'packaging_catalog' => PackagingItem::query()->where('active', true)->with('packagingMaterial')->orderBy('name')->get(), + 'set_product_catalog' => $this->setProductCatalog($model), ]; if ($validator->fails()) { @@ -110,6 +130,43 @@ class ProductController extends Controller } } + /** + * Set-Validierung: Set braucht mindestens ein Einzelprodukt als Bestandteil + * (keine Sets, nicht das Produkt selbst). + * + * @param array $data + */ + protected function validateSetItems($validator, array $data, Product $model): void + { + if (! isset($data['is_set'])) { + return; + } + + $validator->after(function ($validator) use ($data, $model) { + $componentIds = collect($data['set_component_id'] ?? []) + ->map(fn ($id) => (int) $id) + ->filter(fn (int $id) => $id > 0 && $id !== (int) $model->id) + ->unique() + ->values(); + + if ($componentIds->isEmpty()) { + $validator->errors()->add('set_component_id', __('Ein Set benötigt mindestens ein Einzelprodukt als Bestandteil.')); + + return; + } + + $components = Product::query()->whereIn('id', $componentIds)->get(['id', 'is_set']); + + if ($components->count() !== $componentIds->count()) { + $validator->errors()->add('set_component_id', __('Mindestens ein gewählter Bestandteil existiert nicht.')); + } + + if ($components->where('is_set', true)->isNotEmpty()) { + $validator->errors()->add('set_component_id', __('Set-Bestandteile dürfen selbst keine Sets sein.')); + } + }); + } + public function copy($id) { $model = Product::findOrFail($id); diff --git a/app/Http/Controllers/User/OrderController.php b/app/Http/Controllers/User/OrderController.php index da61ef8..272f3f3 100755 --- a/app/Http/Controllers/User/OrderController.php +++ b/app/Http/Controllers/User/OrderController.php @@ -1,54 +1,54 @@ middleware('active.account'); } - public function delivery($for, $id=null) + public function delivery($for, $id = null) { - $user = User::find(\Auth::user()->id); + $user = User::find(Auth::user()->id); $shopping_user = null; $delivery_id = null; - if(strpos($for, 'ot') !== false){ + if (strpos($for, 'ot') !== false) { $shopping_user = Shop::checkShoppingUser($id, $user); $delivery_id = $shopping_user->id; - if(!Shop::checkShoppingCountry($for, $delivery_id) && !\Session()->has('custom-error')){ + if (! Shop::checkShoppingCountry($for, $delivery_id) && ! \Session()->has('custom-error')) { \Session()->flash('custom-error', __('validation.custom.shipping_not_found')); + return redirect(route('user_order_my_delivery', [$for, $delivery_id])); } } - if(Request::get('action') === 'next'){ + if (Request::get('action') === 'next') { Yard::instance('shopping')->destroy(); - if(strpos(Request::get('switchers-radio-is-for'), 'ot') !== false){ + if (strpos(Request::get('switchers-radio-is-for'), 'ot') !== false) { $delivery_id = $id; } + return redirect(route('user_order_my_list', [Request::get('switchers-radio-is-for'), $delivery_id])); } $data = [ @@ -58,39 +58,41 @@ class OrderController extends Controller 'for' => $for, 'delivery_id' => $delivery_id, ]; + return view('user.order.delivery', $data); } - public function list($for, $id=null) + public function list($for, $id = null) { - - $user = User::find(\Auth::user()->id); + + $user = User::find(Auth::user()->id); $shopping_user = null; $delivery_id = null; - - if(strpos($for, 'ot') !== false){ + + if (strpos($for, 'ot') !== false) { $shopping_user = Shop::checkShoppingUser($id, $user); $delivery_id = $shopping_user->id; } - if($for === 'ot-customer'){ //noch nicht implementiert - //Liederung an ot-customer (Kunden) Zahlung und Rechnung geht an Kunden + if ($for === 'ot-customer') { // noch nicht implementiert + // Liederung an ot-customer (Kunden) Zahlung und Rechnung geht an Kunden UserService::initCustomerYard($shopping_user, $for); - }else{ - //Lieferung an user oder ot-member (Kunden) rechnung geht an User - //lieferland und rechnungsland prüfen - $shipping_country_id = Shop::checkShoppingCountry($for, $id); - if(!$shipping_country_id){ + } else { + // Lieferung an user oder ot-member (Kunden) rechnung geht an User + // lieferland und rechnungsland prüfen + $shipping_country_id = Shop::checkShoppingCountry($for, $id); + if (! $shipping_country_id) { \Session()->flash('custom-error', __('validation.custom.shipping_not_found')); + return redirect(route('user_order_my_delivery', [$for, $delivery_id])); } UserService::initUserYard($user, $shipping_country_id, $for); } - if($for === 'cr'){ + if ($for === 'cr') { Yard::instance('shopping')->setGlobalTaxRate(0); Yard::instance('shopping')->setShoppingUser($user, false); - }else{ + } else { Yard::instance('shopping')->setShoppingUser($user, true); } $data = [ @@ -102,15 +104,17 @@ class OrderController extends Controller 'delivery_id' => $delivery_id, 'comp_products' => $this->getCompProducts($for), ]; + return view('user.order.list', $data); } - public function payment($for, $id=null){ + public function payment($for, $id = null) + { $data = Request::all(); $user = User::find(Auth::user()->id); - - $rules = array( + + $rules = [ 'shipping_salutation' => 'required', 'shipping_firstname' => 'required', 'shipping_lastname' => 'required', @@ -118,25 +122,25 @@ class OrderController extends Controller 'shipping_zipcode' => 'required', 'shipping_city' => 'required', 'shipping_state' => 'required', - ); + ]; $validator = Validator::make(Request::all(), $rules); if ($validator->fails()) { return back()->withErrors($validator)->withInput(Request::all()); } - //hier prüfen, ob versand etc richtig berechnet wurde - $this->checkSendYardForPayment($data, $id); + // hier prüfen, ob versand etc richtig berechnet wurde + $this->checkSendYardForPayment($data, $id); - if(Yard::instance('shopping')->getNumComp() > 0){ - if(!isset($data['switchers-comp-product'])){ + if (Yard::instance('shopping')->getNumComp() > 0) { + if (! isset($data['switchers-comp-product'])) { $validator->errors()->add('switchers-comp-product', __('Bitte wähle ein Kompensationsprodukt aus')); - }else{ - if(!is_array($data['switchers-comp-product'])){ + } else { + if (! is_array($data['switchers-comp-product'])) { $validator->errors()->add('switchers-comp-product', __('Bitte wähle ein Kompensationsprodukt aus')); - }else{ - if(count($data['switchers-comp-product']) !== Yard::instance('shopping')->getNumComp()){ - $validator->errors()->add('switchers-comp-product', __('Bitte wähle :count Kompensationsprodukte aus', ['count'=>Yard::instance('shopping')->getNumComp()])); + } else { + if (count($data['switchers-comp-product']) !== Yard::instance('shopping')->getNumComp()) { + $validator->errors()->add('switchers-comp-product', __('Bitte wähle :count Kompensationsprodukte aus', ['count' => Yard::instance('shopping')->getNumComp()])); } } } @@ -144,7 +148,7 @@ class OrderController extends Controller return back()->withErrors($validator)->withInput(Request::all()); } } - + /* do { $identifier = Util::getToken(); @@ -173,118 +177,119 @@ class OrderController extends Controller ]); Yard::instance('shopping')->store($identifier); */ - //add to DB - //$path = route('checkout.checkout_card', ['identifier'=>$identifier]); - UserHistory::create(['user_id' => $user->id, 'action'=>'user_order_payment', 'status'=>1, 'product_id'=>null, 'identifier'=>$identifier]); - //$path = str_replace('http', 'https', $path); - //return redirect()->secure($path); + // add to DB + // $path = route('checkout.checkout_card', ['identifier'=>$identifier]); + UserHistory::create(['user_id' => $user->id, 'action' => 'user_order_payment', 'status' => 1, 'product_id' => null, 'identifier' => $identifier]); + + // $path = str_replace('http', 'https', $path); + // return redirect()->secure($path); return redirect(route('user_checkout', [$identifier])); } - - private function checkSendYardForPayment($data, $id){ + private function checkSendYardForPayment($data, $id) + { - $user = User::find(\Auth::user()->id); + $user = User::find(Auth::user()->id); $shopping_user = null; - if(strpos($data['shipping_is_for'], 'ot') !== false){ + if (strpos($data['shipping_is_for'], 'ot') !== false) { $shopping_user = Shop::checkShoppingUser($id, $user); } - + $shipping_country_id = Shop::checkShoppingCountry($data['shipping_is_for'], $id); - if(!$shipping_country_id){ + if (! $shipping_country_id) { $identifier = 'error-'.time().mt_rand(1000000, 9999999); Yard::instance('shopping')->store($identifier); $data['user_id'] = Auth::user()->id; $data['shopping_user_id'] = $id; - \App\Services\MyLog::writeLog('payment', 'error', 'no shipping_country_id found | Yard identifier: '.$identifier, $data); + MyLog::writeLog('payment', 'error', 'no shipping_country_id found | Yard identifier: '.$identifier, $data); abort(403, __('msg.shipping_country_was_not_found')); } - //must be the same shipping country - if($shipping_country_id != Yard::instance('shopping')->getShippingCountryId()){ + // must be the same shipping country + if ($shipping_country_id != Yard::instance('shopping')->getShippingCountryId()) { $identifier = 'error-'.time().mt_rand(1000000, 9999999); Yard::instance('shopping')->store($identifier); $data['user_id'] = Auth::user()->id; $data['shopping_user_id'] = $id; - \App\Services\MyLog::writeLog('payment', 'error', 'shipping_country_id is not the same from Yard | Yard identifier: '.$identifier, $data); + MyLog::writeLog('payment', 'error', 'shipping_country_id is not the same from Yard | Yard identifier: '.$identifier, $data); abort(403, __('msg.shipping_country_was_not_correctly')); } - if($data['shipping_is_for'] !== 'ot-customer'){ - if(Yard::instance('shopping')->shipping_free){ + if ($data['shipping_is_for'] !== 'ot-customer') { + if (Yard::instance('shopping')->shipping_free) { $identifier = 'error-'.time().mt_rand(1000000, 9999999); Yard::instance('shopping')->store($identifier); $data['user_id'] = Auth::user()->id; $data['shopping_user_id'] = $id; - \App\Services\MyLog::writeLog('payment', 'error', 'Yard can by not shipping_free | Yard identifier: '.$identifier, $data); + MyLog::writeLog('payment', 'error', 'Yard can by not shipping_free | Yard identifier: '.$identifier, $data); abort(403, __('msg.shopping_cart_was_shipping_free')); } } - - if($data['shipping_is_for'] === 'ot-customer'){ - if(!$user->shop){ + + if ($data['shipping_is_for'] === 'ot-customer') { + if (! $user->shop) { $identifier = 'error-'.time().mt_rand(1000000, 9999999); Yard::instance('shopping')->store($identifier); $data['user_id'] = Auth::user()->id; $data['shopping_user_id'] = $id; - \App\Services\MyLog::writeLog('payment', 'error', 'User has no Shop for an User to Customer order| Yard identifier: '.$identifier, $data); + MyLog::writeLog('payment', 'error', 'User has no Shop for an User to Customer order| Yard identifier: '.$identifier, $data); abort(403, __('msg.shopping_cart_was_not_user_shop')); } } - + $shipping_price = Shop::getShippingPriceByShippingCountryId($shipping_country_id, Yard::instance('shopping')->weight()); - //for other and has weight - check - if(strpos($data['shipping_is_for'], 'ot') !== false && $data['shipping_is_for'] !== 'ot-customer' && Yard::instance('shopping')->weight() > 0){ - if(!Yard::instance('shopping')->getShippingPrice() || Yard::instance('shopping')->getShippingPrice() == 0){ - $identifier = 'error-'.time().mt_rand(1000000, 9999999); - Yard::instance('shopping')->store($identifier); - $data['user_id'] = Auth::user()->id; - $data['shopping_user_id'] = $id; - \App\Services\MyLog::writeLog('payment', 'error', 'Yard OT shipping_price is 0 or | Yard identifier: '.$identifier, $data); - abort(403, __('msg.shipping_cost_cannot_be_0')); - } - if(Yard::instance('shopping')->getShippingPrice() != $shipping_price->price){ + // for other and has weight - check + if (strpos($data['shipping_is_for'], 'ot') !== false && $data['shipping_is_for'] !== 'ot-customer' && Yard::instance('shopping')->weight() > 0) { + if (! Yard::instance('shopping')->getShippingPrice() || Yard::instance('shopping')->getShippingPrice() == 0) { $identifier = 'error-'.time().mt_rand(1000000, 9999999); Yard::instance('shopping')->store($identifier); $data['user_id'] = Auth::user()->id; $data['shopping_user_id'] = $id; - \App\Services\MyLog::writeLog('payment', 'error', 'Yard OT shipping_price is not the same from shipping_price | Yard identifier: '.$identifier, $data); + MyLog::writeLog('payment', 'error', 'Yard OT shipping_price is 0 or | Yard identifier: '.$identifier, $data); + abort(403, __('msg.shipping_cost_cannot_be_0')); + } + if (Yard::instance('shopping')->getShippingPrice() != $shipping_price->price) { + $identifier = 'error-'.time().mt_rand(1000000, 9999999); + Yard::instance('shopping')->store($identifier); + $data['user_id'] = Auth::user()->id; + $data['shopping_user_id'] = $id; + MyLog::writeLog('payment', 'error', 'Yard OT shipping_price is not the same from shipping_price | Yard identifier: '.$identifier, $data); abort(403, __('msg.shipping_costs_were_not_calculated_correctly')); } } - - if($data['shipping_is_for'] == 'me' && Yard::instance('shopping')->weight() > 0){ - if(!Yard::instance('shopping')->getShippingPrice() || Yard::instance('shopping')->getShippingPrice() == 0){ + + if ($data['shipping_is_for'] == 'me' && Yard::instance('shopping')->weight() > 0) { + if (! Yard::instance('shopping')->getShippingPrice() || Yard::instance('shopping')->getShippingPrice() == 0) { $identifier = 'error-'.time().mt_rand(1000000, 9999999); Yard::instance('shopping')->store($identifier); $data['user_id'] = Auth::user()->id; $data['shopping_user_id'] = $id; - \App\Services\MyLog::writeLog('payment', 'error', 'Yard ME shipping_price is 0 or | Yard identifier: '.$identifier, $data); + MyLog::writeLog('payment', 'error', 'Yard ME shipping_price is 0 or | Yard identifier: '.$identifier, $data); abort(403, __('msg.shipping_cost_cannot_be_0')); } - if(Yard::instance('shopping')->getShippingPrice() != $shipping_price->price_comp){ + if (Yard::instance('shopping')->getShippingPrice() != $shipping_price->price_comp) { $identifier = 'error-'.time().mt_rand(1000000, 9999999); Yard::instance('shopping')->store($identifier); $data['user_id'] = Auth::user()->id; $data['shopping_user_id'] = $id; - \App\Services\MyLog::writeLog('payment', 'error', 'Yard ME shipping_price is not the same from shipping_price | Yard identifier: '.$identifier, $data); + MyLog::writeLog('payment', 'error', 'Yard ME shipping_price is not the same from shipping_price | Yard identifier: '.$identifier, $data); abort(403, __('msg.shipping_costs_were_not_calculated_correctly')); } - - if(Yard::instance('shopping')->getNumComp() != $shipping_price->num_comp){ + + if (Yard::instance('shopping')->getNumComp() != $shipping_price->num_comp) { $identifier = 'error-'.time().mt_rand(1000000, 9999999); Yard::instance('shopping')->store($identifier); $data['user_id'] = Auth::user()->id; $data['shopping_user_id'] = $id; - \App\Services\MyLog::writeLog('payment', 'error', 'Yard num_comp is 0 | Yard identifier: '.$identifier, $data); + MyLog::writeLog('payment', 'error', 'Yard num_comp is 0 | Yard identifier: '.$identifier, $data); abort(403, __('msg.compensation_products_cannot_be_0')); } } - + } - - public function datatable(){ + public function datatable() + { $not_show_pids = ProductBuy::getNotShowProductIDs(Auth::user()->id); @@ -294,7 +299,7 @@ class OrderController extends Controller break; case 'mp': $query = Product::select('products.*')->where('active', true)->whereJsonContains('show_on', '2'); - break; + break; case 'cr': $query = Product::select('products.*')->where('active', true)->whereJsonContains('show_on', '6'); break; @@ -302,9 +307,8 @@ class OrderController extends Controller $query = Product::select('products.*')->where('active', true)->whereJsonContains('show_on', '1'); break; } - - foreach($not_show_pids as $not_show_pid){ + foreach ($not_show_pids as $not_show_pid) { $query->where('id', '!=', $not_show_pid); } @@ -314,7 +318,11 @@ class OrderController extends Controller $cartItem = Yard::instance('shopping')->getCartItemByProduct($product->id); $qty = isset($cartItem->qty) ? $cartItem->qty : 0; $rowId = isset($cartItem->rowId) ? $cartItem->rowId : ''; - return ''.$product->name.'
+ + if ($product->isOutOfStock()) { + $controls = '
'.e($product->outOfStockNotice()).'
'; + } else { + $controls = '
@@ -325,6 +333,9 @@ class OrderController extends Controller
'; + } + + return ''.$product->name.'
'.$controls; }) /* ->addColumn('add_card', function (Product $product) { @@ -349,32 +360,33 @@ class OrderController extends Controller
'; })*/ - + ->addColumn('category', function (Product $product) { - $ret = ""; - foreach($product->categories as $category){ + $ret = ''; + foreach ($product->categories as $category) { $ret .= '
'.$category->category->name.'
'; } - + return $ret; }) ->addColumn('picture', function (Product $product) { - if(count($product->images)){ + if (count($product->images)) { return '
'; } - return ""; + + return ''; }) ->addColumn('price_net', function (Product $product) { - return $product->getFormattedPriceWith(true, false). "€"; + return $product->getFormattedPriceWith(true, false).'€'; }) ->addColumn('price_gross', function (Product $product) { - return $product->getFormattedPriceWith(false, false). "€"; + return $product->getFormattedPriceWith(false, false).'€'; }) ->addColumn('price_vk_gross', function (Product $product) { - return $product->getFormattedPriceWith(false, false). "€"; + return $product->getFormattedPriceWith(false, false).'€'; }) ->addColumn('single_commission', function (Product $product) { return $product->single_commission ? 'Handelspanne: '.$product->getFormattedValueCommission().' %' : 'Staffelprovision '; }) - ->filterColumn('product', function($query, $keyword) { - if($keyword != ""){ + ->filterColumn('product', function ($query, $keyword) { + if ($keyword != '') { $query->where('name', 'LIKE', '%'.$keyword.'%'); } }) @@ -414,32 +426,32 @@ class OrderController extends Controller , $order); })*/ - ->rawColumns(['add_card', 'category', 'product', 'quantity', 'picture', 'action', 'single_commission']) ->make(true); } - public function performRequest(){ + public function performRequest() + { - if(Request::ajax()) { + if (Request::ajax()) { $data = Request::all(); $is_for = isset($data['shipping_is_for']) ? $data['shipping_is_for'] : 'ot'; $data['comp_products'] = $this->getCompProducts($is_for); - if($data['action'] === 'updateCart' && isset($data['product_id'])){ - if($product = Product::find($data['product_id'])){ - $image = ""; - if($product->images->count()){ + if ($data['action'] === 'updateCart' && isset($data['product_id'])) { + if ($product = Product::find($data['product_id'])) { + $image = ''; + if ($product->images->count()) { $image = $product->images->first()->slug; } - //get the card item + // get the card item + + // Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, $product->price, $product->tax, ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight]); - //Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, $product->price, $product->tax, ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight]); - $cartItem = Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, $product->price, $product->tax, - //$product->getPriceWith(Yard::instance('shopping')->getUserTaxFree(), true, Yard::instance('shopping')->getUserCountry()), $product->getTaxWith(Yard::instance('shopping')->getUserCountry()), //$product->tax, true? + // $product->getPriceWith(Yard::instance('shopping')->getUserTaxFree(), true, Yard::instance('shopping')->getUserCountry()), $product->getTaxWith(Yard::instance('shopping')->getUserCountry()), //$product->tax, true? [ 'image' => $image, 'slug' => $product->slug, @@ -450,140 +462,144 @@ class OrderController extends Controller 'partner_commission' => $product->partner_commission, ]); - if(Yard::instance('shopping')->getUserTaxFree()){ - //Yard::setTax($cartItem->rowId, 0); - Yard::instance('shopping')->setGlobalTaxRate(0); - }else{ - //Yard::setTax($cartItem->rowId, $product->getTaxWith(Yard::instance('shopping')->getUserCountry())); - } + if (Yard::instance('shopping')->getUserTaxFree()) { + // Yard::setTax($cartItem->rowId, 0); + Yard::instance('shopping')->setGlobalTaxRate(0); + } else { + // Yard::setTax($cartItem->rowId, $product->getTaxWith(Yard::instance('shopping')->getUserCountry())); + } - - if(isset($data['qty']) && $data['qty'] > 0){ + if (isset($data['qty']) && $data['qty'] > 0) { Yard::instance('shopping')->update($cartItem->rowId, $data['qty']); - }else{ - //if 0 get the item by qty:1 and remove it + } else { + // if 0 get the item by qty:1 and remove it Yard::instance('shopping')->remove($cartItem->rowId); } // Yard::instance('shopping')->reCalculate(); $this->checkCompProduct(Yard::instance('shopping')->getNumComp()); - $html_card = view("user.order.yard_view_form", $data)->render(); - $html_comp = view("user.order.comp_product", $data)->render(); + $html_card = view('user.order.yard_view_form', $data)->render(); + $html_comp = view('user.order.comp_product', $data)->render(); - - return response()->json(['response' => true, 'data'=>$data, 'html_card'=>$html_card, 'html_comp'=>$html_comp]); + return response()->json(['response' => true, 'data' => $data, 'html_card' => $html_card, 'html_comp' => $html_comp]); } } - if($data['action'] === 'reCalculateCart') { - //set use_payment_credit - $data['reduce_payment_credit'] = $data['reduce_payment_credit'] == 'true' ? true: false; + if ($data['action'] === 'reCalculateCart') { + // set use_payment_credit + $data['reduce_payment_credit'] = $data['reduce_payment_credit'] == 'true' ? true : false; Yard::instance('shopping')->setReducePaymentCredit($data['reduce_payment_credit']); Yard::instance('shopping')->reCalculate(); - $html_card = view("user.order.yard_view_form", $data)->render(); - $html_comp = view("user.order.comp_product", $data)->render(); - return response()->json(['response' => true, 'data'=>$data, 'html_card'=>$html_card, 'html_comp'=>$html_comp]); + $html_card = view('user.order.yard_view_form', $data)->render(); + $html_comp = view('user.order.comp_product', $data)->render(); + + return response()->json(['response' => true, 'data' => $data, 'html_card' => $html_card, 'html_comp' => $html_comp]); } - if($data['action'] === 'clearCart') { + if ($data['action'] === 'clearCart') { Yard::instance('shopping')->destroy(); - return response()->json(['response' => true, 'data'=>Yard::instance('shopping')->count(), 'html_card'=>'', 'html_comp'=>'']); + + return response()->json(['response' => true, 'data' => Yard::instance('shopping')->count(), 'html_card' => '', 'html_comp' => '']); } - if($data['action'] === 'updateShippingCountry') { - if(isset($data['shipping_country_id'])){ - if($shipping_country = ShippingCountry::find($data['shipping_country_id'])){ - Yard::instance('shopping')->setShippingCountryWithPrice($shipping_country->id, $is_for); //$is_for == 'ot' or 'me' + if ($data['action'] === 'updateShippingCountry') { + if (isset($data['shipping_country_id'])) { + if ($shipping_country = ShippingCountry::find($data['shipping_country_id'])) { + Yard::instance('shopping')->setShippingCountryWithPrice($shipping_country->id, $is_for); // $is_for == 'ot' or 'me' $this->checkCompProduct(Yard::instance('shopping')->getNumComp()); } } - $html_card = view("user.order.yard_view_form", $data)->render(); - $html_comp = view("user.order.comp_product", $data)->render(); - return response()->json(['response' => true, 'data'=>$data, 'html_card'=>$html_card, 'html_comp'=>$html_comp]); + $html_card = view('user.order.yard_view_form', $data)->render(); + $html_comp = view('user.order.comp_product', $data)->render(); + + return response()->json(['response' => true, 'data' => $data, 'html_card' => $html_card, 'html_comp' => $html_comp]); } - if($data['action'] === 'updateCompProduct'){ + if ($data['action'] === 'updateCompProduct') { // $data['comp_product_id'] // $data['comp_num'] - //count_comp_products - $this->updateCompProduct($data); - Yard::instance('shopping')->reCalculateShippingPrice(); - $html_card = view("user.order.yard_view_form", $data)->render(); - $html_comp = view("user.order.comp_product", $data)->render(); + // count_comp_products + $this->updateCompProduct($data); + Yard::instance('shopping')->reCalculateShippingPrice(); + $html_card = view('user.order.yard_view_form', $data)->render(); + $html_comp = view('user.order.comp_product', $data)->render(); - return response()->json(['response' => true, 'data'=>$data, 'html_card'=>$html_card, 'html_comp'=>$html_comp]); + return response()->json(['response' => true, 'data' => $data, 'html_card' => $html_card, 'html_comp' => $html_comp]); - } - return response()->json(['response' => false, 'data'=>$data]); + } + + return response()->json(['response' => false, 'data' => $data]); } } - private function checkCompProduct($count_comp_products){ + private function checkCompProduct($count_comp_products) + { foreach (Yard::instance('shopping')->content() as $row) { - //wenn gleich löschen, da neue Versandkosten - if($row->options->comp > $count_comp_products) { + // wenn gleich löschen, da neue Versandkosten + if ($row->options->comp > $count_comp_products) { Yard::instance('shopping')->remove($row->rowId); } } } - private function updateCompProduct($data){ - //clear old - foreach (Yard::instance('shopping')->content() as $row) { - //wenn kleiner wurde ein produkt entfernt aufgrund der Anzahl - //wenn gleich löschen, da neue Versandkosten - if($row->options->comp === $data['comp_num'] || $row->options->comp > $data['count_comp_products']) { + private function updateCompProduct($data) + { + // clear old + foreach (Yard::instance('shopping')->content() as $row) { + // wenn kleiner wurde ein produkt entfernt aufgrund der Anzahl + // wenn gleich löschen, da neue Versandkosten + + if ($row->options->comp === $data['comp_num'] || $row->options->comp > $data['count_comp_products']) { Yard::instance('shopping')->remove($row->rowId); - } - } + } + } - if(isset($data['comp_product_id'])) { + if (isset($data['comp_product_id'])) { if ($product = Product::find($data['comp_product_id'])) { - $image = ""; + $image = ''; if ($product->images->count()) { $image = $product->images->first()->slug; } $cartItem = Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, 0, 0, - [ - 'image' => $image, - 'slug' => $product->slug, - 'weight' => 0, - 'single_commission' => 0, - 'amount_commission' => 0, - 'value_commission' => 0, - 'partner_commission' => 0, - 'comp' => $data['comp_num'], - 'product_id' => $product->id - ]); + [ + 'image' => $image, + 'slug' => $product->slug, + 'weight' => 0, + 'single_commission' => 0, + 'amount_commission' => 0, + 'value_commission' => 0, + 'partner_commission' => 0, + 'comp' => $data['comp_num'], + 'product_id' => $product->id, + ]); Yard::setTax($cartItem->rowId, 0); } } } - private function getCompProducts($for) { - if($for === 'me' && \App\Models\Setting::getContentBySlug('order_partner_is_comp_me')) { + private function getCompProducts($for) + { + if ($for === 'me' && Setting::getContentBySlug('order_partner_is_comp_me')) { return Product::whereActive(true) - ->where(function($query) { - $query->whereRaw("JSON_CONTAINS(show_on, '\"2\"')") - ->orWhereRaw("JSON_CONTAINS(show_on, '\"11\"')"); - }) - ->where('shipping_addon', true) - ->orderBy('pos', 'DESC') - ->get(); + ->where(function ($query) { + $query->whereRaw("JSON_CONTAINS(show_on, '\"2\"')") + ->orWhereRaw("JSON_CONTAINS(show_on, '\"11\"')"); + }) + ->where('shipping_addon', true) + ->orderBy('pos', 'DESC') + ->get(); } - - if($for === 'ot' && \App\Models\Setting::getContentBySlug('order_partner_is_comp_ot')) { + + if ($for === 'ot' && Setting::getContentBySlug('order_partner_is_comp_ot')) { return Product::whereActive(true) - ->where(function($query) { - $query->whereRaw("JSON_CONTAINS(show_on, '\"1\"')") - ->orWhereRaw("JSON_CONTAINS(show_on, '\"11\"')"); - }) - ->where('shipping_addon', true) - ->orderBy('pos', 'DESC') - ->get(); + ->where(function ($query) { + $query->whereRaw("JSON_CONTAINS(show_on, '\"1\"')") + ->orWhereRaw("JSON_CONTAINS(show_on, '\"11\"')"); + }) + ->where('shipping_addon', true) + ->orderBy('pos', 'DESC') + ->get(); } - + return null; } - - -} \ No newline at end of file +} diff --git a/app/Http/Requests/Inventory/StoreProductStockMovementRequest.php b/app/Http/Requests/Inventory/StoreProductStockMovementRequest.php new file mode 100644 index 0000000..3eb452b --- /dev/null +++ b/app/Http/Requests/Inventory/StoreProductStockMovementRequest.php @@ -0,0 +1,39 @@ +user()?->isAdmin(); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'direction' => ['required', 'in:in,out'], + 'quantity' => ['required', 'integer', 'min:1'], + 'reason' => ['required', 'string', 'max:100'], + 'note' => ['nullable', 'string', 'max:255'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'direction.required' => __('Bitte Eingang oder Ausgang wählen.'), + 'quantity.required' => __('Bitte eine Stückzahl angeben.'), + 'quantity.min' => __('Die Stückzahl muss mindestens 1 sein.'), + 'reason.required' => __('Bitte einen Grund angeben.'), + ]; + } +} diff --git a/app/Http/Requests/Inventory/StoreProductionRequest.php b/app/Http/Requests/Inventory/StoreProductionRequest.php index 4ca4c65..e883e8d 100644 --- a/app/Http/Requests/Inventory/StoreProductionRequest.php +++ b/app/Http/Requests/Inventory/StoreProductionRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Inventory; +use App\Models\Product; use Illuminate\Foundation\Http\FormRequest; class StoreProductionRequest extends FormRequest @@ -16,19 +17,28 @@ class StoreProductionRequest extends FormRequest */ public function rules(): array { + $recipeRequired = $this->recipeRequired(); + return [ 'product_id' => ['required', 'integer', 'exists:products,id'], 'location_id' => ['required', 'integer', 'exists:locations,id'], 'produced_at' => ['required', 'date'], 'quantity' => ['required', 'integer', 'min:1'], 'notes' => ['nullable', 'string', 'max:2000'], - 'ingredient_lines' => ['required', 'array', 'min:1'], + 'ingredient_lines' => [$recipeRequired ? 'required' : 'nullable', 'array', $recipeRequired ? 'min:1' : 'min:0'], 'ingredient_lines.*.ingredient_id' => ['required', 'integer', 'exists:ingredients,id'], 'ingredient_lines.*.stock_entry_id' => ['required', 'integer', 'exists:stock_entries,id'], 'ingredient_lines.*.quantity_used' => ['required', 'string'], ]; } + private function recipeRequired(): bool + { + $product = Product::query()->find($this->input('product_id')); + + return $product === null ? true : ! (bool) $product->no_recipe_required; + } + /** * @return array */ @@ -48,7 +58,7 @@ class StoreProductionRequest extends FormRequest 'stock_entry_id' => (int) $line['stock_entry_id'], 'quantity_used' => $line['quantity_used'], ]; - }, $data['ingredient_lines']), + }, $data['ingredient_lines'] ?? []), ]; } } diff --git a/app/Http/Requests/Inventory/StoreStockDisposalRequest.php b/app/Http/Requests/Inventory/StoreStockDisposalRequest.php new file mode 100644 index 0000000..0a794db --- /dev/null +++ b/app/Http/Requests/Inventory/StoreStockDisposalRequest.php @@ -0,0 +1,95 @@ +user()?->isAdmin(); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'disposal_type' => ['required', Rule::in(['ingredient', 'packaging'])], + 'ingredient_id' => ['nullable', 'integer', 'exists:ingredients,id'], + 'packaging_item_id' => ['nullable', 'integer', 'exists:packaging_items,id'], + 'stock_entry_id' => ['nullable', 'integer', 'exists:stock_entries,id'], + 'location_id' => ['required', 'integer', 'exists:locations,id'], + 'quantity' => ['required', 'numeric', 'min:0.01'], + 'reason' => ['required', 'string', 'max:100'], + 'note' => ['nullable', 'string', 'max:255'], + 'disposed_at' => ['required', 'date'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'location_id.required' => __('Bitte einen Lagerort wählen.'), + 'quantity.required' => __('Bitte eine Menge angeben.'), + 'quantity.min' => __('Die Menge muss größer als 0 sein.'), + 'reason.required' => __('Bitte einen Grund angeben.'), + 'disposed_at.required' => __('Bitte ein Datum angeben.'), + ]; + } + + protected function prepareForValidation(): void + { + $disposedAt = $this->input('disposed_at'); + if (is_string($disposedAt) && preg_match('/^\d{2}\.\d{2}\.\d{4}$/', trim($disposedAt))) { + $disposedAt = Carbon::createFromFormat('d.m.Y', trim($disposedAt))->format('Y-m-d'); + } + + $this->merge([ + 'quantity' => reFormatNumber($this->input('quantity')), + 'disposed_at' => $disposedAt, + ]); + } + + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + if ($this->input('disposal_type') === 'ingredient') { + if (empty($this->input('ingredient_id'))) { + $validator->errors()->add('ingredient_id', __('Bitte einen Rohstoff wählen.')); + } + } elseif ($this->input('disposal_type') === 'packaging') { + if (empty($this->input('packaging_item_id'))) { + $validator->errors()->add('packaging_item_id', __('Bitte einen Verpackungsartikel wählen.')); + } + } + }); + } + + /** + * @return array + */ + public function validatedPayload(): array + { + $data = $this->validated(); + + if (($data['disposal_type'] ?? '') === 'ingredient') { + $data['packaging_item_id'] = null; + $data['unit'] = 'gram'; + } else { + $data['ingredient_id'] = null; + $data['stock_entry_id'] = null; + $data['unit'] = 'piece'; + } + + return $data; + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index e45401b..4260490 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -5,8 +5,10 @@ namespace App\Models; use App\Services\Type; use App\Services\Util; use Cviebrock\EloquentSluggable\Sluggable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -200,6 +202,14 @@ class Product extends Model 'max_buy' => 'bool', 'max_buy_num' => 'int', 'whitelabel' => 'bool', + 'no_recipe_required' => 'bool', + 'is_set' => 'bool', + 'main_product_id' => 'int', + 'main_product_quantity' => 'int', + 'out_of_stock_until' => 'date', + 'out_of_stock_indefinite' => 'bool', + 'min_product_stock' => 'int', + 'critical_product_stock' => 'int', ]; use Sluggable; @@ -247,6 +257,14 @@ class Product extends Model 'max_buy_num', 'shelf_life_type', 'shelf_life_months', + 'no_recipe_required', + 'is_set', + 'main_product_id', + 'main_product_quantity', + 'out_of_stock_until', + 'out_of_stock_indefinite', + 'min_product_stock', + 'critical_product_stock', ]; @@ -369,6 +387,94 @@ class Product extends Model return $this->hasMany(Production::class); } + /** + * Produktbestands-Bewegungen (Eingang/Ausgang). + * + * @return HasMany + */ + public function stockMovements(): HasMany + { + return $this->hasMany(ProductStockMovement::class); + } + + /** + * Set-Bestandteile (Einzelprodukte) dieses Sets mit Menge und Reihenfolge. + * + * @return BelongsToMany + */ + public function setItems(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'product_set_items', 'set_product_id', 'component_product_id') + ->withPivot('quantity', 'pos') + ->withTimestamps() + ->orderByPivot('pos'); + } + + /** + * Sets, in denen dieses Produkt als Bestandteil enthalten ist. + * + * @return BelongsToMany + */ + public function partOfSets(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'product_set_items', 'component_product_id', 'set_product_id') + ->withPivot('quantity', 'pos') + ->withTimestamps(); + } + + /** + * Übergeordnetes Hauptprodukt (z. B. „50 × 15 ml"). + * + * @return BelongsTo + */ + public function mainProduct(): BelongsTo + { + return $this->belongsTo(Product::class, 'main_product_id'); + } + + /** + * Untergeordnete Varianten, die auf dieses Produkt als Hauptprodukt zeigen. + * + * @return HasMany + */ + public function variants(): HasMany + { + return $this->hasMany(Product::class, 'main_product_id'); + } + + /** + * Nur Einzelprodukte (keine Sets). + * + * @param Builder $query + * @return Builder + */ + public function scopeSingleProducts(Builder $query): Builder + { + return $query->where('is_set', false); + } + + /** + * Sets. + * + * @param Builder $query + * @return Builder + */ + public function scopeSets(Builder $query): Builder + { + return $query->where('is_set', true); + } + + /** + * Haupt-/Einzelprodukte, die keinem übergeordneten Hauptprodukt zugeordnet sind. + * + * @param Builder $query + * @return Builder + */ + public function scopeMainProducts(Builder $query): Builder + { + return $query->whereNull('main_product_id'); + } + public function getShortCopy($clean = false, $len = false) { $ret = $this->short_copy ? $this->short_copy : $this->description; @@ -437,6 +543,53 @@ class Product extends Model return isset($this->attributes['price']) ? Util::formatNumber($this->attributes['price']) : ''; } + public function isOutOfStock(): bool + { + if ($this->out_of_stock_indefinite) { + return true; + } + + return $this->out_of_stock_until !== null && $this->out_of_stock_until->endOfDay()->isFuture(); + } + + public function outOfStockRemainingDays(): ?int + { + if ($this->out_of_stock_indefinite || $this->out_of_stock_until === null) { + return null; + } + + if (! $this->out_of_stock_until->endOfDay()->isFuture()) { + return null; + } + + $diffSeconds = $this->out_of_stock_until->copy()->startOfDay()->getTimestamp() - now()->startOfDay()->getTimestamp(); + + return (int) max(0, (int) round($diffSeconds / 86400)); + } + + public function outOfStockNotice(): ?string + { + if ($this->out_of_stock_indefinite) { + return __('Zur Zeit nicht vorrätig'); + } + + $days = $this->outOfStockRemainingDays(); + + if ($days === null) { + return null; + } + + if ($days <= 0) { + return __('In Kürze wieder verfügbar'); + } + + if ($days === 1) { + return __('In ca. 1 Tag wieder da!'); + } + + return __('In ca. :days Tagen wieder da!', ['days' => $days]); + } + public function getFormattedPriceEk() { return isset($this->attributes['price_ek']) ? Util::formatNumber($this->attributes['price_ek']) : ''; diff --git a/app/Models/ProductStockMovement.php b/app/Models/ProductStockMovement.php new file mode 100644 index 0000000..186fcc8 --- /dev/null +++ b/app/Models/ProductStockMovement.php @@ -0,0 +1,70 @@ + + */ + protected function casts(): array + { + return [ + 'quantity' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return MorphTo + */ + public function reference(): MorphTo + { + return $this->morphTo(); + } + + public function isIn(): bool + { + return $this->direction === 'in'; + } + + /** + * Vorzeichenbehaftete Menge (Eingang positiv, Ausgang negativ). + */ + public function signedQuantity(): int + { + return $this->isIn() ? (int) $this->quantity : -(int) $this->quantity; + } +} diff --git a/app/Models/StockDisposal.php b/app/Models/StockDisposal.php new file mode 100644 index 0000000..6261d3d --- /dev/null +++ b/app/Models/StockDisposal.php @@ -0,0 +1,87 @@ + + */ + protected function casts(): array + { + return [ + 'disposed_at' => 'date', + 'quantity' => 'decimal:2', + ]; + } + + /** + * @return BelongsTo + */ + public function ingredient(): BelongsTo + { + return $this->belongsTo(Ingredient::class); + } + + /** + * @return BelongsTo + */ + public function packagingItem(): BelongsTo + { + return $this->belongsTo(PackagingItem::class); + } + + /** + * @return BelongsTo + */ + public function stockEntry(): BelongsTo + { + return $this->belongsTo(StockEntry::class); + } + + /** + * @return BelongsTo + */ + public function location(): BelongsTo + { + return $this->belongsTo(Location::class); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isIngredient(): bool + { + return $this->disposal_type === 'ingredient'; + } + + public function articleName(): string + { + return $this->isIngredient() + ? ($this->ingredient?->name ?? '—') + : ($this->packagingItem?->name ?? '—'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a53802e..c6b8cf3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,12 @@ namespace App\Providers; +use App\Services\InventoryService; +use App\Services\ProductStockService; use Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\URL; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -18,6 +21,18 @@ class AppServiceProvider extends ServiceProvider { Schema::defaultStringLength(191); URL::forceScheme('https'); + + View::composer('layouts.includes.layout-sidenav', function ($view): void { + $criticalIngredients = 0; + $criticalProducts = 0; + $user = auth()->user(); + if ($user && $user->isCopyReader()) { + $criticalIngredients = app(InventoryService::class)->criticalIngredientCount(); + $criticalProducts = app(ProductStockService::class)->criticalProductCount(); + } + $view->with('criticalIngredientCount', $criticalIngredients); + $view->with('criticalProductCount', $criticalProducts); + }); } /** diff --git a/app/Repositories/ProductRepository.php b/app/Repositories/ProductRepository.php index d7ba0aa..c1152d2 100644 --- a/app/Repositories/ProductRepository.php +++ b/app/Repositories/ProductRepository.php @@ -33,8 +33,39 @@ class ProductRepository extends BaseRepository $data['whitelabel'] = isset($data['whitelabel']) ? 1 : 0; $data['shipping_addon'] = isset($data['shipping_addon']) ? 1 : 0; $data['max_buy'] = isset($data['max_buy']) ? 1 : 0; + $data['no_recipe_required'] = isset($data['no_recipe_required']) ? 1 : 0; + $data['is_set'] = isset($data['is_set']) ? 1 : 0; $data['show_on'] = isset($data['show_on']) ? $data['show_on'] : null; + $data['main_product_quantity'] = isset($data['main_product_quantity']) && $data['main_product_quantity'] !== '' + ? (int) $data['main_product_quantity'] + : null; + + // Ein Set ist selbst keine Variante eines Hauptprodukts. + $data['main_product_id'] = ! $data['is_set'] && isset($data['main_product_id']) && $data['main_product_id'] !== '' + ? (int) $data['main_product_id'] + : null; + + // AP-11: Produktbestand-Schwellwerte (leer => null). + $data['min_product_stock'] = isset($data['min_product_stock']) && $data['min_product_stock'] !== '' + ? max(0, (int) $data['min_product_stock']) + : null; + $data['critical_product_stock'] = isset($data['critical_product_stock']) && $data['critical_product_stock'] !== '' + ? max(0, (int) $data['critical_product_stock']) + : null; + + // AP-03: „Nicht vorrätig"-Status. „Unbestimmt" hat Vorrang vor der Tagesangabe. + $data['out_of_stock_indefinite'] = isset($data['out_of_stock_indefinite']) ? 1 : 0; + + if ($data['out_of_stock_indefinite']) { + $data['out_of_stock_until'] = null; + } elseif (isset($data['out_of_stock_active']) && isset($data['out_of_stock_days']) && $data['out_of_stock_days'] !== '') { + $days = max(0, (int) $data['out_of_stock_days']); + $data['out_of_stock_until'] = now()->addDays($days)->startOfDay(); + } else { + $data['out_of_stock_until'] = null; + } + if (array_key_exists('shelf_life_type', $data)) { if ($data['shelf_life_type'] === '' || $data['shelf_life_type'] === null) { $data['shelf_life_type'] = null; @@ -60,14 +91,54 @@ class ProductRepository extends BaseRepository $this->updateWLVariants(isset($data['whitelabel_variants']) ? $data['whitelabel_variants'] : []); $this->updateWLImageAttributs(isset($data['image_wl_attributes']) ? $data['image_wl_attributes'] : [], isset($data['whitelabel_variants']) ? $data['whitelabel_variants'] : []); - $this->updateIngredients($data); - $this->updateManufacturerIngredients($data); - $this->updatePackagings($data); + if ($this->model->is_set) { + // Sets haben keine eigene Rezeptur/Verpackung und werden nicht produziert. + ProductIngredient::where('product_id', $this->model->id)->delete(); + $this->model->packagings()->detach(); + $this->updateSetItems($data); + } else { + $this->model->setItems()->detach(); + $this->updateIngredients($data); + $this->updateManufacturerIngredients($data); + $this->updatePackagings($data); + } + $this->updateCountryPrices($data); return $this->model; } + public function updateSetItems(array $data = []): bool + { + if (! isset($data['set_component_id']) || ! is_array($data['set_component_id'])) { + $this->model->setItems()->detach(); + + return true; + } + + $ids = $data['set_component_id']; + $quantities = $data['set_quantity'] ?? []; + $syncData = []; + foreach ($ids as $index => $componentId) { + $cid = (int) $componentId; + if ($cid <= 0 || $cid === (int) $this->model->id) { + continue; + } + $qty = (int) ($quantities[$index] ?? 1); + if ($qty < 1) { + $qty = 1; + } + $syncData[$cid] = [ + 'quantity' => $qty, + 'pos' => (int) $index, + ]; + } + + $this->model->setItems()->sync($syncData); + + return true; + } + public function updatePackagings(array $data = []): bool { if (! isset($data['pp_packaging_item_id']) || ! is_array($data['pp_packaging_item_id'])) { @@ -400,6 +471,17 @@ class ProductRepository extends BaseRepository } $this->model->packagings()->sync($packSync); + $setSync = []; + foreach ($model->setItems()->get() as $component) { + $setSync[$component->id] = [ + 'quantity' => $component->pivot->quantity !== null ? (int) $component->pivot->quantity : 1, + 'pos' => (int) ($component->pivot->pos ?? 0), + ]; + } + if ($setSync !== []) { + $this->model->setItems()->sync($setSync); + } + foreach ($model->country_prices as $cp) { CountryPrice::create([ 'country_id' => $cp->country_id, diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 0000000..813c684 --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,309 @@ +|null $ingredientIds Optionaler Filter; null = alle. + * @return array [ingredient_id => grams] + */ + public function remainingByIngredient(?array $ingredientIds = null): array + { + $received = StockEntry::query() + ->where('status', 'received') + ->where('entry_type', 'ingredient') + ->whereNotNull('ingredient_id') + ->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds)) + ->selectRaw('ingredient_id, SUM(received_quantity) as qty') + ->groupBy('ingredient_id') + ->pluck('qty', 'ingredient_id'); + + $consumed = ProductionIngredient::query() + ->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds)) + ->selectRaw('ingredient_id, SUM(quantity_used) as qty') + ->groupBy('ingredient_id') + ->pluck('qty', 'ingredient_id'); + + $disposed = $this->disposedByIngredient($ingredientIds); + + $result = []; + foreach ($received as $id => $qty) { + $result[(int) $id] = round((float) $qty - (float) ($consumed[$id] ?? 0) - (float) ($disposed[(int) $id] ?? 0), 2); + } + + return $result; + } + + /** + * Per Ausgang/Ausschuss entnommene Menge je Rohstoff in Gramm. + * + * @param array|null $ingredientIds + * @return array [ingredient_id => grams] + */ + public function disposedByIngredient(?array $ingredientIds = null): array + { + $rows = StockDisposal::query() + ->where('disposal_type', 'ingredient') + ->whereNotNull('ingredient_id') + ->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds)) + ->selectRaw('ingredient_id, SUM(quantity) as qty') + ->groupBy('ingredient_id') + ->pluck('qty', 'ingredient_id'); + + $result = []; + foreach ($rows as $id => $qty) { + $result[(int) $id] = round((float) $qty, 2); + } + + return $result; + } + + /** + * Restbestand eines Rohstoffs je Lagerort. + * + * @return array [location_id => grams] + */ + public function remainingByLocationForIngredient(int $ingredientId): array + { + $received = StockEntry::query() + ->where('status', 'received') + ->where('entry_type', 'ingredient') + ->where('ingredient_id', $ingredientId) + ->selectRaw('location_id, SUM(received_quantity) as qty') + ->groupBy('location_id') + ->pluck('qty', 'location_id'); + + $consumed = ProductionIngredient::query() + ->join('stock_entries', 'stock_entries.id', '=', 'production_ingredients.stock_entry_id') + ->where('production_ingredients.ingredient_id', $ingredientId) + ->selectRaw('stock_entries.location_id as location_id, SUM(production_ingredients.quantity_used) as qty') + ->groupBy('stock_entries.location_id') + ->pluck('qty', 'location_id'); + + $disposed = StockDisposal::query() + ->where('disposal_type', 'ingredient') + ->where('ingredient_id', $ingredientId) + ->selectRaw('location_id, SUM(quantity) as qty') + ->groupBy('location_id') + ->pluck('qty', 'location_id'); + + $result = []; + foreach ($received as $locationId => $qty) { + $result[(int) $locationId] = round((float) $qty - (float) ($consumed[$locationId] ?? 0) - (float) ($disposed[$locationId] ?? 0), 2); + } + + return $result; + } + + /** + * Restbestand je Verpackungsartikel in Stück (Wareneingang − Produktionsverbrauch − Ausschuss). + * + * @param array|null $packagingItemIds + * @return array [packaging_item_id => pieces] + */ + public function remainingByPackagingItem(?array $packagingItemIds = null): array + { + $received = StockEntry::query() + ->where('status', 'received') + ->whereIn('entry_type', ['packaging', 'shipping']) + ->whereNotNull('packaging_item_id') + ->when($packagingItemIds !== null, fn ($q) => $q->whereIn('packaging_item_id', $packagingItemIds)) + ->selectRaw('packaging_item_id, SUM(received_quantity) as qty') + ->groupBy('packaging_item_id') + ->pluck('qty', 'packaging_item_id'); + + $consumed = ProductionPackaging::query() + ->when($packagingItemIds !== null, fn ($q) => $q->whereIn('packaging_item_id', $packagingItemIds)) + ->selectRaw('packaging_item_id, SUM(quantity_used) as qty') + ->groupBy('packaging_item_id') + ->pluck('qty', 'packaging_item_id'); + + $disposed = $this->disposedByPackagingItem($packagingItemIds); + + $result = []; + foreach ($received as $id => $qty) { + $result[(int) $id] = round((float) $qty - (float) ($consumed[$id] ?? 0) - (float) ($disposed[(int) $id] ?? 0), 2); + } + + return $result; + } + + /** + * Per Ausgang/Ausschuss entnommene Menge je Verpackungsartikel in Stück. + * + * @param array|null $packagingItemIds + * @return array [packaging_item_id => pieces] + */ + public function disposedByPackagingItem(?array $packagingItemIds = null): array + { + $rows = StockDisposal::query() + ->where('disposal_type', 'packaging') + ->whereNotNull('packaging_item_id') + ->when($packagingItemIds !== null, fn ($q) => $q->whereIn('packaging_item_id', $packagingItemIds)) + ->selectRaw('packaging_item_id, SUM(quantity) as qty') + ->groupBy('packaging_item_id') + ->pluck('qty', 'packaging_item_id'); + + $result = []; + foreach ($rows as $id => $qty) { + $result[(int) $id] = round((float) $qty, 2); + } + + return $result; + } + + /** + * Offene (bestellte, noch nicht eingegangene) Menge je Rohstoff in Gramm. + * + * @param array|null $ingredientIds + * @return array [ingredient_id => grams] + */ + public function openOrderQuantityByIngredient(?array $ingredientIds = null): array + { + $rows = StockEntry::query() + ->where('status', 'pending') + ->where('entry_type', 'ingredient') + ->whereNotNull('ingredient_id') + ->when($ingredientIds !== null, fn ($q) => $q->whereIn('ingredient_id', $ingredientIds)) + ->selectRaw('ingredient_id, SUM(ordered_quantity) as qty') + ->groupBy('ingredient_id') + ->pluck('qty', 'ingredient_id'); + + $result = []; + foreach ($rows as $id => $qty) { + $result[(int) $id] = round((float) $qty, 2); + } + + return $result; + } + + /** + * Durchschnittlicher Tagesverbrauch je Rohstoff (Gramm/Tag) aus der Produktionshistorie. + * + * @param array|null $ingredientIds + * @return array [ingredient_id => grams_per_day] + */ + public function dailyConsumptionByIngredient(?array $ingredientIds = null, int $windowDays = self::CONSUMPTION_WINDOW_DAYS): array + { + $windowDays = max(1, $windowDays); + $since = Carbon::now()->subDays($windowDays)->startOfDay(); + + $rows = ProductionIngredient::query() + ->join('productions', 'productions.id', '=', 'production_ingredients.production_id') + ->where('productions.produced_at', '>=', $since) + ->when($ingredientIds !== null, fn ($q) => $q->whereIn('production_ingredients.ingredient_id', $ingredientIds)) + ->selectRaw('production_ingredients.ingredient_id as ingredient_id, SUM(production_ingredients.quantity_used) as qty') + ->groupBy('production_ingredients.ingredient_id') + ->pluck('qty', 'ingredient_id'); + + $result = []; + foreach ($rows as $id => $qty) { + $result[(int) $id] = round((float) $qty / $windowDays, 4); + } + + return $result; + } + + /** + * Tage bis der Bestand bei gleichbleibendem Verbrauch aufgebraucht ist (null = kein Verbrauch). + */ + public function daysUntilEmpty(float $remaining, ?float $dailyConsumption): ?int + { + if ($dailyConsumption === null || $dailyConsumption <= 0) { + return null; + } + if ($remaining <= 0) { + return 0; + } + + return (int) floor($remaining / $dailyConsumption); + } + + /** + * Voraussichtliches Datum, an dem der Bestand auf null fällt (null = kein Verbrauch). + */ + public function expectedEmptyDate(float $remaining, ?float $dailyConsumption): ?Carbon + { + $days = $this->daysUntilEmpty($remaining, $dailyConsumption); + if ($days === null) { + return null; + } + + return Carbon::now()->startOfDay()->addDays($days); + } + + /** + * Bestands-Status eines Rohstoffs: + * - "critical": Meldebestand gepflegt und unterschritten (rot, Badge-relevant). + * - "critical_ordered": kritisch, aber es existiert bereits eine offene Bestellung (entschärft). + * - "warning": Bestand reicht voraussichtlich nicht mehr bis zur nächsten Lieferung. + * - "ok": ausreichend. + */ + public function stockStatus(?float $minStockAlert, float $remaining, ?float $dailyConsumption, ?int $leadDays, bool $hasOpenOrder = false): string + { + if ($minStockAlert !== null && $minStockAlert > 0 && $remaining <= $minStockAlert) { + return $hasOpenOrder ? 'critical_ordered' : 'critical'; + } + + $days = $this->daysUntilEmpty($remaining, $dailyConsumption); + if ($days !== null) { + $lead = $leadDays !== null && $leadDays > 0 ? $leadDays : self::DEFAULT_LEAD_DAYS; + if ($days <= $lead) { + return 'warning'; + } + } + + return 'ok'; + } + + /** + * Anzahl der kritischen (Meldebestand unterschritten) aktiven Rohstoffe – für das Sidenav-Badge. + */ + public function criticalIngredientCount(): int + { + $ingredients = Ingredient::query() + ->where('active', true) + ->whereNotNull('min_stock_alert') + ->where('min_stock_alert', '>', 0) + ->get(['id', 'min_stock_alert']); + + if ($ingredients->isEmpty()) { + return 0; + } + + $ids = $ingredients->pluck('id')->all(); + $remaining = $this->remainingByIngredient($ids); + $openOrders = $this->openOrderQuantityByIngredient($ids); + + return $ingredients->filter(function (Ingredient $ingredient) use ($remaining, $openOrders) { + $rem = $remaining[$ingredient->id] ?? 0.0; + if ($rem > (float) $ingredient->min_stock_alert) { + return false; + } + + // Kritisch, aber bereits nachbestellt => entschärft, nicht im Badge zählen. + return ($openOrders[$ingredient->id] ?? 0.0) <= 0; + })->count(); + } +} diff --git a/app/Services/ProductStockService.php b/app/Services/ProductStockService.php new file mode 100644 index 0000000..4b67db0 --- /dev/null +++ b/app/Services/ProductStockService.php @@ -0,0 +1,146 @@ +|null $productIds + * @return array [product_id => stock] + */ + public function currentStockByProduct(?array $productIds = null): array + { + $rows = ProductStockMovement::query() + ->when($productIds !== null, fn ($q) => $q->whereIn('product_id', $productIds)) + ->selectRaw("product_id, SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END) as stock") + ->groupBy('product_id') + ->pluck('stock', 'product_id'); + + $result = []; + foreach ($rows as $id => $stock) { + $result[(int) $id] = (int) $stock; + } + + return $result; + } + + public function currentStock(int $productId): int + { + return $this->currentStockByProduct([$productId])[$productId] ?? 0; + } + + /** + * Bucht eine Bestandsbewegung. Menge wird immer positiv gespeichert, die Richtung steuert das Vorzeichen. + * + * @param array{type: string, id: int}|Model|null $reference + */ + public function recordMovement( + Product $product, + string $direction, + int $quantity, + string $reason, + string $source = 'manual', + ?string $note = null, + ?int $userId = null, + Model|array|null $reference = null, + ): ProductStockMovement { + $attributes = [ + 'product_id' => $product->id, + 'direction' => $direction === 'out' ? 'out' : 'in', + 'quantity' => max(0, $quantity), + 'reason' => $reason, + 'source' => $source, + 'note' => $note, + 'user_id' => $userId, + ]; + + if ($reference instanceof Model) { + $attributes['reference_type'] = $reference->getMorphClass(); + $attributes['reference_id'] = $reference->getKey(); + } elseif (is_array($reference)) { + $attributes['reference_type'] = $reference['type']; + $attributes['reference_id'] = $reference['id']; + } + + return ProductStockMovement::query()->create($attributes); + } + + /** + * Verbucht den Produktbestand einer Produktion idempotent: gleicht die bereits gebuchte + * Menge dieser Produktion mit der Soll-Stückzahl ab und bucht nur die Differenz. + */ + public function recordProductionStock(Production $production, ?int $userId = null): void + { + $production->loadMissing('product'); + + $alreadyBooked = (int) ProductStockMovement::query() + ->where('source', 'production') + ->where('reference_type', $production->getMorphClass()) + ->where('reference_id', $production->getKey()) + ->selectRaw("COALESCE(SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END), 0) as net") + ->value('net'); + + $target = (int) $production->quantity; + $delta = $target - $alreadyBooked; + + if ($delta === 0) { + return; + } + + $this->recordMovement( + $production->product, + $delta > 0 ? 'in' : 'out', + abs($delta), + $alreadyBooked === 0 ? __('Produktion') : __('Produktionskorrektur'), + 'production', + null, + $userId ?? $production->produced_by, + $production, + ); + } + + /** + * Bestands-Status: "critical" (rot) ≤ kritischer Schwellwert, "warning" (gelb) ≤ Meldebestand, sonst "ok". + */ + public function productStatus(int $stock, ?int $minStock, ?int $criticalStock): string + { + if ($criticalStock !== null && $stock <= $criticalStock) { + return 'critical'; + } + if ($minStock !== null && $stock <= $minStock) { + return 'warning'; + } + + return 'ok'; + } + + /** + * Anzahl kritischer Hauptprodukte (Bestand ≤ kritischer Schwellwert) – für das Sidenav-Badge. + */ + public function criticalProductCount(): int + { + $products = Product::query() + ->where('active', true) + ->where('is_set', false) + ->whereNull('main_product_id') + ->whereNotNull('critical_product_stock') + ->get(['id', 'critical_product_stock']); + + if ($products->isEmpty()) { + return 0; + } + + $stock = $this->currentStockByProduct($products->pluck('id')->all()); + + return $products->filter(function (Product $product) use ($stock) { + return ($stock[$product->id] ?? 0) <= (int) $product->critical_product_stock; + })->count(); + } +} diff --git a/app/Services/ProductionService.php b/app/Services/ProductionService.php index 7cf73bc..2aaf340 100644 --- a/app/Services/ProductionService.php +++ b/app/Services/ProductionService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\Product; use App\Models\Production; +use App\Models\ProductionIngredient; use App\Models\StockEntry; use Carbon\Carbon; use Illuminate\Support\Collection; @@ -12,6 +13,10 @@ use Illuminate\Validation\ValidationException; class ProductionService { + public function __construct( + protected ProductStockService $productStockService, + ) {} + /** * @param array $data * @param array $ingredientLines @@ -20,7 +25,7 @@ class ProductionService { return DB::transaction(function () use ($data, $ingredientLines, $userId) { $product = Product::query() - ->with(['p_ingredients', 'packagings']) + ->with(['manufacturer_ingredients', 'packagings']) ->findOrFail($data['product_id']); $locationId = (int) $data['location_id']; @@ -29,44 +34,22 @@ class ProductionService throw ValidationException::withMessages(['quantity' => __('Die Stückzahl muss mindestens 1 sein.')]); } - if ($product->p_ingredients->isEmpty()) { - throw ValidationException::withMessages(['product_id' => __('Das Produkt hat keine Rezeptur (Inhaltsstoffe).')]); - } + $this->assertNotASet($product); - $requiredGrams = $this->requiredGramsByIngredient($product, $producedQty); + if ($product->no_recipe_required) { + $ingredientLines = []; + } else { + $this->assertManufacturerRecipe($product); - $sums = []; - foreach ($ingredientLines as $line) { - $iid = (int) $line['ingredient_id']; - $used = $this->parseQuantity($line['quantity_used'] ?? null); - $sums[$iid] = ($sums[$iid] ?? 0) + $used; - } + $requiredGrams = $this->requiredGramsByIngredient($product, $producedQty); - foreach ($requiredGrams as $iid => $req) { - $sum = $sums[$iid] ?? 0; - if (abs($sum - $req) > 0.02) { - throw ValidationException::withMessages([ - 'ingredient_lines' => __('Summe der Chargen-Mengen pro INCI muss dem Soll-Verbrauch entsprechen (INCI :id: Soll :req g, Ist :sum g).', [ - 'id' => $iid, - 'req' => number_format($req, 2, ',', '.'), - 'sum' => number_format($sum, 2, ',', '.'), - ]), - ]); + $this->assertLinesMatchRecipe($requiredGrams, $ingredientLines); + + foreach ($ingredientLines as $line) { + $this->assertStockEntryMatchesLine($line, $locationId); } } - foreach ($sums as $iid => $_sum) { - if (! isset($requiredGrams[$iid])) { - throw ValidationException::withMessages([ - 'ingredient_lines' => __('Unerwarteter Inhaltsstoff in den Chargen-Zeilen.'), - ]); - } - } - - foreach ($ingredientLines as $line) { - $this->assertStockEntryMatchesLine($line, $locationId); - } - $mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines); $production = Production::query()->create([ @@ -87,17 +70,10 @@ class ProductionService ]); } - foreach ($product->packagings as $bom) { - $perUnit = (float) ($bom->pivot->quantity ?? 1); - $pieces = (int) round($perUnit * $producedQty); - if ($pieces < 1) { - $pieces = 1; - } - $production->productionPackagings()->create([ - 'packaging_item_id' => $bom->id, - 'quantity_used' => $pieces, - ]); - } + $this->syncPackagingSnapshot($production, $product, $producedQty); + + $production->setRelation('product', $product); + $this->productStockService->recordProductionStock($production, $userId); return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']); }); @@ -109,9 +85,9 @@ class ProductionService */ public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId): Production { - return DB::transaction(function () use ($production, $data, $ingredientLines) { + return DB::transaction(function () use ($production, $data, $ingredientLines, $userId) { $product = Product::query() - ->with(['p_ingredients', 'packagings']) + ->with(['manufacturer_ingredients', 'packagings']) ->findOrFail($data['product_id']); $locationId = (int) $data['location_id']; @@ -120,36 +96,22 @@ class ProductionService throw ValidationException::withMessages(['quantity' => __('Die Stückzahl muss mindestens 1 sein.')]); } - if ($product->p_ingredients->isEmpty()) { - throw ValidationException::withMessages(['product_id' => __('Das Produkt hat keine Rezeptur (Inhaltsstoffe).')]); - } + $this->assertNotASet($product); - $requiredGrams = $this->requiredGramsByIngredient($product, $producedQty); + if ($product->no_recipe_required) { + $ingredientLines = []; + } else { + $this->assertManufacturerRecipe($product); - $sums = []; - foreach ($ingredientLines as $line) { - $iid = (int) $line['ingredient_id']; - $used = $this->parseQuantity($line['quantity_used'] ?? null); - $sums[$iid] = ($sums[$iid] ?? 0) + $used; - } + $requiredGrams = $this->requiredGramsByIngredient($product, $producedQty); - foreach ($requiredGrams as $iid => $req) { - $sum = $sums[$iid] ?? 0; - if (abs($sum - $req) > 0.02) { - throw ValidationException::withMessages([ - 'ingredient_lines' => __('Summe der Chargen-Mengen pro INCI muss dem Soll-Verbrauch entsprechen (INCI :id: Soll :req g, Ist :sum g).', [ - 'id' => $iid, - 'req' => number_format($req, 2, ',', '.'), - 'sum' => number_format($sum, 2, ',', '.'), - ]), - ]); + $this->assertLinesMatchRecipe($requiredGrams, $ingredientLines); + + foreach ($ingredientLines as $line) { + $this->assertStockEntryMatchesLine($line, $locationId); } } - foreach ($ingredientLines as $line) { - $this->assertStockEntryMatchesLine($line, $locationId); - } - $mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines); $production->update([ @@ -171,33 +133,102 @@ class ProductionService } $production->productionPackagings()->delete(); - foreach ($product->packagings as $bom) { - $perUnit = (float) ($bom->pivot->quantity ?? 1); - $pieces = (int) round($perUnit * $producedQty); - if ($pieces < 1) { - $pieces = 1; - } - $production->productionPackagings()->create([ - 'packaging_item_id' => $bom->id, - 'quantity_used' => $pieces, - ]); - } + $this->syncPackagingSnapshot($production, $product, $producedQty); + + $production->setRelation('product', $product); + $this->productStockService->recordProductionStock($production, $userId); return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']); }); } + /** + * Sets sind Bündel aus Einzelprodukten und werden nicht produziert. + */ + public function assertNotASet(Product $product): void + { + if ($product->is_set) { + throw ValidationException::withMessages([ + 'product_id' => __('Sets können nicht produziert werden. Bitte ein Einzelprodukt wählen.'), + ]); + } + } + + /** + * Produktion basiert ausschließlich auf der Hersteller-Rezeptur (kein Fallback auf die Produkt-Rezeptur). + */ + public function assertManufacturerRecipe(Product $product): void + { + $product->loadMissing('manufacturer_ingredients'); + + if ($product->manufacturer_ingredients->isEmpty()) { + throw ValidationException::withMessages([ + 'product_id' => __('Für dieses Produkt ist keine Hersteller-Rezeptur gepflegt. Eine Produktion ist erst möglich, wenn eine Hersteller-Rezeptur hinterlegt wurde.'), + ]); + } + } + + /** + * @param array $requiredGrams + * @param array $ingredientLines + */ + private function assertLinesMatchRecipe(array $requiredGrams, array $ingredientLines): void + { + $sums = []; + foreach ($ingredientLines as $line) { + $iid = (int) $line['ingredient_id']; + $used = $this->parseQuantity($line['quantity_used'] ?? null); + $sums[$iid] = ($sums[$iid] ?? 0) + $used; + } + + foreach ($requiredGrams as $iid => $req) { + $sum = $sums[$iid] ?? 0; + if (abs($sum - $req) > 0.02) { + throw ValidationException::withMessages([ + 'ingredient_lines' => __('Summe der Chargen-Mengen pro INCI muss dem Soll-Verbrauch entsprechen (INCI :id: Soll :req g, Ist :sum g).', [ + 'id' => $iid, + 'req' => number_format($req, 2, ',', '.'), + 'sum' => number_format($sum, 2, ',', '.'), + ]), + ]); + } + } + + foreach ($sums as $iid => $_sum) { + if (! isset($requiredGrams[$iid])) { + throw ValidationException::withMessages([ + 'ingredient_lines' => __('Unerwarteter Inhaltsstoff in den Chargen-Zeilen.'), + ]); + } + } + } + + private function syncPackagingSnapshot(Production $production, Product $product, int $producedQty): void + { + foreach ($product->packagings as $bom) { + $perUnit = (float) ($bom->pivot->quantity ?? 1); + $pieces = (int) round($perUnit * $producedQty); + if ($pieces < 1) { + $pieces = 1; + } + $production->productionPackagings()->create([ + 'packaging_item_id' => $bom->id, + 'quantity_used' => $pieces, + ]); + } + } + /** * @return array */ public function requiredGramsByIngredient(Product $product, int $producedQuantity): array { $required = []; - foreach ($product->p_ingredients as $ing) { + foreach ($product->manufacturer_ingredients as $ing) { $gram = $ing->pivot->gram; if ($gram === null || $gram === '') { throw ValidationException::withMessages([ - 'product_id' => __('Für „:name“ fehlt der Anteil (%) in der Rezeptur.', ['name' => $ing->name]), + 'product_id' => __('Für „:name“ fehlt der Anteil (%) in der Hersteller-Rezeptur.', ['name' => $ing->name]), ]); } $factor = (float) ($ing->pivot->factor ?? 1.1); @@ -251,52 +282,107 @@ class ProductionService } /** - * Letzte empfangene Wareneingänge pro Inhaltsstoff am Standort (max. 3). + * Verbrauchte Menge je Wareneingang (über alle Produktionen), optional ohne eine bestimmte Produktion. + * + * @param array $stockEntryIds + * @return array + */ + public function consumedByStockEntry(array $stockEntryIds, ?int $excludeProductionId = null): array + { + if ($stockEntryIds === []) { + return []; + } + + $rows = ProductionIngredient::query() + ->selectRaw('stock_entry_id, SUM(quantity_used) as used') + ->whereIn('stock_entry_id', $stockEntryIds) + ->when($excludeProductionId !== null, fn ($q) => $q->where('production_id', '!=', $excludeProductionId)) + ->groupBy('stock_entry_id') + ->pluck('used', 'stock_entry_id'); + + $result = []; + foreach ($rows as $stockEntryId => $used) { + $result[(int) $stockEntryId] = (float) $used; + } + + return $result; + } + + /** + * Wareneingänge eines Inhaltsstoffs am Standort mit Restbestand > 0 (FEFO-Reihenfolge). * * @return Collection */ - public function latestStockEntriesForIngredient(int $ingredientId, int $locationId, int $limit = 3) + public function availableStockEntriesForIngredient(int $ingredientId, int $locationId, ?int $excludeProductionId = null): Collection { - return StockEntry::query() + $entries = StockEntry::query() + ->with('supplier') ->where('status', 'received') ->where('entry_type', 'ingredient') ->where('ingredient_id', $ingredientId) ->where('location_id', $locationId) - ->orderByDesc('received_at') - ->orderByDesc('id') - ->limit($limit) + ->orderByRaw('best_before is null, best_before asc') + ->orderBy('id') ->get(); + + $consumed = $this->consumedByStockEntry($entries->pluck('id')->all(), $excludeProductionId); + + return $entries + ->map(function (StockEntry $entry) use ($consumed) { + $received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0; + $entry->setAttribute('remaining_quantity', round($received - ($consumed[(int) $entry->id] ?? 0.0), 2)); + + return $entry; + }) + ->filter(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity') > 0.0) + ->values(); + } + + public function stockEntryLabel(StockEntry $entry): string + { + $parts = []; + $parts[] = $entry->supplier?->name ?: __('Lieferant unbekannt'); + $parts[] = $entry->batch_number ?: ('#'.$entry->id); + if ($entry->best_before) { + $parts[] = $entry->best_before->format('d.m.Y'); + } + + return implode(' - ', $parts); } /** * @return array */ - public function buildRecipePayload(Product $product, int $locationId, int $productionQuantity): array + public function buildRecipePayload(Product $product, int $locationId, int $productionQuantity, ?int $excludeProductionId = null): array { - $product->loadMissing(['p_ingredients', 'packagings.packagingMaterial']); + $product->loadMissing(['manufacturer_ingredients', 'packagings.packagingMaterial']); $qty = max(1, $productionQuantity); + $recipeRequired = ! (bool) $product->no_recipe_required; + $hasRecipe = $product->manufacturer_ingredients->isNotEmpty(); $ingredients = []; - foreach ($product->p_ingredients as $ing) { + foreach ($product->manufacturer_ingredients as $ing) { $gram = $ing->pivot->gram; $factor = (float) ($ing->pivot->factor ?? 1.1); - $req = ($gram !== null && $gram !== '') ? (float) $gram * $factor * $qty : null; + $hasGram = $gram !== null && $gram !== ''; + $req = $hasGram ? (float) $gram * $factor * $qty : null; $ingredients[] = [ 'id' => $ing->id, 'name' => $ing->name, - 'gram' => $gram !== null && $gram !== '' ? (float) $gram : null, + 'gram' => $hasGram ? (float) $gram : null, 'factor' => $factor, 'required_grams_total' => $req, - 'stock_entries' => $this->latestStockEntriesForIngredient((int) $ing->id, $locationId)->map(function (StockEntry $se) { - return [ - 'id' => $se->id, - 'batch_number' => $se->batch_number, - 'best_before' => $se->best_before?->format('Y-m-d'), - 'received_at' => $se->received_at?->format('Y-m-d'), - 'received_quantity' => $se->received_quantity !== null ? (float) $se->received_quantity : null, - ]; - })->values()->all(), + 'stock_entries' => $this->availableStockEntriesForIngredient((int) $ing->id, $locationId, $excludeProductionId) + ->map(function (StockEntry $se) { + return [ + 'id' => $se->id, + 'label' => $this->stockEntryLabel($se), + 'batch_number' => $se->batch_number, + 'best_before' => $se->best_before?->format('Y-m-d'), + 'remaining' => (float) $se->getAttribute('remaining_quantity'), + ]; + })->values()->all(), ]; } @@ -307,7 +393,7 @@ class ProductionService 'id' => $pk->id, 'name' => $pk->name, 'quantity_per_product' => $perUnit, - 'total_pieces' => (int) round($perUnit * max(1, $productionQuantity)), + 'total_pieces' => (int) round($perUnit * $qty), 'weight_grams' => $pk->weight_grams !== null ? (float) $pk->weight_grams : null, 'material_name' => $pk->packagingMaterial?->name, ]; @@ -320,8 +406,10 @@ class ProductionService 'shelf_life_type' => $product->shelf_life_type, 'shelf_life_months' => $product->shelf_life_months, ], + 'recipe_required' => $recipeRequired, + 'has_recipe' => $hasRecipe, 'location_id' => $locationId, - 'production_quantity' => $productionQuantity, + 'production_quantity' => $qty, 'ingredients' => $ingredients, 'packagings' => $packagings, ]; diff --git a/database/migrations/2026_06_03_102214_add_no_recipe_required_to_products_table.php b/database/migrations/2026_06_03_102214_add_no_recipe_required_to_products_table.php new file mode 100644 index 0000000..268c707 --- /dev/null +++ b/database/migrations/2026_06_03_102214_add_no_recipe_required_to_products_table.php @@ -0,0 +1,22 @@ +boolean('no_recipe_required')->default(false)->after('shelf_life_months'); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('no_recipe_required'); + }); + } +}; diff --git a/database/migrations/2026_06_03_105204_add_set_fields_to_products_table.php b/database/migrations/2026_06_03_105204_add_set_fields_to_products_table.php new file mode 100644 index 0000000..ede8ee3 --- /dev/null +++ b/database/migrations/2026_06_03_105204_add_set_fields_to_products_table.php @@ -0,0 +1,33 @@ +boolean('is_set')->default(false)->after('no_recipe_required'); + $table->unsignedInteger('main_product_id')->nullable()->after('is_set'); + $table->unsignedInteger('main_product_quantity')->nullable()->after('main_product_id'); + + $table->foreign('main_product_id')->references('id')->on('products')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropForeign(['main_product_id']); + $table->dropColumn(['is_set', 'main_product_id', 'main_product_quantity']); + }); + } +}; diff --git a/database/migrations/2026_06_03_105204_create_product_set_items_table.php b/database/migrations/2026_06_03_105204_create_product_set_items_table.php new file mode 100644 index 0000000..9a2e465 --- /dev/null +++ b/database/migrations/2026_06_03_105204_create_product_set_items_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedInteger('set_product_id'); + $table->unsignedInteger('component_product_id'); + $table->unsignedInteger('quantity')->default(1); + $table->unsignedSmallInteger('pos')->default(0); + $table->timestamps(); + + $table->foreign('set_product_id')->references('id')->on('products')->cascadeOnDelete(); + $table->foreign('component_product_id')->references('id')->on('products')->cascadeOnDelete(); + $table->unique(['set_product_id', 'component_product_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_set_items'); + } +}; diff --git a/database/migrations/2026_06_03_111226_add_out_of_stock_fields_to_products_table.php b/database/migrations/2026_06_03_111226_add_out_of_stock_fields_to_products_table.php new file mode 100644 index 0000000..2f3eb0f --- /dev/null +++ b/database/migrations/2026_06_03_111226_add_out_of_stock_fields_to_products_table.php @@ -0,0 +1,23 @@ +date('out_of_stock_until')->nullable()->after('main_product_quantity'); + $table->boolean('out_of_stock_indefinite')->default(false)->after('out_of_stock_until'); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn(['out_of_stock_until', 'out_of_stock_indefinite']); + }); + } +}; diff --git a/database/migrations/2026_06_03_122635_add_stock_thresholds_to_products_table.php b/database/migrations/2026_06_03_122635_add_stock_thresholds_to_products_table.php new file mode 100644 index 0000000..46ec232 --- /dev/null +++ b/database/migrations/2026_06_03_122635_add_stock_thresholds_to_products_table.php @@ -0,0 +1,29 @@ +unsignedInteger('min_product_stock')->nullable()->after('out_of_stock_indefinite'); + $table->unsignedInteger('critical_product_stock')->nullable()->after('min_product_stock'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn(['min_product_stock', 'critical_product_stock']); + }); + } +}; diff --git a/database/migrations/2026_06_03_122635_create_product_stock_movements_table.php b/database/migrations/2026_06_03_122635_create_product_stock_movements_table.php new file mode 100644 index 0000000..ff1baf6 --- /dev/null +++ b/database/migrations/2026_06_03_122635_create_product_stock_movements_table.php @@ -0,0 +1,40 @@ +id(); + $table->unsignedInteger('product_id'); + $table->enum('direction', ['in', 'out']); + $table->unsignedInteger('quantity'); + $table->string('reason', 100)->nullable(); + $table->string('source', 30)->default('manual'); + $table->string('note', 255)->nullable(); + $table->unsignedInteger('user_id')->nullable(); + $table->nullableMorphs('reference'); + $table->timestamps(); + + $table->foreign('product_id')->references('id')->on('products')->cascadeOnDelete(); + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + $table->index(['product_id', 'created_at']); + $table->index('source'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_stock_movements'); + } +}; diff --git a/database/migrations/2026_06_03_124056_create_stock_disposals_table.php b/database/migrations/2026_06_03_124056_create_stock_disposals_table.php new file mode 100644 index 0000000..856a638 --- /dev/null +++ b/database/migrations/2026_06_03_124056_create_stock_disposals_table.php @@ -0,0 +1,47 @@ +id(); + $table->enum('disposal_type', ['ingredient', 'packaging']); + $table->unsignedInteger('ingredient_id')->nullable(); + $table->unsignedBigInteger('packaging_item_id')->nullable(); + $table->unsignedBigInteger('stock_entry_id')->nullable(); + $table->unsignedBigInteger('location_id'); + $table->decimal('quantity', 12, 2); + $table->string('unit', 20)->default('gram'); + $table->string('reason', 100); + $table->string('note', 255)->nullable(); + $table->unsignedInteger('user_id')->nullable(); + $table->date('disposed_at'); + $table->timestamps(); + + $table->foreign('ingredient_id')->references('id')->on('ingredients')->nullOnDelete(); + $table->foreign('packaging_item_id')->references('id')->on('packaging_items')->nullOnDelete(); + $table->foreign('stock_entry_id')->references('id')->on('stock_entries')->nullOnDelete(); + $table->foreign('location_id')->references('id')->on('locations')->cascadeOnDelete(); + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + $table->index(['disposal_type', 'ingredient_id']); + $table->index(['disposal_type', 'packaging_item_id']); + $table->index('disposed_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stock_disposals'); + } +}; diff --git a/dev/product management /entwicklungsplan-aktualisiert-02-06-2026.md b/dev/product management /entwicklungsplan-aktualisiert-02-06-2026.md index 020a4e8..3b17910 100644 --- a/dev/product management /entwicklungsplan-aktualisiert-02-06-2026.md +++ b/dev/product management /entwicklungsplan-aktualisiert-02-06-2026.md @@ -21,6 +21,9 @@ Geprüfte Dateien u. a.: `routes/web.php`, `app/Http/Controllers/Admin/Inventory | Datum | AP | Kurzbeschreibung | Tests | |---|---|---|---| +| 03.06.2026 | **AP-12 (Ausgang / Ausschuss)** | Neue Übersicht **Warenwirtschaft → Ausgang / Ausschuss** (CopyReader, nach Einkauf). Migration `…_create_stock_disposals_table` (`disposal_type`, `ingredient_id`/`packaging_item_id`/`stock_entry_id` nullable FKs, `location_id`, `quantity`, `unit`, `reason` Pflicht, `note`, `user_id`, `disposed_at`). Modell `StockDisposal`. `InventoryService` zieht Ausschuss in `remainingByIngredient()`/`remainingByLocationForIngredient()` ab (wirkt auf Kritisch-Badge) + neue `remainingByPackagingItem()`/`disposed*`-Methoden. `StockDisposalController` (`index`/`create`/`store`/`ingredientCharges`-JSON), FormRequest `StoreStockDisposalRequest` (Grund Pflicht, dt. Zahl/Datum). Erfassungsformular mit Typ-Umschaltung, Select2-Suche, optionaler Charge (setzt Lagerort), Grund-Auswahl, Datepicker. Sidenav-Eintrag + „Ausschuss erfassen"-Button (vorbelegt) auf der Rohstoff-Detailseite. | `tests/Feature/StockDisposalTest.php` (8 grün): Summierung, Restbestand-Abzug Rohstoff + je Lager + Verpackung, HTTP-Buchung, Grund-Pflicht, Admin-Schutz, Render, Chargen-Endpoint. Regression Rohstoff-/Produktbestand grün | +| 03.06.2026 | **AP-11 (Produktbestand + Historie)** | Neues Menü **Warenwirtschaft → Produktbestand** (CopyReader) mit Untermenü „Übersicht"/„Historie" und Kritisch-Badge. Migrationen `…_create_product_stock_movements_table` (`product_id`, `direction` ENUM(`in`,`out`), `quantity`, `reason`, `source`, `note`, `user_id`, `nullableMorphs('reference')`) + `…_add_stock_thresholds_to_products_table` (`min_product_stock`/`critical_product_stock`). Modell `ProductStockMovement` + `Product::stockMovements()`. `app/Services/ProductStockService.php`: `currentStockByProduct()` (=`SUM(in)`−`SUM(out)`), `recordMovement()`, `recordProductionStock()` (idempotenter Differenz-Abgleich, append-only), `productStatus()`, `criticalProductCount()`. `ProductStockController` (`index`/`storeMovement`/`history`): Übersicht (nur Haupt-/Einzelprodukte, Bild, Bestand rot/gelb, Suche + „nur kritische", `+`/`−`-Buchungsmodal nur für Admin, `Produzieren`-Link mit Produktvorwahl), Historie (Filter Produkt/Eingang-Ausgang/Grund/Monat+Jahr). Produktion bucht Produktbestand automatisch (`ProductionService`). Schwellwert-Felder im Produktformular (Warenwirtschaft-Card) + `ProductRepository`. AP-10 „Enthalten in" um Produktbestand-Spalte ergänzt. | `tests/Feature/ProductStockTest.php` (9 grün): Bestand=Eingang−Ausgang, Status, Kritisch-Zähler, Produktionsbuchung + Update-Korrektur, Index/Historie-Render, HTTP-Buchung + Admin-Schutz. Regression Produktion + `RawMaterialStockTest` (26) grün | +| 03.06.2026 | **AP-10 (Rohstoffbestand)** | Neue Übersicht **Warenwirtschaft → Rohstoffbestand** (CopyReader, erster Menüpunkt). Neuer `app/Services/InventoryService.php` als zentrale Bestandslogik: `remainingByIngredient()` (= `SUM(received_quantity)` der eingegangenen Rohstoff-Chargen − `SUM(production_ingredients.quantity_used)`), `remainingByLocationForIngredient()`, `dailyConsumptionByIngredient()` (Ø Gramm/Tag aus Produktionshistorie der letzten 90 Tage), `daysUntilEmpty()`/`expectedEmptyDate()`, `stockStatus()` (critical = Meldebestand unterschritten, warning = Reichweite ≤ Lieferzeit) und `criticalIngredientCount()`. `RawMaterialStockController@index` (Tabelle: Name, Qualität, Bestand, Verbrauch/Tag, Voraussichtlich auf Null + Resttage, Hochrechnungs-Spalte mit Horizont-Dropdown 1/3/6/12 Monate; Suche + „nur kritische anzeigen"-Filter; kritisch=`table-danger`, warning=`table-warning`, Zeile klickbar) und `@show` (Bestell-Detailseite: Kennzahlen, „Enthalten in" = aktive Rezepturen mit g/Stück, Lieferanten mit Lieferzeit/letztem Netto-kg-Preis + Bestellaktion `Zum Shop`/`Per Mail` je `order_method`, verfügbare Chargen mit Restbestand + Bestand je Lagerort). Routen `raw-material-stock.index`/`.show` in der `copyreader`-Gruppe. Sidenav-Eintrag „Rohstoffbestand" inkl. rotem Kritisch-Badge über `View::composer` (AppServiceProvider, nur für CopyReader). **Hinweis:** Produktbestand/Verkauf-pro-Tag je Produkt im „Enthalten in"-Block folgt mit AP-11 (Produktbestand existiert noch nicht). | `tests/Feature/RawMaterialStockTest.php` (8 grün): Restbestand=Eingang−Verbrauch, Verbrauch/Tag-Mittelung, Fenstergrenze, Status kritisch/warnung, Kritisch-Zähler, Index- und Detail-Render. Regression `ProductionManufacturerRecipeTest`/`ProductSetTest` (19) grün | | 02.06.2026 | **AP-01** | URL-Bugfixes B1/B2 umgesetzt: `suppliers/form.blade.php` und `packaging-items/form.blade.php` von `type="url"` auf `type="text"` (placeholder `https://`); `Store/UpdatePackagingItemRequest` URL-Regel `url\|max:500` → `string\|max:2048`; Migration `2026_06_02_145358_widen_url_columns_in_inventory_tables` (suppliers.url + packaging_items.url → varchar(2048)). | `tests/Feature/InventoryUrlFieldsTest.php` (3 grün); Regression Phase 2+3 grün (17) | | 02.06.2026 | **AP-04** | iPad-taugliche Tabellen-Aktionen (B5): neue Partial `resources/views/admin/inventory/partials/table-actions-style.blade.php` (`@once`-Style für `.wawi-table td .btn`, min. 42px Touch-Target, mehr Abstand); Klasse `wawi-table` + Partial-Include in allen 8 Index-Views (locations, material-qualities, packaging-materials, supplier-categories, suppliers, packaging-items, stock-entries, productions). | Render-Regression Phase 2+3+5 grün (21) | | 02.06.2026 | **AP-00** | Regressionsbasis für umgesetzte 5.1-Features als Pest-Tests nachgezogen: INCI-Rohstoffqualität-Relation, Hersteller-Rezeptur getrennt von Produkt-Rezeptur, Produkt-Kopie inkl. beider Rezepturen, „nur aktive Produkte" im Produktions-Formular, Produktion edit/copy rendern. | `tests/Feature/ProductPhase51Test.php` (5 grün) | @@ -31,13 +34,18 @@ Geprüfte Dateien u. a.: `routes/web.php`, `app/Http/Controllers/Admin/Inventory | 02.06.2026 | **AP-06 (Nachtrag: Lieferzeit in Tagen)** | Lieferzeit-Vorlagen erhalten festes Feld `days` (ganze Tage bis Wareneingang, Basis für spätere „rechtzeitig bestellen"-Ableitung). Migration `2026_06_02_160411_add_days_to_delivery_times_table` (`delivery_times.days` unsignedSmallInt nullable) + `2026_06_02_160411_add_delivery_time_days_to_suppliers_table` (`suppliers.delivery_time_days`). `DeliveryTime` (fillable+cast `days`), Factory/Seeder (3/5/14 Tage, Bestandsdaten nachgepflegt). `Store/UpdateDeliveryTimeRequest` + `Store/UpdateSupplierRequest` um `days`/`delivery_time_days` (nullable int) erweitert; `SupplierRepository` + `Supplier` cast. Views: Tage-Feld in `delivery-times/form`, Spalte „Tage" in `general/index`, Tage-Feld im `suppliers/form` + JS-Autofill (`data-days` an Datalist-Optionen setzt Tage bei Vorlagenauswahl, manuell überschreibbar). Migrationen auf DB ausgeführt, Default-Vorlagen mit Tagen befüllt. | `DeliveryTimeSettingsTest` (10 grün): days speichern/optional/Integer-Validierung; `SupplierOrderFieldsTest` (9 grün): `delivery_time_days` speichern, Integer-Validierung, `data-days`-Ausgabe | | 02.06.2026 | **AP-08 (Einkauf erweitern)** | Einkauf um UST-Satz + Netto/Brutto-Automatik + Duplizieren erweitert. Migration `2026_06_02_181548_add_price_fields_to_stock_entries_table` (`price_per_kg_gross` DECIMAL(10,4), `tax_rate_id` FK→`tax_rates` nullOnDelete, `tax_rate_percent` DECIMAL(5,2) als Snapshot). `price_per_kg` bleibt das bestehende **Netto**-Feld (kein Rename → keine Migration der Bestandsdaten/Tests). `StockEntry`: fillable + casts (`price_per_kg_gross`/`tax_rate_percent`) + `taxRate()` belongsTo. `Store/UpdateStockEntryRequest`: Regeln `tax_rate_id` (exists) + `price_per_kg_gross` (numeric), Reformat dt. Zahl, neue Regel „bei Rohstoff genau eines von Netto/Brutto verpflichtend". **Berechnung zentral im `StockEntryRepository::resolvePrices()`:** UST-Prozent als Snapshot, fehlender Netto-/Brutto-Wert wird aus dem Faktor `(1+%/100)` berechnet (Netto↔Brutto), bei Verpackung Preisfelder/UST genullt (Netto-Gesamt bleibt). View `_form`: UST-Dropdown (aktive `tax_rates`, `data-percent`) + Netto-/Brutto-Felder nebeneinander; `_scripts`: JS rechnet live Netto↔Brutto bei Eingabe und UST-Wechsel (dt. Zahlenformat). `show`: Anzeige Netto/Brutto/USt. **Duplizieren:** Route `stock-entries/{stock_entry}/copy` + `StockEntryController@copy` legt direkt eine `pending`-Kopie der Stufe-1-Felder an (Charge/MHD/Eingangsdaten leer, `ordered_at`=heute, `ordered_by`=aktueller User) und leitet zur Bearbeitung; Kopieren-Button in `index` (Aktionsspalte) + `show`-Header. Migration auf DB ausgeführt. | `tests/Feature/StockEntryPriceTest.php` (6 grün): Netto→Brutto, Brutto→Netto, ohne UST Netto=Brutto, Netto/Brutto-Pflicht, Duplizieren erzeugt pending-Kopie ohne Chargendaten, Copy-Zugriffsschutz. Regression `InventoryPhase3Test` (8 grün) | | 02.06.2026 | **AP-07.1 (Lieferanten-Detailansicht/Modal)** | Zwischenschritt (Kunde): Lieferanten-Zuordnungen auch von der Lieferantenseite aus sichtbar/pflegbar. `Supplier::ingredients()` belongsToMany (Gegenstück zu `Ingredient::suppliers()`). Resource `suppliers` `show` reaktiviert + neue Routen `suppliers.ingredients.attach/detach` und `suppliers.packaging-items.attach/detach` (admin-Gruppe). `SupplierController`: `show()` + `attach/detachIngredient()` + `attach/detachPackagingItem()` rendern gemeinsames Partial `suppliers/_details.blade.php` (Stammdaten + zwei kleine Listen „Zugeordnete INCIs" / „Zugeordnete Verpackungsartikel" mit Entfernen-Button und Hinzufügen-Auswahl der noch nicht zugeordneten Einträge). Index: Augen-Button (Spalte 1) öffnet Bootstrap-Modal, lädt Details per AJAX; Hinzufügen/Entfernen via delegiertem jQuery-AJAX (X-CSRF-TOKEN-Header) und ersetzt den Modal-Body mit dem neu gerenderten Partial. Verpackungsartikel-Zuordnung = `packaging_items.supplier_id` setzen/leeren. | `tests/Feature/SupplierDetailsTest.php` (7 grün): show zeigt zugeordnete INCIs/Verpackung, INCI attach/detach, Verpackung attach/detach, Validierung, Zugriffsschutz Nicht-Admin | +| 03.06.2026 | **AP-03 („Nicht vorrätig" mit Zeitangabe)** | Produkt zeitlich begrenzt oder unbefristet als nicht vorrätig markierbar (vorerst **nur Hinweis**, Kauf bleibt möglich). Migration `2026_06_03_111226_add_out_of_stock_fields_to_products_table` (`products.out_of_stock_until` DATE nullable, `out_of_stock_indefinite` bool default 0). `Product`: fillable + casts (`date`/`bool`), Helper `isOutOfStock()`, `outOfStockRemainingDays()` (Differenz tagesgenau, ≥0), `outOfStockNotice()` (Singular/Plural „In ca. X Tag(en) wieder da!" bzw. „Zur Zeit nicht vorrätig"). **Produktformular:** neue Card „Verfügbarkeit" (Section-Nav-Eintrag nach „Details") mit Checkbox „Vorübergehend nicht vorrätig (mit Zeitangabe)" + Tagefeld und zweiter Checkbox „Auf unbestimmte Zeit nicht vorrätig"; JS (`toggleOutOfStock` in `edit.blade.php`) blendet das Tagefeld nur bei aktiver Zeitangabe ein und deaktiviert sie bei „unbestimmt". **Repository:** `update()` normalisiert die Felder — „unbestimmt" hat Vorrang (Datum=null), sonst `out_of_stock_until = now()->addDays($tage)`, ohne Aktivierung beides geleert. **Shop:** Hinweis im Produktraster (`web/shop/_shop_products_inner`) und in der Detailansicht (`web/shop/show_product`). Hinweise-Doku (AP-18) aktualisiert. Migration auf DB ausgeführt. | `tests/Feature/ProductOutOfStockTest.php` (6 grün): Tage→Datum, Unbestimmt-Vorrang+Datum-Nullung, Deaktivierung leert Felder, Vergangenheit gilt nicht, Hinweis-Resttage, HTTP-Store. Regression `ProductSetTest`/`ProductPhase51Test` grün | +| 03.06.2026 | **AP-02 (Produkt-Klassen: Einzelprodukt vs. Set + Hauptprodukt)** | Echte Sets via Pivot. Migrationen `2026_06_03_105204_add_set_fields_to_products_table` (`products.is_set` bool, `main_product_id` FK→products nullOnDelete, `main_product_quantity` uint) + `…_create_product_set_items_table` (`set_product_id`/`component_product_id` FK cascade, `quantity`, `pos`, unique-Paar). `Product`: fillable + casts (`is_set` bool, `main_product_id`/`main_product_quantity` int), Relationen `setItems()`/`partOfSets()` (belongsToMany self), `mainProduct()`/`variants()`, Scopes `singleProducts()`/`sets()`/`mainProducts()`. **Produktformular:** neue Card „Set / Produktart" mit Checkbox „Ist Set" + Set-Bestandteile-Tabelle (Modal-Auswahl nur aktiver Einzelprodukte, Menge, Drag&Drop) analog Verpackung; Hauptprodukt-Zuordnung (`main_product_id` Dropdown + `main_product_quantity`) in der Warenwirtschaft-Card; Section-Nav-Eintrag „Set". **JS (`edit.blade.php`):** `toggleSetMode` blendet bei aktivem Set die Cards Rezeptur/Hersteller-Rezeptur/Verpackung/Warenwirtschaft (+ `.js-nav-recipe`-Sprungmarken) aus und die Set-Felder ein; Set-Item-Modal/Sortable. **Repository:** `update()` persistiert `is_set`/`main_product_*` (Set ⇒ `main_product_id`=null), bei Set werden Rezeptur (beide Typen) + Verpackung geleert und Set-Items gesynct, sonst Set-Items detached; `copy()` übernimmt Set-Bestandteile. **Validierung (`ProductController::validateSetItems`):** Set braucht ≥1 Bestandteil, Bestandteile müssen existieren, dürfen selbst keine Sets sein, nicht das Produkt selbst. **Produktion:** Sets aus den Produkt-Dropdowns (create/edit/copy) ausgeschlossen; neuer `ProductionService::assertNotASet()` blockiert das Produzieren von Sets. Migrationen auf DB ausgeführt. | `tests/Feature/ProductSetTest.php` (10 grün): Set+Mengen speichern, Set leert Rezeptur/Verpackung, Hauptprodukt-Zuordnung/Set-Nullung, Scopes, Set nicht produzierbar, Dropdown ohne Sets, Validierung (leer/Set-Bestandteil), HTTP-Store gültiges Set, Kopie inkl. Bestandteile. Regression `ProductPhase51Test`/`ProductionPhase5Test`/`ProductionManufacturerRecipeTest` grün | +| 03.06.2026 | **AP-18 (Hinweise-/Doku-Seite)** | MD-basierte Hinweise-Seite unter **Warenwirtschaft → Einstellungen → „Hinweise"** (SuperAdmin). Pflege-Quelle `resources/docs/hinweise.md` (Entwicklungsstand, Nutzungshinweise, festgehaltene Entscheidungen §5.2/§5.3/§5.4, offene Briefings). `NoticeController@index` liest die MD-Datei und rendert sie via `Str::markdown()` (`league/commonmark` vorhanden) zu HTML; View `admin/inventory/notices/index.blade.php` (Card + dezentes `.wawi-notices`-Styling über `@section('styles')`). Route `admin.inventory.notices` in der `superadmin`-Gruppe; Sidenav-Eintrag „Hinweise" als letzter Punkt unter „Einstellungen" inkl. `open`/`active`-Logik. Früh als laufend gepflegter Platzhalter angelegt – mit jedem weiteren AP fortzuschreiben. | `tests/Feature/InventoryNoticesTest.php` (2 grün): Render (MD→HTML, `

`/„Entwicklungsstand" sichtbar), Zugriffsschutz Nicht-SuperAdmin | +| 03.06.2026 | **AP-09.1 (Eigenprodukte ohne Rezeptur)** | Kunden-Feedback beim Testen: Eigenprodukte (Broschüren, Etiketten etc.) haben keine Rezeptur. Neue Spalte `products.no_recipe_required` (bool, Migration `2026_06_03_102214_add_no_recipe_required_to_products_table`), `Product` fillable + cast `bool`, `ProductRepository::update()` normalisiert die Checkbox (`isset ? 1 : 0`). **Produktformular:** Checkbox „Dieses Produkt benötigt keine Rezeptur (Eigenprodukt …)" oben in der Card „Inhaltsstoffe/Rezeptur"; bei aktiver Option blendet JS (`toggleRecipeFields` in `edit.blade.php`) die Rezeptur-Felder **beider** Cards (Produkt- + Hersteller-Rezeptur, Wrapper `.js-recipe-fields`) aus. **Produktion:** `ProductionService::store/updateProduction` überspringt bei `no_recipe_required` die Hersteller-Rezeptur-Prüfung und Chargen (keine `production_ingredients`); `buildRecipePayload` liefert `recipe_required=false`; `StoreProductionRequest` macht `ingredient_lines` dann optional; Produktions-JS zeigt Hinweis „benötigt keine Rezeptur" statt Warnung. | `ProductionManufacturerRecipeTest` erweitert (9 grün): Produktion ohne Chargen, `recipe_required=false`, HTTP-Store eines Eigenprodukts ohne Chargen | +| 03.06.2026 | **AP-09 (Produktion korrigieren)** | Produktion vollständig auf **Hersteller-Rezeptur** umgestellt (kein Fallback). `ProductionService` (`store`/`updateProduction`/`requiredGramsByIngredient`/`buildRecipePayload`) lädt jetzt `manufacturer_ingredients`; neue `assertManufacturerRecipe()` wirft deutliche Warnung, wenn keine Hersteller-Rezeptur gepflegt ist; `buildRecipePayload` liefert Flag `has_recipe`. **Restbestand-Logik:** neue `consumedByStockEntry()` + `availableStockEntriesForIngredient()` (FEFO nach MHD, nur Chargen mit Restbestand > 0; `remaining_quantity` als verbraucht-abzüglich-Berechnung, beim Bearbeiten via `exclude_production` ohne die eigene Produktion). **Chargen-Label** `stockEntryLabel()` = `Lieferant - Chargennr. - dd.mm.yyyy` (kein „MHD"-Text). `recipeJson` nimmt `exclude_production` entgegen. **Views refactored:** gemeinsame Partials `productions/_form_fields.blade.php` + `productions/_scripts.blade.php` (create/edit/copy nutzen beide). JS: **B3-Fix** „Weitere Charge" fügt genau **eine** Zeile hinzu; **stabile Soll-Neuberechnung** (Stückzahländerung berechnet nur Soll neu via `data-recipe-ing`, ohne bereits eingetragene Chargen/Mengen zu überschreiben — Refetch nur bei Produkt-/Lagerortwechsel); Hersteller-Rezeptur-Warnung blockiert Submit. **UI vereinfacht:** Charge+Menge je Zeile als `input-group` mit `g`-Suffix, keine Pro-Zeile-Spaltenüberschriften. **B4 iPad-Fix:** Kopfdaten-Grid auf `col-12 col-sm-6`/`col-md-6` (keine Überlappung). **Produktentwicklung-Platzhalter:** `ProductDevelopmentController`, Route `admin.inventory.product-development`, View mit „Briefing ausstehend"-Hinweis; Sidenav „Produktion" zu Untermenü („Produktionen" + „Produktentwicklung") umgebaut. **Datumsfelder-Konvention (Kunde):** alle Datumsfelder im Modul von nativem `type="date"` auf `` (Format `dd.mm.yyyy`, global initialisiert in `public/js/custom.js`) umgestellt — Produktion `produced_at`, Einkauf `ordered_at`, Wareneingang `received_at`/`best_before`. Backend bleibt format-agnostisch (`Carbon::parse`/`date`-Regel verarbeiten `d.m.Y`). | `tests/Feature/ProductionManufacturerRecipeTest.php` (6 grün): Soll aus Hersteller-Rezeptur, Block ohne Hersteller-Rezeptur trotz Produkt-Rezeptur, `has_recipe=false`, Charge ohne Restbestand fehlt, Label-Format ohne MHD, Produktentwicklung-Render. Regression `ProductionPhase5Test` (4) + `ProductPhase51Test` (5) auf Hersteller-Rezeptur angepasst, grün | | 02.06.2026 | **AP-07 (INCI erweitern)** | INCI/Rohstoffe um Lieferanten-Mehrfachwahl, UST-Satz und eigene Lieferzeit (inkl. Tage-Autofill) erweitert. Migration `2026_06_02_161237_add_order_fields_to_ingredients_table` (`ingredients.tax_rate_id` FK→`tax_rates` nullOnDelete, `delivery_time` VARCHAR, `delivery_time_days` unsignedSmallInt) + `2026_06_02_161237_create_ingredient_supplier_table` (Pivot `ingredient_id` (unsignedInt, passend zu altem `increments`) / `supplier_id`, `preferred` bool, `supplier_sku`, `url`(2048), unique-Paar, cascadeOnDelete). `Ingredient`: fillable + cast `delivery_time_days`, Relationen `taxRate()` belongsTo + `suppliers()` belongsToMany (Pivot `preferred`/`supplier_sku`/`url`). **O1 erledigt:** `IngredientController::store()` von `Request::all()` auf neuen `App\Http\Requests\StoreIngredientRequest` umgestellt (validiert + normalisiert deutsche Dezimalzahlen `default_factor`/`min_stock_alert`, leere FKs→null). `edit()` lädt aktive `taxRates`, aktive `deliveryTimes`, aktive `suppliers` + eager-load `suppliers`; nach Speichern `suppliers()->sync()`. Single-Endpoint-Schema (`admin_product_ingredient_store` für Neu+Update) beibehalten → ein FormRequest genügt. View `admin/ingredient/form.blade.php`: UST-Dropdown (aktive `tax_rates`), Select2-Mehrfachwahl Lieferanten, Lieferzeit-Textfeld mit `` (`data-days`) + Tage-Feld; `edit.blade.php` `@section('scripts')` mit Select2-Init + Tage-Autofill (manuell überschreibbar). Lieferzeit-Logik: INCI-Lieferzeit hat Vorrang vor Lieferanten-Lieferzeit (Auswertung erst in AP-10). Migrationen auf DB ausgeführt. | `tests/Feature/IngredientOrderFieldsTest.php` (6 grün): Formular zeigt Lieferanten/UST/aktive Vorlagen+`data-days`, Speichern mit UST/Lieferzeit/Tagen/Lieferanten, Lieferanten-Sync bei Update, Validierung Tage-Integer/UST-Existenz/Name-Pflicht. Regression `SupplierOrderFieldsTest` (8) + `ProductPhase51Test` (5) grün | -**Status Roadmap:** AP-00, AP-01, AP-04, AP-05, AP-06 (inkl. Nachtrag) erledigt; **AP-07 erledigt** (INCI: Lieferanten-Mehrfachwahl, UST-Satz, eigene Lieferzeit inkl. Tage-Autofill, `ingredient_supplier`-Pivot; O1 `IngredientController` auf FormRequest umgestellt) inkl. **AP-07.1** (Lieferanten-Detailansicht im Modal mit pflegbaren INCI-/Verpackungs-Listen); **AP-08 erledigt** (Einkauf: UST-Snapshot, Netto/Brutto-Automatik, Duplizieren). +**Status Roadmap:** AP-00, AP-01, AP-04, AP-05, AP-06 (inkl. Nachtrag) erledigt; **AP-07 erledigt** (INCI: Lieferanten-Mehrfachwahl, UST-Satz, eigene Lieferzeit inkl. Tage-Autofill, `ingredient_supplier`-Pivot; O1 `IngredientController` auf FormRequest umgestellt) inkl. **AP-07.1** (Lieferanten-Detailansicht im Modal mit pflegbaren INCI-/Verpackungs-Listen); **AP-08 erledigt** (Einkauf: UST-Snapshot, Netto/Brutto-Automatik, Duplizieren); **AP-09 erledigt** (Produktion: ausschließlich Hersteller-Rezeptur + Warnung, Restbestandsfilter + Chargen-Label, B3-Fix, stabile Soll-Neuberechnung, B4 iPad, Produktentwicklung-Platzhalter) inkl. **AP-09.1** (Eigenprodukte ohne Rezeptur); **AP-18 erledigt** (MD-basierte Hinweise-Seite unter Einstellungen, laufend zu pflegen); **AP-02 erledigt** (Produkt-Klassen: echte Sets via Pivot `product_set_items`, Hauptprodukt-Zuordnung, Set-UI im Produktformular, Sets nicht produzierbar); **AP-03 erledigt** („Nicht vorrätig" mit Zeitangabe/unbefristet, nur Hinweis im Shop, Resttage-Countdown, Kauf bleibt möglich); **AP-10 erledigt** (Rohstoffbestand: `InventoryService` mit Restbestand/Verbrauch-pro-Tag/Reichweite/Status, Übersicht + Bestell-Detailseite, Kritisch-Badge in der Navigation); **AP-11 erledigt** (Produktbestand: `ProductStockService` mit Bestand=Eingang−Ausgang, Schwellwert-Status, manuelle `+`/`−`-Bewegungen, automatische Produktionsbuchung, Übersicht + revisionssichere Historie, Kritisch-Badge); **AP-12 erledigt** (Ausgang/Ausschuss: `stock_disposals`, Erfassung mit Pflicht-Grund + optionaler Charge, reduziert Rohstoff-/Verpackungsbestand inkl. Kritisch-Badge). > **Alle Klärungspunkte aus §5 sind beantwortet** (Kunde, 02.06.2026) und in die jeweiligen APs eingearbeitet — keine Blocker mehr offen. > -> **➡️ NÄCHSTER SCHRITT: AP-09 (Produktion korrigieren).** Konkret: (1) Produktion **ausschließlich** auf Hersteller-Rezeptur umstellen + **Warnung**, wenn keine gepflegt ist (kein Fallback); (2) Chargen-Dropdown-Label + nur Chargen mit Restbestand; (3) B3 „Weitere Charge"-JS-Fix (genau eine Zeile); (4) Soll-Neuberechnung ohne Überschreiben manueller Eingaben; (5) B4 iPad-Layout der Kopfdaten; (6) Produktentwicklung-Platzhalterseite („Briefing ausstehend"). Danach AP-02/AP-03, dann die großen Übersichten. **Neu:** AP-18 (Hinweise-Doku unter Einstellungen) kann jederzeit dazwischengezogen werden. +> **➡️ NÄCHSTER SCHRITT: AP-18 (Hinweise-Doku) und/oder AP-02 (Produkt-Klassen: Einzelprodukt vs. Set).** AP-18 kann jederzeit als Platzhalter angelegt und laufend gepflegt werden; danach Datenmodell AP-02 (Sets via Pivot) / AP-03 („Nicht vorrätig", nur Hinweis), dann die großen Übersichten AP-10/AP-11. --- @@ -269,6 +277,8 @@ Diese Punkte sind klein, konkret und blockieren teils die tägliche Nutzung. Sie **Tests:** Soll-Verbrauch aus Hersteller-Rezeptur; **Warnung bei fehlender Hersteller-Rezeptur**; Charge ohne Restbestand erscheint nicht; Service-Berechnung bei Stückzahländerung. +> **Status:** Erledigt (03.06.2026). `ProductionService` auf `manufacturer_ingredients` umgestellt (kein Fallback), `assertManufacturerRecipe()` + `has_recipe`-Flag in `buildRecipePayload`. Restbestand über `consumedByStockEntry()`/`availableStockEntriesForIngredient()` (FEFO, nur Rest > 0, beim Bearbeiten via `exclude_production`). Chargen-Label `Lieferant - Charge - dd.mm.yyyy` (`stockEntryLabel()`). Views in gemeinsame Partials `_form_fields`/`_scripts` refactored; JS-B3-Fix (genau eine Zeile), stabile Soll-Neuberechnung (kein Refetch/Überschreiben bei Stückzahländerung), Hersteller-Rezeptur-Warnung blockiert Submit, UI vereinfacht (`g`-Suffix, keine Pro-Zeile-Headers), B4 iPad-Grid `col-sm-6`. Produktentwicklung-Platzhalter (`ProductDevelopmentController`, Route `admin.inventory.product-development`, Sidenav-Untermenü). Tests: `tests/Feature/ProductionManufacturerRecipeTest.php` (6 grün) + angepasste Regression `ProductionPhase5Test`/`ProductPhase51Test`. + --- ### AP-02 — Produkt-Klassen: Einzelprodukt vs. Set + Hauptprodukt @@ -287,6 +297,8 @@ Diese Punkte sind klein, konkret und blockieren teils die tägliche Nutzung. Sie **Akzeptanz:** Sets bestehen aus Einzelprodukten mit Menge; Sets sind nicht produzierbar; Produktbestand (AP-11) zeigt nur Haupt-/Einzelprodukte; Set-Verkauf reduziert später die enthaltenen Einzelprodukte (AP-13). +> **Status:** Erledigt (03.06.2026). Migrationen `2026_06_03_105204_add_set_fields_to_products_table` + `…_create_product_set_items_table`. `Product`: `setItems()`/`partOfSets()`/`mainProduct()`/`variants()`, Scopes `singleProducts()`/`sets()`/`mainProducts()`. Produktformular: Card „Set / Produktart" (Checkbox + Bestandteile-Modal nur für aktive Einzelprodukte, Menge, Drag&Drop), Hauptprodukt-Felder in der Warenwirtschaft-Card, JS `toggleSetMode` blendet Rezeptur/Verpackung/Warenwirtschaft bei Set aus. `ProductRepository`: bei Set werden Rezeptur/Verpackung geleert, `main_product_id` genullt, Set-Items gesynct; `copy()` übernimmt Bestandteile. Validierung in `ProductController::validateSetItems` (≥1 Einzelprodukt, kein Set als Bestandteil, nicht sich selbst). Produktion: Sets aus Dropdowns ausgeschlossen + `ProductionService::assertNotASet()`. Tests: `tests/Feature/ProductSetTest.php` (10 grün). **Hinweis:** Set-Bestandsabzug beim Verkauf folgt in AP-13, Produktbestand-Filter (nur Haupt-/Einzelprodukte) in AP-11. + --- ### AP-03 — „Nicht vorrätig" mit Zeitangabe @@ -302,6 +314,8 @@ Diese Punkte sind klein, konkret und blockieren teils die tägliche Nutzung. Sie **Akzeptanz:** Produkt zeitweise/unbefristet als nicht vorrätig markierbar; Resttage zählen automatisch herunter; nach Ablauf verschwindet der Hinweis ohne manuelles Zutun. +> **Status:** Erledigt (03.06.2026). Migration `2026_06_03_111226_add_out_of_stock_fields_to_products_table` (`out_of_stock_until` DATE nullable, `out_of_stock_indefinite` bool). `Product`-Helper `isOutOfStock()`/`outOfStockRemainingDays()`/`outOfStockNotice()`. Produktformular-Card „Verfügbarkeit" (Checkbox + Tagefeld + Checkbox „unbestimmt", JS-Toggle); `ProductRepository::update()` rechnet `out_of_stock_until = now()->addDays($tage)` bzw. nullt bei „unbestimmt"/Deaktivierung (Backend tagesbasiert → Resttage zählen automatisch herunter, Hinweis verschwindet nach Ablauf). Shop-Hinweis im Produktraster und in der Detailansicht; Kauf bleibt möglich (Kauf-Sperre als spätere Option in AP-18 dokumentiert). **Interne Bestellliste** (`OrderController::datatable` Produkt-Spalte + `admin/modal/show_product`): roter Hinweis ersetzt die Mengen-Buttons (dort vorübergehend nicht bestellbar), Detail-Modal zeigt zusätzlich einen Hinweis. Tests: `tests/Feature/ProductOutOfStockTest.php` (6 grün). + --- ### AP-10 — Rohstoffbestand (InventoryService + Übersicht) @@ -312,6 +326,8 @@ Diese Punkte sind klein, konkret und blockieren teils die tägliche Nutzung. Sie **Akzeptanz:** Reale Restbestände sichtbar; Bestellweg direkt aus der Übersicht erreichbar; kritische Rohstoffe hervorgehoben. +> **Status:** Erledigt (03.06.2026). `app/Services/InventoryService.php` zentralisiert die Bestandslogik (Restbestand = eingegangene Mengen − Produktionsverbrauch, Verbrauch/Tag aus 90-Tage-Produktionshistorie, Reichweite/Status, Kritisch-Zähler). `RawMaterialStockController` (`raw-material-stock.index`/`.show`, `copyreader`-Gruppe): Übersicht (Name, Qualität, Bestand, Verbrauch/Tag, „auf Null"-Datum, Hochrechnungs-Dropdown 1/3/6/12 Monate, Suche + „nur kritische"-Filter, rot/gelbe Markierung, Zeile klickbar) und Bestell-Detailseite (Kennzahlen, „Enthalten in", Lieferanten + Bestellaktion `Zum Shop`/`Per Mail`, Chargen mit Restbestand + Bestand je Lagerort). Sidenav-Eintrag „Rohstoffbestand" mit Kritisch-Badge (`View::composer`). **Offene Bestellungen** (`status=pending`) werden über `InventoryService::openOrderQuantityByIngredient()` als „Offen bestellt"-Spalte (Übersicht) bzw. eigener Block (Detail) sichtbar gemacht, zählen aber nicht zum Bestand; ein kritischer Rohstoff mit offener Bestellung wird zu Status `critical_ordered` entschärft (nicht im Badge gezählt). Über „Einkauf erfassen" auf der Detailseite wird der Einkauf mit Art=Rohstoff + vorausgewähltem Inhaltsstoff geöffnet (`StockEntryController@create` liest `ingredient_id`). Pro-Lagerort-Spalten der Übersicht bewusst weggelassen (Mockup zeigt Gesamtbestand; Lagerort-Aufschlüsselung steht auf der Detailseite). **Offen für AP-11:** Produktbestand/Verkauf-pro-Tag je Produkt im „Enthalten in"-Block (Produktbestand existiert noch nicht); Bedarfsableitung „rechtzeitig bestellen" über Lieferzeit-Tage kann später verfeinert werden. Tests: `tests/Feature/RawMaterialStockTest.php` (8 grün). + --- ### AP-11 — Produktbestand + Historie @@ -326,18 +342,100 @@ Diese Punkte sind klein, konkret und blockieren teils die tägliche Nutzung. Sie **Akzeptanz:** Bestand schnell pflegbar; jede Bewegung in der Historie; nur Hauptprodukte sichtbar; Kritisch-Filter funktioniert. +> **Status:** Erledigt (03.06.2026). Migrationen `2026_06_03_122635_create_product_stock_movements_table` (`product_id` FK→`products` cascade, `direction` ENUM(`in`,`out`), `quantity`, `reason`, `source` default `manual`, `note`, `user_id` FK→`users` nullOnDelete, `nullableMorphs('reference')`) + `…_add_stock_thresholds_to_products_table` (`min_product_stock`, `critical_product_stock`). Modell `ProductStockMovement` (+ `Product::stockMovements()`, Schwellwerte in fillable/casts). `app/Services/ProductStockService.php` zentralisiert die Logik: `currentStockByProduct()`/`currentStock()` (= `SUM(in)`−`SUM(out)`), `recordMovement()` (Menge immer positiv, Richtung steuert Vorzeichen), `recordProductionStock()` (idempotenter Soll-/Ist-Abgleich je Produktion → bucht nur die Differenz, append-only), `productStatus()` (critical ≤ kritischer Schwellwert, warning ≤ Meldebestand), `criticalProductCount()` (nur aktive Hauptprodukte mit gesetztem kritischen Schwellwert). `ProductStockController` (`copyreader`-Gruppe): `index` (nur Haupt-/Einzelprodukte, Bild, Bestand rot/gelb, Suche + „nur kritische"-Filter, `+`/`−`-Modal für Bewegung, `Produzieren`-Link mit Produktvorwahl), `storeMovement` (FormRequest `StoreProductStockMovementRequest`, nur `isAdmin`), `history` (Filter Produkt/Eingang-Ausgang/Grund/Monat+Jahr, revisionssicher). Produktion bucht Produktbestand automatisch (`ProductionService` → `recordProductionStock` bei `store`/`update`). Produktformular: Schwellwert-Felder in der Warenwirtschaft-Card (+ `ProductRepository`). Sidenav „Produktbestand" mit Untermenü „Übersicht"/„Historie" und Kritisch-Badge (`View::composer` erweitert um `criticalProductCount`). AP-10 „Enthalten in"-Block um Produktbestand-Spalte ergänzt. Tests: `tests/Feature/ProductStockTest.php` (9 grün), Produktions-Regression unverändert grün. **Hinweis:** Verkauf/Tag je Produkt sowie Bestandsabzug beim (Set-)Verkauf folgen mit AP-13. + --- ### AP-12 — Ausgang / Ausschuss (Rohstoffe/Verpackung) - `stock_disposals` (Typ, Artikel, Charge optional, Lagerort, Menge, Einheit, Grund Pflicht, User, Datum) + Controller/Views; Integration in `InventoryService`. - **Akzeptanz:** Ausgang reduziert Rohstoff-/Verpackungsbestand; Grund ist Pflicht. +> **Status:** Erledigt (03.06.2026). Migration `2026_06_03_124056_create_stock_disposals_table` (`disposal_type` ENUM(`ingredient`,`packaging`), `ingredient_id`/`packaging_item_id`/`stock_entry_id` nullable FKs, `location_id`, `quantity` decimal, `unit`, `reason` Pflicht, `note`, `user_id`, `disposed_at`). Modell `StockDisposal`. `InventoryService` erweitert: `disposedByIngredient()` + Abzug in `remainingByIngredient()` und `remainingByLocationForIngredient()` (somit auch im Kritisch-Zähler/Badge), `remainingByPackagingItem()` (Wareneingang − Produktionsverbrauch − Ausschuss) + `disposedByPackagingItem()`. `StockDisposalController` (`copyreader`-Gruppe; `create`/`store` nur `isAdmin`): `index` (Liste mit Art-Filter), `create`/`store` (FormRequest `StoreStockDisposalRequest`, deutsche Dezimal-/Datumsnormalisierung, Pflicht-Grund), `ingredientCharges` (JSON-Endpoint: eingegangene Chargen je Rohstoff + Lagerort für die Charge-Vorauswahl). Erfassungsformular mit Typ-Umschaltung (Rohstoff/Verpackung), Select2-Suche, optionaler Charge (setzt Lagerort automatisch), Grund-Auswahl, Datepicker. Sidenav-Eintrag „Ausgang / Ausschuss" (nach „Einkauf & Wareneingang"); „Ausschuss erfassen"-Button auf der Rohstoff-Detailseite (vorbelegt). Tests: `tests/Feature/StockDisposalTest.php` (8 grün), Regression Rohstoff-/Produktbestand grün. + --- -### AP-13 — Shop-Anbindung: Bestand bei Verkauf reduzieren -- **Entscheidung (§5.2, geklärt):** Bestandsabzug erfolgt **beim Versand** (erst wenn der Versand gebucht ist, wurde das Produkt real „aus dem Regal" genommen). Dieser Hinweis ist auch in der Hinweise-Doku (AP-18) zu hinterlegen. -- Beim Statuswechsel auf **versendet** `product_stock_movements`-`out`-Buchung; bei Sets die enthaltenen Einzelprodukte (× Menge) reduzieren. Stornos/Retouren als Gegenbuchung (Detailregel bei Umsetzung festzurren). -- **Akzeptanz:** Versand reduziert Produktbestand; Set-Versand reduziert Einzelprodukte; jede Buchung in der Historie. +### AP-13 — Shop-Anbindung: Bestand bei Verkauf/Versand reduzieren (Entwicklungskonzept) + +> **Stand:** Konzept (03.06.2026), noch nicht umgesetzt. Skizziert Trigger, Datenmodell, Set-Auflösung, Storno und offene Klärungspunkte, damit die Umsetzung ohne Rückfragen starten kann. + +#### 0. Ausgangslage (verifizierter Code-Stand) +Wichtigste Erkenntnis aus der Code-Prüfung: **Die Verkaufsdaten liegen bereits im System** — AP-13 braucht primär einen sauberen Trigger an den vorhandenen Statuswechsel, keine zwingend neue WooCommerce-Schnittstelle für den Abzug selbst. + +- **Bestellungen vorhanden:** `shopping_orders` + `shopping_order_items` (`product_id`, `qty`, `price`). Jede Position zeigt per `product()` auf das Produkt. +- **Produkt-Mapping zu WooCommerce besteht:** über `products.wp_number` (siehe `app/Http/Controllers/Api/ShoppingUserController.php::prepareOrder()` → `Product::whereWpNumber($order->article)`). Es ist **kein** neues Mapping nötig. +- **Versandstatus steckt in `shopping_orders.shipped`** (0 = offen, 1 = in Bearbeitung, **2 = versendet**, 3 = abgeschlossen, 4 = Abholung, 5 = Wartestellung, 10 = storniert) + `shipped_at` (datetime). +- **Zentraler Statuswechsel:** `app/Http/Controllers/SalesController.php@store`, Action `store_shipped` (~Z. 421–454). Dort wird `shipped` gesetzt und – bereits **idempotent** über `if (! $shopping_order->shipped_at)` – einmalig `shipped_at` beim Übergang auf „sent"/„close" gesetzt. **Idealer Aufhängepunkt.** +- **WooCommerce-Anbindung (Ist):** WP ruft per Passport-API (`/api/wp/*`, `ShoppingUserController`) `store`/`order`/`update`/`status`/`cancel`/`open`. Status „versendet" wird derzeit **nicht** von WP gesetzt, sondern intern im Backend; WP **liest** den Status (`ShoppingOrder::getAPIShippedType()`). +- **Produktbestand-Infrastruktur steht (AP-11):** `product_stock_movements` mit `direction`, `source`, `reason`, `nullableMorphs('reference')` und `ProductStockService::recordMovement()`. → Die „out"-Buchung setzt **ohne neue Tabellen** darauf auf. + +#### 1. Ablauf-Skizze +``` +WooCommerce ──(push /api/wp/order)──▶ shopping_orders / shopping_order_items (Mapping via products.wp_number) + │ +Backend: Versand buchen (SalesController@store / store_shipped) ──▶ shipped = 2 + shipped_at + │ (Trigger) + ▼ + SaleStockService::bookShipment(ShoppingOrder) + ├─ je Position: Set? ──ja──▶ Komponenten (× Set-Menge × qty) je „out" + │ └─nein─▶ Produkt selbst (× qty) „out" + ├─ source = 'sale', reference = ShoppingOrder, reason = "Versand #" + └─ idempotent (shopping_orders.stock_booked_at + Referenzprüfung) + │ +Storno/Rücknahme (shipped = 10 / zurück auf offen) ──▶ reverseShipment() = Gegenbuchung „in" (source = 'sale_reversal') + ▼ + AP-11 Historie (Quelle „Verkauf"/„Storno") +``` + +#### 2. Trigger-Zeitpunkt — Entscheidung (§5.2, geklärt) +Bestandsabzug **beim Versand** (erst mit gebuchtem Versand ist das Produkt real „aus dem Regal"). Buchung beim Übergang auf `shipped ∈ {2 versendet, 3 abgeschlossen, 4 Abholung}`, am vorhandenen `shipped_at`-Guard in `SalesController@store`. Dieser Hinweis ist auch in der Hinweise-Doku (AP-18) hinterlegt. + +#### 3. Datenmodell — keine neue Tabelle +- **`product_stock_movements` wiederverwenden:** `direction='out'`, neuer Quell-Wert `source='sale'`, `reference` = `ShoppingOrder` (polymorph), `reason` z. B. „Versand #", `user_id` = ausführender Admin (falls vorhanden). +- **Idempotenz zweistufig:** (a) schneller Marker `shopping_orders.stock_booked_at` (nullable datetime, neue Mini-Migration) + (b) Sicherheitsnetz: vor Buchung prüfen, ob für diese `ShoppingOrder`-Referenz mit `source='sale'` bereits eine Bewegung existiert. + +#### 4. Set-Auflösung (Abhängigkeit AP-02 ✅) +Pro `shopping_order_item` Produkt laden: +- **Set** (`is_set`): über `setItems()` jede Komponente einzeln buchen, Menge = `pivot.quantity × item.qty`. +- **Einzel-/Hauptprodukt:** Produkt selbst buchen, Menge = `item.qty`. +- **Variante/Hauptprodukt (`main_product_id`/`main_product_quantity`):** Default — Variante bucht auf **sich selbst** (eigener Produktbestand). Abweichende Behandlung (Abzug vom Hauptprodukt × `main_product_quantity`) ist eine **offene Detailfrage** (siehe §9). + +#### 5. Storno / Retoure / Rücknahme (revisionssicher) +- Wechsel auf `shipped=10` (storniert) **oder** Zurücksetzen auf „offen/in Bearbeitung" **nach** erfolgter Abzugsbuchung → Gegenbuchung `direction='in'`, `source='sale_reversal'`, gleiche `reference`, `reason='Storno #…'`; `stock_booked_at` wieder auf `null`. +- **Kein Löschen**, nur Gegenbuchung (konsistent zur AP-11-Historie). Anknüpfpunkte: `ShoppingUserController::cancel()`/`open()` (WP-Seite) und `SalesController@store` (Backend). + +#### 6. Zentrale Logik in einem Service +- Neuer schlanker `app/Services/SaleStockService.php` (oder Methodenpaar in `ProductStockService`): `bookShipment(ShoppingOrder $order)` und `reverseShipment(ShoppingOrder $order)`. Kapselt Set-Auflösung, Mengenberechnung, Idempotenz und Gegenbuchung. +- Aufruf aus `SalesController@store` (Backend-Versand) und – falls Szenario B (siehe §7) – aus dem WP-Pfad. Ein Ort, eine Wahrheit. + +#### 7. Bestelldatenherkunft — zwei Szenarien (vorab zu bestätigen) +- **Szenario A (empfohlen, kleinster Eingriff):** Versand wird im Backend gebucht (heutiger Stand: `SalesController`). Trigger dort, **keine neue WooCommerce-Integration nötig** für den Abzug. Bestellungen kommen weiterhin über die bestehende `/api/wp/order`-Push-Schnittstelle herein. +- **Szenario B (Fulfillment in WooCommerce):** Versand passiert extern, WooCommerce meldet „versendet" zurück → neuer/erweiterter `/api/wp/*`-Endpoint (z. B. `ship`) **oder** Webhook, der `shipped=2` setzt und denselben Service ruft. Alternativ periodischer **WooCommerce-REST-Pull** (`GET wc/v3/orders?status=completed`), Abgleich über `wp_order_number`. +- **Zu klären:** Löst in WooCommerce der Status „completed" oder „processing" den Abzug aus? Wer ist führend für den Versandstatus — Backend oder Woo? + +#### 8. UI / Sichtbarkeit +- **Keine neue Seite zwingend:** Verkaufsbuchungen erscheinen automatisch in der AP-11-**Historie**; deren Quell-Filter um „Verkauf"/„Storno" erweitern. +- **Optional (schließt AP-10/AP-11-Lücke):** Kennzahl „Verkauf/Tag" je Produkt (analog „Verbrauch/Tag" bei Rohstoffen) im Produktbestand und im „Enthalten in"-Block der Rohstoff-Detailseite. + +#### 9. Edge Cases & offene Detailfragen +- Produkt ohne `wp_number` bzw. nicht auffindbar → Position überspringen + Logeintrag, **kein** Abbruch der Bestellbuchung. +- Bestand darf negativ werden (Versand ist Fakt) → nur visuelle Warnung, **kein** Block. +- Doppelte/wiederholte Statuswechsel → durch Idempotenz (§3) abgesichert. +- Teilstorno einzelner Positionen (falls Woo das liefert) → vorerst **ganze Bestellung**, Position-Granularität später. +- Varianten vs. Hauptprodukt-Abzug (§4) → Entscheidung mit Kunde. + +#### 10. Tests (Pest) +- Versand bucht „out" je Produkt × `qty`. +- Set-Versand bucht Komponenten × (Set-Menge × qty). +- Idempotenz: zweiter `store_shipped` bucht **nicht** doppelt. +- Storno bucht „in" als Gegenbuchung; `stock_booked_at` zurückgesetzt. +- Produkt ohne `wp_number` wird übersprungen (kein Fehler). +- Historie zeigt Quelle „Verkauf". + +#### 11. Abhängigkeiten & Aufwand +- Abhängig von **AP-02 (Sets)** ✅ und **AP-11 (Produktbestand)** ✅ — beide erledigt. +- **Aufwand:** Szenario A ~2–3 Tage; Szenario B (Webhook/Pull) ~3–5 Tage. + +**Akzeptanz:** Versand reduziert den Produktbestand; Set-Versand reduziert die enthaltenen Einzelprodukte; Storno bucht zurück; jede Buchung steht revisionssicher in der Historie. --- @@ -383,6 +481,8 @@ Diese Punkte sind klein, konkret und blockieren teils die tägliche Nutzung. Sie - **Akzeptanz:** Kunde sieht unter Einstellungen → Hinweise eine lesbare, gepflegte Statusseite. > **Empfehlung:** Früh als Platzhalter anlegen und mit jedem AP fortschreiben, damit der Kunde jederzeit den Stand sieht. +> +> **Status:** Erledigt (03.06.2026). Pflege-Quelle `resources/docs/hinweise.md`; `NoticeController` rendert sie mit `Str::markdown()` (CommonMark) zu HTML. View `admin/inventory/notices/index.blade.php`, Route `admin.inventory.notices` (`superadmin`), Sidenav-Eintrag „Hinweise" unter „Einstellungen". Inhalt deckt Entwicklungsstand, Nutzungshinweise und die festgehaltenen Entscheidungen (§5.2 Versand-Abzug, §5.3 Kauf-Sperre optional, §5.4 Blockrechte) sowie das offene Produktentwicklungs-Briefing ab. **Bei jedem weiteren AP fortzuschreiben.** Tests: `tests/Feature/InventoryNoticesTest.php` (2 grün). --- @@ -408,13 +508,11 @@ Diese Punkte sind klein, konkret und blockieren teils die tägliche Nutzung. Sie ## 6. Empfohlene Sofort-Reihenfolge (nächste Schritte) -✅ **Erledigt:** AP-00, AP-01, AP-04 (+ AP-04.1), AP-05, AP-06 (+ Nachtrag), AP-07 (+ AP-07.1), AP-08. +✅ **Erledigt:** AP-00, AP-01, AP-04 (+ AP-04.1), AP-05, AP-06 (+ Nachtrag), AP-07 (+ AP-07.1), AP-08, AP-09 (+ AP-09.1), AP-02 (Sets via Pivot), AP-03 („Nicht vorrätig"), AP-10 (Rohstoffbestand), AP-11 (Produktbestand + Historie), AP-12 (Ausgang / Ausschuss), AP-18 (Platzhalter, laufend zu pflegen). **➡️ Hier geht es weiter:** -1. **AP-09** Produktionskorrekturen: ausschließlich Hersteller-Rezeptur (+ Warnung bei fehlender Rezeptur, kein Fallback), Chargen-Label + Restbestandsfilter, B3 „Weitere Charge"-Fix, stabile Soll-Neuberechnung, B4 iPad-Layout, Produktentwicklung-Platzhalter. -2. **AP-18** Hinweise-Doku (Einstellungen → Hinweise) — kann parallel/früh als Platzhalter angelegt und laufend gepflegt werden. -3. Datenmodell **AP-02** (Sets via Pivot) / **AP-03** („Nicht vorrätig", nur Hinweis). -4. Große Übersichten **AP-10/AP-11** und Folge-APs (AP-12–AP-17). +1. **AP-13** (Shop-Anbindung: Bestandsabzug beim Versand inkl. Sets) – **Entwicklungskonzept liegt vor** (siehe AP-13 in §4). Kernbefund: Bestellungen liegen bereits im System (`shopping_orders`/`shopping_order_items`, Mapping über `products.wp_number`), Versandstatus wird zentral in `SalesController@store` gesetzt → Abzug kann ohne neue Tabelle auf der AP-11-Infrastruktur aufsetzen. **Vorab nur noch zu bestätigen:** Szenario A (Versand wird im Backend gebucht – empfohlen, kein neuer Woo-Eingriff) vs. Szenario B (WooCommerce meldet Versand per Webhook/REST-Pull zurück) sowie die Varianten-/Hauptprodukt-Detailfrage. Danach Folge-APs (AP-14–AP-17). +2. **AP-18** mit jedem weiteren AP fortschreiben (Hinweise-Seite aktuell halten). --- @@ -423,3 +521,4 @@ Diese Punkte sind klein, konkret und blockieren teils die tägliche Nutzung. Sie - Jedes abgeschlossene AP hier mit Datum + Kurzbeschreibung + Test-Status protokollieren (analog Umsetzungsprotokoll in `entwicklungsplan.md`). - Bei DB-Änderungen: Migration-Dateinamen referenzieren; bei Modellen Casts in `casts()`-Methode pflegen (L11-Konvention). - Vor jedem Commit: `vendor/bin/pint --dirty` und betroffene Tests (`php artisan test --filter=...`). +- **UI-Konvention Datumsfelder:** Datumsfelder in Formularen immer als `` (kein natives `type="date"`). Der Datepicker wird global über `public/js/custom.js` auf `.datepicker-base` gebunden (Format `dd.mm.yyyy`, deutsche Locale). Modellwerte mit `->format('d.m.Y')` ausgeben; Backend parst `d.m.Y` über `Carbon::parse` bzw. die `date`-Validierungsregel. diff --git a/resources/docs/hinweise.md b/resources/docs/hinweise.md new file mode 100644 index 0000000..7deb185 --- /dev/null +++ b/resources/docs/hinweise.md @@ -0,0 +1,124 @@ +# Hinweise zur Warenwirtschaft & Produktion + +> Diese Seite wird laufend gepflegt und zeigt den aktuellen Entwicklungsstand, +> wichtige Hinweise für die Nutzung sowie festgehaltene Entscheidungen, die +> später noch ausgebaut werden können. +> +> **Stand:** 03.06.2026 + +--- + +## 1. Überblick / Entwicklungsstand + +### Bereits nutzbar + +- **Einstellungen → Allgemein:** Umsatzsteuersätze und Lieferzeit-Vorlagen + (inkl. Tageswert) pflegbar. +- **Stammdaten:** Lagerorte, Rohstoffqualität, Verpackungsmaterial, + Lieferanten-Kategorien. +- **Lieferanten:** Bestellweg (E-Mail / Online-Shop), Bestell-Adresse, + Lieferzeit (Freitext + Tage). +- **INCI / Rohstoffe:** mehrere Lieferanten, Umsatzsteuersatz, eigene Lieferzeit. +- **Einkauf & Wareneingang:** zweistufig (bestellt → eingegangen), Charge & MHD, + Netto-/Brutto-Automatik mit USt-Snapshot, Einkauf duplizierbar. +- **Produktion:** rechnet ausschließlich auf Basis der **Hersteller-Rezeptur**. + Fehlt diese, erscheint eine Warnung (kein Rückgriff auf die Produkt-Rezeptur). + Eigenprodukte ohne Rezeptur (z. B. Broschüren, Etiketten) lassen sich über die + Produkt-Option „benötigt keine Rezeptur" ohne Chargen produzieren. +- **Produkt-Klassen:** Einzelprodukt vs. **Set** (Bündel mehrerer Einzelprodukte + mit Menge). Sets werden nicht produziert; optional kann ein Einzelprodukt einem + Hauptprodukt zugeordnet werden. +- **„Nicht vorrätig":** Produkt zeitlich begrenzt (mit Tagesangabe → + „In ca. X Tagen wieder da!") oder auf unbestimmte Zeit als nicht vorrätig + markierbar. Im öffentlichen Shop erscheint nur ein Hinweis, der Kauf bleibt + möglich. In der **internen Bestellliste** ersetzt der rote Hinweis die + Mengen-Buttons – dort ist das Produkt also vorübergehend nicht bestellbar. +- **Rohstoffbestand:** Übersicht aller aktiven Rohstoffe mit echtem Restbestand + (Wareneingang abzüglich Produktionsverbrauch), durchschnittlichem Verbrauch + pro Tag, voraussichtlichem „auf Null"-Datum und Hochrechnung für 1/3/6/12 + Monate. Suche und Filter „nur kritische anzeigen". Kritische Rohstoffe + (Meldebestand unterschritten) sind rot hervorgehoben; ein Badge in der + Navigation zeigt die Anzahl. Die Detailseite zeigt Bestand je Lagerort und + Charge sowie die zugeordneten Lieferanten mit Bestell-Link (Shop / Per Mail). + **Offene Bestellungen** (bestellt, aber noch nicht eingegangen) werden separat + ausgewiesen (Spalte „Offen bestellt" in der Übersicht, eigener Block in der + Detailansicht). Sie zählen **nicht** zum Bestand; ein kritischer Rohstoff mit + bereits offener Bestellung wird jedoch entschärft („Kritisch · bereits + bestellt") und nicht im Navigations-Badge mitgezählt. +- **Produktbestand:** Übersicht aller Haupt-/Einzelprodukte mit aktuellem + Bestand (Stück). Bestand = Summe aller Eingänge minus aller Ausgänge. Über die + Buttons **`+`/`−`** lassen sich Bewegungen mit Stückzahl, Grund (z. B. + Initialbestand, Korrektur, Inventur, Retoure, Testervergabe) und Hinweis + buchen; der Button **„Produzieren"** öffnet die Produktion mit bereits + ausgewähltem Produkt. Suche und Filter „nur kritische anzeigen"; bei + unterschrittenem kritischem Bestand ist die Zeile rot, beim Meldebestand gelb – + ein Badge in der Navigation zeigt die Anzahl. Schwellwerte werden je Produkt im + Produktformular (Warenwirtschaft) gepflegt. +- **Produktbestand-Historie:** Revisionssichere Liste aller Bewegungen + (Eingang/Ausgang, Stückzahl, Datum, Grund, Hinweis, Mitarbeiter), filterbar + nach Produkt, Richtung, Grund und Zeitraum (Monat/Jahr). + +- **Ausgang / Ausschuss:** Erfassung von Rohstoff- und Verpackungs-Abgängen + (z. B. Bruch, Verfall/MHD, Qualitätsmangel, Schwund, Testverbrauch). Pflichtfeld + **Grund**, optionale **Charge** (setzt den Lagerort automatisch), Menge in + Gramm (Rohstoff) bzw. Stück (Verpackung) und Datum. Jeder Ausgang reduziert + sofort den Bestand – beim Rohstoffbestand also auch die „auf Null"-Prognose und + den Kritisch-Status. + +### In Vorbereitung / geplant + +- **Shop-Anbindung** (Bestandsabzug beim Versand), + **Audit-Trail**, **blockbasierte Rechte**, **2FA für Admins**. +- **Produktentwicklung:** Menüpunkt ist als Platzhalter angelegt – das genaue + Briefing steht noch aus. + +--- + +## 2. Wichtige Hinweise für die Nutzung + +- **Produktion benötigt eine Hersteller-Rezeptur.** Ist für ein Produkt keine + gepflegt, lässt es sich nicht produzieren – außer es ist ausdrücklich als + Eigenprodukt ohne Rezeptur markiert. +- **Chargen-Auswahl in der Produktion** zeigt nur Chargen mit Restbestand, + in der Reihenfolge des frühesten Mindesthaltbarkeitsdatums (FEFO). +- **Umsatzsteuersätze** werden beim Einkauf als Snapshot gespeichert. Spätere + Änderungen am Steuersatz verändern bereits erfasste Einkäufe nicht. +- **Rohstoffbestand – Verbrauch/Tag** wird aus der Produktionshistorie der + letzten 90 Tage gemittelt. Ein Rohstoff gilt als **kritisch** (rot), wenn der + Restbestand den am Rohstoff gepflegten **Meldebestand** unterschreitet, und als + **„bald nachbestellen"** (gelb), wenn der Bestand voraussichtlich vor Ablauf der + Lieferzeit aufgebraucht ist. +- **Bestand = nur eingegangene Ware.** Ein neuer Einkauf ist zunächst „offen" + (bestellt) und erhöht den Bestand erst, wenn beim Einkauf der **Wareneingang + gebucht** wird (Stufe 2: eingegangen, mit Menge/Charge/MHD). Bis dahin erscheint + die Menge nur unter „Offen bestellt". +- **Produktbestand wächst automatisch durch Produktion.** Jede gebuchte + Produktion erzeugt einen Eingang in Höhe der produzierten Stückzahl; eine + spätere Mengenkorrektur der Produktion wird als Differenz nachgebucht. Manuelle + Korrekturen erfolgen ausschließlich über Gegenbuchungen (`+`/`−`) – Einträge + der Historie werden nicht gelöscht. Verkaufsabgänge folgen mit der + Shop-Anbindung. + +--- + +## 3. Festgehaltene Entscheidungen (mit Ausbaupotenzial) + +Diese Punkte sind bewusst zunächst einfach umgesetzt und können bei Bedarf +später erweitert werden: + +- **„Nicht vorrätig":** Vorerst nur ein **Hinweis** – der Kauf bleibt möglich. + Eine echte **Kauf-Sperre** kann künftig optional ergänzt werden. +- **Bestandsabzug im Shop** erfolgt **beim Versand** (erst mit gebuchtem Versand + gilt die Ware als „aus dem Regal" entnommen). Stornos/Retouren werden als + Gegenbuchung abgebildet. +- **Blockbasierte Rechte** gelten zunächst **nur für Warenwirtschaft und + Produktmanagement**. Bei Bedarf können sie später auf weitere Bereiche + ausgebaut werden. + +--- + +## 4. Offene Briefings + +- **Produktentwicklung:** Funktion und Ablauf sind noch nicht final + beschrieben. Sobald ein Briefing vorliegt, wird der Platzhalter durch die + tatsächliche Funktionalität ersetzt. diff --git a/resources/views/admin/inventory/notices/index.blade.php b/resources/views/admin/inventory/notices/index.blade.php new file mode 100644 index 0000000..253fd9b --- /dev/null +++ b/resources/views/admin/inventory/notices/index.blade.php @@ -0,0 +1,44 @@ +@extends('layouts.layout-2') + +@section('content') +

{{ __('Hinweise') }}

+ +
+
+ {!! $content !!} +
+
+ +@endsection + +@section('styles') + +@endsection diff --git a/resources/views/admin/inventory/product-development/index.blade.php b/resources/views/admin/inventory/product-development/index.blade.php new file mode 100644 index 0000000..fbd01df --- /dev/null +++ b/resources/views/admin/inventory/product-development/index.blade.php @@ -0,0 +1,19 @@ +@extends('layouts.layout-2') + +@section('content') +

{{ __('Produktentwicklung') }}

+ +
+
+
+
{{ __('Briefing ausstehend') }}
+

+ {{ __('Dieser Bereich ist als Platzhalter angelegt. Zur konkreten Funktionsweise der Produktentwicklung steht noch ein genaues Briefing aus.') }} +

+

+ {{ __('Es findet hier aktuell keine Bestandsbuchung und keine Verarbeitungslogik statt.') }} +

+
+
+
+@endsection diff --git a/resources/views/admin/inventory/product-stock/history.blade.php b/resources/views/admin/inventory/product-stock/history.blade.php new file mode 100644 index 0000000..7d7e2f7 --- /dev/null +++ b/resources/views/admin/inventory/product-stock/history.blade.php @@ -0,0 +1,110 @@ +@extends('layouts.layout-2') + +@php + $months = [ + 1 => __('Januar'), 2 => __('Februar'), 3 => __('März'), 4 => __('April'), + 5 => __('Mai'), 6 => __('Juni'), 7 => __('Juli'), 8 => __('August'), + 9 => __('September'), 10 => __('Oktober'), 11 => __('November'), 12 => __('Dezember'), + ]; +@endphp + +@section('content') + @include('admin.inventory.partials.table-actions-style') +
+
{{ __('Produktbestand Historie') }}
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ @if ($filters['product_id'] || $filters['direction'] || $filters['reason'] || $filters['month'] || $filters['year']) + {{ __('Filter zurücksetzen') }} + @endif +
+
+
+ + + + + + + + + + + + + @forelse ($movements as $movement) + + + + + + + + + @empty + + + + @endforelse + +
{{ __('Produkt / Material') }}{{ __('Art') }}{{ __('Stückzahl') }}{{ __('Datum') }}{{ __('Grund') }}{{ __('Hinweis') }}
{{ $movement->product?->name ?? '—' }} + @if ($movement->isIn()) + {{ __('Eingang') }} + @else + {{ __('Ausgang') }} + @endif + {{ \App\Services\Util::formatNumber($movement->quantity, 0) }} {{ __('Stück') }}{{ $movement->created_at?->translatedFormat('l, d.m.Y') }}{{ $movement->reason ?: '—' }} + {{ $movement->note }} + @if ($movement->user) + {{ __('Mitarbeiter') }}: {{ $movement->user->getFullName(false) ?: $movement->user->email }} + @endif +
{{ __('Keine Bewegungen gefunden.') }}
+
+
+@endsection diff --git a/resources/views/admin/inventory/product-stock/index.blade.php b/resources/views/admin/inventory/product-stock/index.blade.php new file mode 100644 index 0000000..512640b --- /dev/null +++ b/resources/views/admin/inventory/product-stock/index.blade.php @@ -0,0 +1,144 @@ +@extends('layouts.layout-2') + +@section('content') + @include('admin.inventory.partials.table-actions-style') +
+
+
+
{{ __('Produktbestand') }}
+ +
+
+ +
+ +
+
+
+
+ + + + + + + + + + + @forelse($rows as $row) + @php + $product = $row['product']; + $rowClass = $row['status'] === 'critical' ? 'table-danger' : ($row['status'] === 'warning' ? 'table-warning' : ''); + $stockClass = $row['status'] === 'critical' ? 'text-danger font-weight-bold' : ($row['status'] === 'warning' ? 'text-warning font-weight-bold' : ''); + @endphp + + + + + + + @empty + + + + @endforelse + +
{{ __('Name') }}{{ __('Bestand') }}{{ __('Aktion') }}
+ @if ($product->images->count()) + + @endif + {{ $product->name }}{{ \App\Services\Util::formatNumber($row['stock'], 0) }} {{ __('Stück') }} + @if (Auth::user()->isAdmin()) + + + @endif + {{ __('Produzieren') }} +
{{ __('Keine Produkte vorhanden.') }}
+
+
+ + @if (Auth::user()->isAdmin()) + + @endif + + +@endsection diff --git a/resources/views/admin/inventory/productions/_form_fields.blade.php b/resources/views/admin/inventory/productions/_form_fields.blade.php new file mode 100644 index 0000000..2f5dd60 --- /dev/null +++ b/resources/views/admin/inventory/productions/_form_fields.blade.php @@ -0,0 +1,71 @@ +@php($isEdit = $isEdit ?? false) +
+
+ + + @error('product_id') +
{{ $message }}
+ @enderror +
+
+ + + @error('location_id') +
{{ $message }}
+ @enderror +
+
+
+
+ + + @error('produced_at') +
{{ $message }}
+ @enderror +
+
+ + + @error('quantity') +
{{ $message }}
+ @enderror +
+
+ + + + + +
+ + +
diff --git a/resources/views/admin/inventory/productions/_scripts.blade.php b/resources/views/admin/inventory/productions/_scripts.blade.php new file mode 100644 index 0000000..40f442f --- /dev/null +++ b/resources/views/admin/inventory/productions/_scripts.blade.php @@ -0,0 +1,213 @@ + diff --git a/resources/views/admin/inventory/productions/create.blade.php b/resources/views/admin/inventory/productions/create.blade.php index 4a1ac94..b4b6faf 100644 --- a/resources/views/admin/inventory/productions/create.blade.php +++ b/resources/views/admin/inventory/productions/create.blade.php @@ -7,73 +7,7 @@
@csrf -
-
- - - @error('product_id') -
{{ $message }}
- @enderror -
-
- - - @error('location_id') -
{{ $message }}
- @enderror -
-
-
-
- - - @error('produced_at') -
{{ $message }}
- @enderror -
-
- - - @error('quantity') -
{{ $message }}
- @enderror -
-
- - - -
- - -
+ @include('admin.inventory.productions._form_fields', ['isEdit' => false]) {{ __('Abbrechen') }} @@ -83,109 +17,12 @@ @endsection @section('scripts') - + @php + $existingLines = $model + ? $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) { + return $lines->map(fn ($l) => ['stock_entry_id' => $l->stock_entry_id, 'quantity_used' => (float) $l->quantity_used])->values(); + }) + : []; + @endphp + @include('admin.inventory.productions._scripts', ['existingLines' => $existingLines, 'excludeProductionId' => null]) @endsection diff --git a/resources/views/admin/inventory/productions/edit.blade.php b/resources/views/admin/inventory/productions/edit.blade.php index 035b449..f82799e 100644 --- a/resources/views/admin/inventory/productions/edit.blade.php +++ b/resources/views/admin/inventory/productions/edit.blade.php @@ -8,73 +8,7 @@ @csrf @method('PUT') -
-
- - - @error('product_id') -
{{ $message }}
- @enderror -
-
- - - @error('location_id') -
{{ $message }}
- @enderror -
-
-
-
- - - @error('produced_at') -
{{ $message }}
- @enderror -
-
- - - @error('quantity') -
{{ $message }}
- @enderror -
-
- - - -
- - -
+ @include('admin.inventory.productions._form_fields', ['isEdit' => true]) {{ __('Abbrechen') }} @@ -84,120 +18,10 @@ @endsection @section('scripts') - + @php + $existingLines = $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) { + return $lines->map(fn ($l) => ['stock_entry_id' => $l->stock_entry_id, 'quantity_used' => (float) $l->quantity_used])->values(); + }); + @endphp + @include('admin.inventory.productions._scripts', ['existingLines' => $existingLines, 'excludeProductionId' => $model->id]) @endsection diff --git a/resources/views/admin/inventory/raw-material-stock/index.blade.php b/resources/views/admin/inventory/raw-material-stock/index.blade.php new file mode 100644 index 0000000..9faccd5 --- /dev/null +++ b/resources/views/admin/inventory/raw-material-stock/index.blade.php @@ -0,0 +1,156 @@ +@extends('layouts.layout-2') + +@section('content') + @include('admin.inventory.partials.table-actions-style') + +
+
+
+
{{ __('Rohstoffbestand') }}
+ +
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + + + @forelse($rows as $row) + @php + $ingredient = $row['ingredient']; + $rowClass = $row['status'] === 'critical' + ? 'table-danger' + : (in_array($row['status'], ['warning', 'critical_ordered'], true) ? 'table-warning' : ''); + @endphp + + + + + + + + + + @empty + + + + @endforelse + +
{{ __('Name') }}{{ __('Qualität') }}{{ __('Bestand') }}{{ __('Offen bestellt') }}{{ __('Verbrauch / Tag') }}{{ __('Voraussichtlich auf Null') }} + +
+ + {{ $ingredient->name }} + {{ $ingredient->materialQuality?->name ?? '—' }} + {{ \App\Services\Util::formatNumber($row['remaining'], 0) }} g + @if($row['status'] === 'critical_ordered') + {{ __('bestellt') }} + @endif + + @if($row['open_order'] > 0) + {{ \App\Services\Util::formatNumber($row['open_order'], 0) }} g + @else + + @endif + + @if($row['daily'] !== null && $row['daily'] > 0) + {{ \App\Services\Util::formatNumber($row['daily'], 0) }} g + @else + + @endif + + @if($row['expected_empty'] !== null) + {{ $row['expected_empty']->format('d.m.Y') }} + ({{ $row['days_until_empty'] }} {{ trans_choice('Tag|Tagen', $row['days_until_empty']) }}) + @else + + @endif + + @if($row['daily'] !== null && $row['daily'] > 0) + {{ \App\Services\Util::formatNumber($row['daily'] * $defaultHorizon, 0) }} g + @else + + @endif +
{{ __('Keine aktiven Rohstoffe vorhanden.') }}
+
+
+ + +@endsection diff --git a/resources/views/admin/inventory/raw-material-stock/show.blade.php b/resources/views/admin/inventory/raw-material-stock/show.blade.php new file mode 100644 index 0000000..2e08530 --- /dev/null +++ b/resources/views/admin/inventory/raw-material-stock/show.blade.php @@ -0,0 +1,256 @@ +@extends('layouts.layout-2') + +@section('content') + @include('admin.inventory.partials.table-actions-style') + + @php + $statusBadge = match ($status) { + 'critical' => '' . __('Kritisch') . '', + 'critical_ordered' => '' . __('Kritisch · bereits bestellt') . '', + 'warning' => '' . __('Bald nachbestellen') . '', + default => '' . __('Ausreichend') . '', + }; + @endphp + +
+
+ {{ __('Rohstoffbestellung') }}: {{ $ingredient->name }} {!! $statusBadge !!} + {{ __('Zurück') }} +
+
+
+
+
{{ __('Qualität') }}
+
{{ $ingredient->materialQuality?->name ?? '—' }}
+
+
+
{{ __('Gesamtbestand') }}
+
+ {{ \App\Services\Util::formatNumber($remaining, 0) }} g +
+
+
+
{{ __('Offen bestellt') }}
+
+ @if($openTotal > 0) + {{ \App\Services\Util::formatNumber($openTotal, 0) }} g + @else + — + @endif +
+
+
+
{{ __('Verbrauch / Tag') }}
+
+ @if($daily !== null && $daily > 0) + {{ \App\Services\Util::formatNumber($daily, 0) }} g + @else + — + @endif +
+
+
+
{{ __('Voraussichtlich auf Null') }}
+
+ @if($expectedEmpty !== null) + {{ $expectedEmpty->format('d.m.Y') }} + ({{ $daysUntilEmpty }} {{ trans_choice('Tag|Tagen', $daysUntilEmpty) }}) + @else + — + @endif +
+
+
+ @if($ingredient->min_stock_alert !== null) +
+ {{ __('Meldebestand') }}: {{ \App\Services\Util::formatNumber($ingredient->min_stock_alert, 0) }} g +
+ @endif +
+
+ +
+
{{ __('Enthalten in') }}
+
+ + + + + + + + + + @forelse($ingredient->products as $product) + + + + + + @empty + + + + @endforelse + +
{{ __('Produkt') }}{{ __('Produktbestand') }}{{ __('Rezeptur-Anteil (g/Stück)') }}
{{ $product->name }}{{ \App\Services\Util::formatNumber($productStock[$product->id] ?? 0, 0) }} {{ __('Stück') }} + @if($product->pivot->gram !== null && $product->pivot->gram !== '') + {{ \App\Services\Util::formatNumber($product->pivot->gram, 2) }} g + @else + + @endif +
{{ __('Dieser Rohstoff wird in keiner aktiven Rezeptur verwendet.') }}
+
+
+ +
+
{{ __('Lieferanten & Bestellung') }}
+
+ + + + + + + + + + + @forelse($ingredient->suppliers as $supplier) + @php + $deliveryTime = $ingredient->delivery_time ?: $supplier->delivery_time; + $lastPrice = $lastPriceBySupplier[$supplier->id] ?? null; + $orderUrl = $supplier->order_url ?: $supplier->url; + $orderEmail = $supplier->order_email ?: $supplier->email; + @endphp + + + + + + + @empty + + + + @endforelse + +
{{ __('Lieferant') }}{{ __('Lieferzeit') }}{{ __('Letzter Einkauf (Netto/kg)') }}
+ {{ $supplier->name }} + @if($supplier->pivot->preferred) + {{ __('bevorzugt') }} + @endif + @if($supplier->pivot->supplier_sku) +
{{ __('Art.-Nr.') }}: {{ $supplier->pivot->supplier_sku }}
+ @endif +
{{ $deliveryTime ?: '—' }} + @if($lastPrice !== null) + {{ \App\Services\Util::formatNumber($lastPrice, 2) }} € + @else + + @endif + + @if($supplier->order_method === 'online_shop' && $orderUrl) + {{ __('Zum Shop') }} + @elseif($orderEmail) + {{ __('Per Mail') }} + @elseif($orderUrl) + {{ __('Zum Shop') }} + @else + + @endif +
{{ __('Diesem Rohstoff ist kein Lieferant zugeordnet.') }}
+
+ @if(Auth::user()->isAdmin()) + + @endif +
+ +
+
{{ __('Offene Bestellungen / unterwegs') }}
+
+ + + + + + + + + + + + @forelse($openOrders as $order) + + + + + + + + @empty + + + + @endforelse + +
{{ __('Bestellt am') }}{{ __('Lieferant') }}{{ __('Lagerort') }}{{ __('Bestellte Menge') }}
{{ $order->ordered_at?->format('d.m.Y') ?? '—' }}{{ $order->supplier?->name ?? '—' }}{{ $order->location?->name ?? '—' }}{{ \App\Services\Util::formatNumber($order->ordered_quantity, 0) }} g + + + +
{{ __('Keine offenen Bestellungen.') }}
+
+ @if($openOrders->isNotEmpty()) + + @endif +
+ +
+
{{ __('Verfügbare Chargen') }}
+
+ + + + + + + + + + + + @forelse($charges as $charge) + + + + + + + + @empty + + + + @endforelse + + @if($charges->isNotEmpty() && count($remainingByLocation) > 0) + + @foreach($locations as $location) + @if(isset($remainingByLocation[$location->id]) && $remainingByLocation[$location->id] > 0) + + + + + @endif + @endforeach + + @endif +
{{ __('Charge') }}{{ __('Lieferant') }}{{ __('Lagerort') }}{{ __('MHD') }}{{ __('Restbestand') }}
{{ $charge->batch_number ?: '#' . $charge->id }}{{ $charge->supplier?->name ?? '—' }}{{ $charge->location?->name ?? '—' }}{{ $charge->best_before?->format('d.m.Y') ?? '—' }}{{ \App\Services\Util::formatNumber($charge->getAttribute('remaining_quantity'), 0) }} g
{{ __('Kein Restbestand vorhanden.') }}
{{ __('Bestand') }} {{ $location->name }}{{ \App\Services\Util::formatNumber($remainingByLocation[$location->id], 0) }} g
+
+
+@endsection diff --git a/resources/views/admin/inventory/stock-disposals/create.blade.php b/resources/views/admin/inventory/stock-disposals/create.blade.php new file mode 100644 index 0000000..4d5121f --- /dev/null +++ b/resources/views/admin/inventory/stock-disposals/create.blade.php @@ -0,0 +1,194 @@ +@extends('layouts.layout-2') + +@section('content') +

{{ __('Ausgang / Ausschuss erfassen') }}

+
+
+

{{ __('Reduziert den Bestand des gewählten Rohstoffs bzw. Verpackungsartikels. Der Grund ist Pflicht und erscheint in der Ausgangsliste.') }}

+ + @csrf + +
+ + +
+ + + + + + + +
+ + + @error('location_id') +
{{ $message }}
+ @enderror +
+ +
+ + + {{ __('Bei Rohstoff in Gramm, bei Verpackung in Stück.') }} + @error('quantity') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('reason') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('note') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('disposed_at') +
{{ $message }}
+ @enderror +
+ + + {{ __('Zurück') }} + +
+
+@endsection + +@section('scripts') + +@endsection diff --git a/resources/views/admin/inventory/stock-disposals/index.blade.php b/resources/views/admin/inventory/stock-disposals/index.blade.php new file mode 100644 index 0000000..d44395f --- /dev/null +++ b/resources/views/admin/inventory/stock-disposals/index.blade.php @@ -0,0 +1,76 @@ +@extends('layouts.layout-2') + +@section('content') + @include('admin.inventory.partials.table-actions-style') +
+
+
{{ __('Ausgang / Ausschuss') }}
+
+
+ +
+ @if (Auth::user()->isAdmin()) + + {{ __('Ausschuss erfassen') }} + + @endif +
+
+
+ + + + + + + + + + + + + + + + @forelse($values as $disposal) + + + + + + + + + + + + @empty + + + + @endforelse + +
{{ __('Datum') }}{{ __('Art') }}{{ __('Artikel') }}{{ __('Charge') }}{{ __('Lagerort') }}{{ __('Menge') }}{{ __('Grund') }}{{ __('Hinweis') }}{{ __('Mitarbeiter') }}
{{ $disposal->disposed_at?->format('d.m.Y') }} + @if ($disposal->isIngredient()) + {{ __('Rohstoff') }} + @else + {{ __('Verpackung') }} + @endif + {{ $disposal->articleName() }} + @if ($disposal->stockEntry) + {{ $disposal->stockEntry->batch_number ?: '#'.$disposal->stockEntry->id }} + @else + + @endif + {{ $disposal->location?->name ?? '—' }} + −{{ \App\Services\Util::formatNumber($disposal->quantity, $disposal->unit === 'piece' ? 0 : 2) }} + {{ $disposal->unit === 'piece' ? __('Stück') : 'g' }} + {{ $disposal->reason }}{{ $disposal->note }}{{ $disposal->user?->getFullName(false) ?: ($disposal->user?->email ?? '—') }}
{{ __('Noch keine Ausgänge erfasst.') }}
+
+
+@endsection diff --git a/resources/views/admin/inventory/stock-entries/_form.blade.php b/resources/views/admin/inventory/stock-entries/_form.blade.php index bdb75c5..28a6a2c 100644 --- a/resources/views/admin/inventory/stock-entries/_form.blade.php +++ b/resources/views/admin/inventory/stock-entries/_form.blade.php @@ -95,9 +95,9 @@
- + @error('ordered_at')
{{ $message }}
@enderror diff --git a/resources/views/admin/inventory/stock-entries/show.blade.php b/resources/views/admin/inventory/stock-entries/show.blade.php index 4d5971a..f7e8fef 100644 --- a/resources/views/admin/inventory/stock-entries/show.blade.php +++ b/resources/views/admin/inventory/stock-entries/show.blade.php @@ -137,9 +137,9 @@
- + @error('received_at')
{{ $message }}
@enderror @@ -175,8 +175,8 @@
- @error('best_before')
{{ $message }}
diff --git a/resources/views/admin/modal/show_product.blade.php b/resources/views/admin/modal/show_product.blade.php index 2a14ffd..2cc69d8 100644 --- a/resources/views/admin/modal/show_product.blade.php +++ b/resources/views/admin/modal/show_product.blade.php @@ -21,6 +21,11 @@

{{ $product->name }}

+ @if ($product->isOutOfStock()) +
+ {{ $product->outOfStockNotice() }} +
+ @endif {!! $product->copy !!} diff --git a/resources/views/admin/product/edit.blade.php b/resources/views/admin/product/edit.blade.php index c59ec7c..3e5413a 100755 --- a/resources/views/admin/product/edit.blade.php +++ b/resources/views/admin/product/edit.blade.php @@ -62,12 +62,21 @@ 'material_name' => $item->packagingMaterial?->name ?? '', ]; }); + $set_product_catalog_for_js = $set_product_catalog->keyBy('id')->map(function ($item) { + return [ + 'name' => $item->name, + 'number' => $item->number, + 'weight' => $item->weight ? $item->weight . ' g' : '—', + 'price' => $item->getFormattedPrice() !== '' ? $item->getFormattedPrice() . ' €' : '—', + ]; + }); @endphp diff --git a/resources/views/admin/product/form.blade.php b/resources/views/admin/product/form.blade.php index 14f8856..f2ca05d 100755 --- a/resources/views/admin/product/form.blade.php +++ b/resources/views/admin/product/form.blade.php @@ -6,13 +6,16 @@ id="product-form-section-nav" aria-label="{{ __('Sprungmarken Produktformular') }}"> {{ __('Bereiche') }}:{{ __('Produkt') }} + {{ __('Verfügbarkeit') }} + {{ __('Set') }}{{ __('Preise in EUR') }}{{ __('Landesspezifische Preise') }}{{ __('White-Label') }}{{ __('Details') }} - {{ __('Inhaltsstoffe') }} / {{ __('Rezeptur') }} - {{ __('Hersteller Rezeptur') }} - {{ __('Warenwirtschaft') }} + {{ __('Inhaltsstoffe') }} / {{ __('Rezeptur') }} + {{ __('Hersteller Rezeptur') }} + {{ __('Verpackung') }} + {{ __('Warenwirtschaft') }} @if (Auth::user()->isSySAdmin()) {{ __('SySAdmin Einstellungen') }} @endif @@ -97,6 +100,171 @@ +
+
{{ __('Verfügbarkeit') }}
+
+ @php($outOfStockDays = $product->outOfStockRemainingDays()) +
+ +

{{ __('Zeigt im Shop den Hinweis „In ca. X Tagen wieder da!". Der Kauf bleibt weiterhin möglich.') }}

+
+ + +
+ +
+ +

{{ __('Daueranzeige ohne Zeitangabe (hat Vorrang vor der Tagesangabe). Der Kauf bleibt weiterhin möglich.') }}

+
+ +
+
+
+   +
+
+
+
+ +
+
{{ __('Set / Produktart') }}
+
+
+ +

{{ __('Bei aktivem Set werden Rezeptur, Verpackung und Warenwirtschaft ausgeblendet. Ein Set wird nicht produziert; beim Verkauf werden später die enthaltenen Einzelprodukte abgebucht.') }}

+
+ +
+ + + + + + + + + + + + + @foreach ($product->setItems as $component) + + + + + + + + + + @endforeach + +
{{ __('Name') }}{{ __('Artikelnr.') }}{{ __('Gewicht') }}{{ __('Preis VK in EUR (Brutto)') }}{{ __('Menge') }}
{{ $component->name }}{{ $component->number ?? '—' }}{{ $component->weight ? $component->weight . ' g' : '—' }}{{ $component->getFormattedPrice() !== '' ? $component->getFormattedPrice() . ' €' : '—' }} + + + + +
+
+
+ +
+ + +
+ +
+
+
+   +
+
+
+
+
{{ __('Preise in EUR') }} @@ -419,6 +587,16 @@
+ +
+ +

{{ __('Bei aktiver Option werden die Rezeptur-Felder ausgeblendet und in der Produktion wird keine Rezeptur abgefragt.') }}

+
+ +

{{ __('Reihenfolge per Drag & Drop ändern, danach speichern.') }}

@@ -553,6 +731,7 @@ + {{-- /.js-recipe-fields --}}

@@ -568,6 +747,7 @@
+

{{ __('Eigene Hersteller-Rezeptur (separate INCI-Liste). Reihenfolge per Drag & Drop ändern, danach speichern.') }}

@@ -683,6 +863,7 @@ + {{-- /.js-recipe-fields --}}

@@ -846,6 +1027,40 @@ @endforeach
+ +
+

{{ __('Produktbestand-Schwellwerte (Stück). Bei Erreichen wird das Produkt im Produktbestand farblich markiert.') }}

+
+
+ + {{ Form::number('min_product_stock', old('min_product_stock', $product->min_product_stock), ['placeholder' => __('z. B. 20'), 'class' => 'form-control', 'id' => 'min_product_stock', 'min' => 0, 'step' => 1]) }} +
+
+ + {{ Form::number('critical_product_stock', old('critical_product_stock', $product->critical_product_stock), ['placeholder' => __('z. B. 10'), 'class' => 'form-control', 'id' => 'critical_product_stock', 'min' => 0, 'step' => 1]) }} +
+
+ +
+

{{ __('Optional: dieses Einzelprodukt einem Hauptprodukt zuordnen (z. B. „50 × 15 ml"). Der Produktbestand führt nur Haupt-/Einzelprodukte.') }}

+
+
+ + +
+
+ + {{ Form::number('main_product_quantity', old('main_product_quantity', $product->main_product_quantity), ['placeholder' => __('z. B. 50'), 'class' => 'form-control', 'id' => 'main_product_quantity', 'min' => 1, 'step' => 1]) }} +
+
+

diff --git a/resources/views/admin/product/index.blade.php b/resources/views/admin/product/index.blade.php index b5260d5..7b75e71 100755 --- a/resources/views/admin/product/index.blade.php +++ b/resources/views/admin/product/index.blade.php @@ -4,87 +4,117 @@
- {{__('Produkte')}} + {{ __('Produkte') }}
- - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - + + + - @foreach($values as $value) - - - - - - - - - - - - - - - - - - - - - - @endforeach + @foreach ($values as $value) + + + + + + + + + + + + + + + + + + + + + + + @endforeach
 {{__('Pos')}}{{__('Bild')}}{{__('Name')}}{{__('Artikelnummer')}}{{__('Kategorie')}}{{__('Preis')}}{{__('Inhalt')}}{{__('Einheit')}}{{__('Grundpreis')}}{{__('Gewicht')}}{{__('sichbar')}}
{{__('WL')}}
{{__('KP')}}
{{__('MK')}}
{{__('ER')}}
{{__('AA')}}
 {{ __('Pos') }}{{ __('Bild') }}{{ __('Name') }}{{ __('Artikelnummer') }}{{ __('Kategorie') }}{{ __('Preis') }}{{ __('Inhalt') }}{{ __('Einheit') }}{{ __('Grundpreis') }}{{ __('Gewicht') }}{{ __('Verfügbarkeit') }}{{ __('sichbar') }} +
{{ __('WL') }}
+
+
{{ __('KP') }} +
+
+
+ {{ __('MK') }}
+
+
{{ __('ER') }}
+
+
+ {{ __('AA') }}
+
{{__('Status')}}
{{ __('Status') }}
- - - - {{ $value->pos }} - @if(count($value->images)) - - @endif - {{ $value->name }}{{ $value->number }} - @foreach($value->categories as $category) -
{{ $category->category->name }}
- @endforeach -
{{ $value->getFormattedPrice() }}{{ $value->contents_total }}{{ $value->getUnitType() }}{{ $value->getBasePriceFormatted() }}{{ $value->weight }}{!! $value->getShowOnTypes('
') !!}
{!! get_active_badge($value->whitelabel, $value->whitelabel_name) !!}{!! get_active_badge($value->shipping_addon) !!}{!! get_active_badge($value->max_buy) !!}{!! get_active_badge($value->single_commission) !!}{!! get_active_badge($value->exclude_stats_sales) !!}{!! get_active_badge($value->active) !!}   -
+ + + + {{ $value->pos }} + @if (count($value->images)) + + @endif + {{ $value->name }}{{ $value->number }} + @foreach ($value->categories as $category) +
{{ $category->category->name }}
+ @endforeach +
{{ $value->getFormattedPrice() }}{{ $value->contents_total }}{{ $value->getUnitType() }}{{ $value->getBasePriceFormatted() }}{{ $value->weight }} + @if ($value->isOutOfStock()) + {{ $value->outOfStockNotice() }} + @else + {{ __('vorrätig') }} + @endif + {!! $value->getShowOnTypes('
') !!}
{!! get_active_badge($value->whitelabel, $value->whitelabel_name) !!}{!! get_active_badge($value->shipping_addon) !!}{!! get_active_badge($value->max_buy) !!}{!! get_active_badge($value->single_commission) !!}{!! get_active_badge($value->exclude_stats_sales) !!}{!! get_active_badge($value->active) !!}   + +
@endsection diff --git a/resources/views/layouts/includes/layout-sidenav.blade.php b/resources/views/layouts/includes/layout-sidenav.blade.php index aa18e05..c6ccd52 100644 --- a/resources/views/layouts/includes/layout-sidenav.blade.php +++ b/resources/views/layouts/includes/layout-sidenav.blade.php @@ -211,17 +211,70 @@
  • WARENWIRTSCHAFT
  • @if (Auth::user()->isCopyReader()) +
  • + + +
    {{ __('Produktbestand') }} + @if (($criticalProductCount ?? 0) > 0) + {{ $criticalProductCount }} + @endif +
    +
    + +
  • +
  • + +
    {{ __('Rohstoffbestand') }}
    + @if (($criticalIngredientCount ?? 0) > 0) + {{ $criticalIngredientCount }} + @endif +
    +
  • {{ __('Einkauf & Wareneingang') }}
  • -
  • - +
  • + +
    {{ __('Ausgang / Ausschuss') }}
    +
    +
  • +
  • + +
    {{ __('Produktion') }}
    +
  • @endif @if (Auth::user()->isAdmin()) @@ -268,7 +321,8 @@ 'admin/inventory/delivery-times*', 'admin/inventory/locations*', 'admin/inventory/material-qualities*', - 'admin/inventory/packaging-materials*')) open @endif"> + 'admin/inventory/packaging-materials*', + 'admin/inventory/notices*')) open @endif">
    Einstellungen
    @@ -300,6 +354,12 @@
    {{ __('Verpackungsmaterial') }}
    +
  • + +
    {{ __('Hinweise') }}
    +
    +
  • @endif diff --git a/resources/views/user/order/list.blade.php b/resources/views/user/order/list.blade.php index dda2b02..ff1c21c 100644 --- a/resources/views/user/order/list.blade.php +++ b/resources/views/user/order/list.blade.php @@ -1,72 +1,90 @@ @extends('layouts.layout-2') @section('content') - - - - - @if($for === 'cr') -

    - {{ __('navigation.my_orders') }} / Mein Guthaben aufladen - zurück -
    -

    + @if ($for === 'cr') +

    + {{ __('navigation.my_orders') }} / Mein Guthaben aufladen + zurück +
    +

    @else -

    - {{ __('navigation.my_orders') }} / {{ __('navigation.do_order') }} - zurück -
    -

    - @if($user->user_level) -

    Die Produktpreise werden entsprechend Deiner Rolle: {{$user->user_level->name}} angezeigt.
    +

    + {{ __('navigation.my_orders') }} / {{ __('navigation.do_order') }} + zurück +
    +

    + @if ($user->user_level) +

    Die Produktpreise werden entsprechend Deiner Rolle: {{ $user->user_level->name }} + angezeigt.
    Hinweis: Wenn Du den Warenkorb verlässt, gehen alle Einstellungen verloren.

    @else

    Hinweis: Dir wurde noch keine Rolle zugewisen. Bitte wende dich an serivce@gruene-seele.bio

    @endif @endif - @if($errors->has('switchers-comp-product')) + @if ($errors->has('switchers-comp-product'))
    @@ -86,46 +104,50 @@
    {{ __('payment.ordering_country') }}
    - {{ App\Services\UserService::getOrderInfo('billing_state') }} + {{ App\Services\UserService::getOrderInfo('billing_state') }}
    {{ __('payment.country_of_delivery') }}
    - {{ App\Services\UserService::getOrderInfo('shipping_state') }} + {{ App\Services\UserService::getOrderInfo('shipping_state') }}
    {{ __('payment.VAT') }}
    - {{ App\Services\UserService::getOrderInfo('tax_free') }} + {{ App\Services\UserService::getOrderInfo('tax_free') }}
    {{ __('payment.reverse_charge_procedure') }}
    - {{ App\Services\UserService::getOrderInfo('user_reverse_charge') }} + {{ App\Services\UserService::getOrderInfo('user_reverse_charge') }}
    - {!! __('order.delivery_country_changed_info', ['link'=> route('user_edit')]) !!} -
    - @if($user->user_level) -

    {!! __('order.product_prices_career_level_info', ['user_level_name'=>$user->user_level->getLang('name'), 'user_level_margin'=>$user->user_level->getFormattedMargin()]) !!}

    - @else -

    {{ __('order.no_career_level_info') }}

    - @endif + {!! __('order.delivery_country_changed_info', ['link' => route('user_edit')]) !!} +
    + @if ($user->user_level) +

    {!! __('order.product_prices_career_level_info', [ + 'user_level_name' => $user->user_level->getLang('name'), + 'user_level_margin' => $user->user_level->getFormattedMargin(), + ]) !!}

    + @else +

    {{ __('order.no_career_level_info') }}

    + @endif
    - +
    - - - - - - - - - - - + + + + + + + + + + + @@ -134,30 +156,29 @@ {!! Form::open(['action' => route('user_order_my_payment', [$for, $delivery_id]), 'class' => 'form-horizontal']) !!} - + - @if($for === 'cr') + @if ($for === 'cr') @include('user.order.shipping_credit') @else
    - @if($for === 'ot') -

    Lieferland des Kunden

    - @include('user.order.shipping_ot') - @endif - @if($for === 'me' || $for === 'mp') -

    Mein Lieferland

    - @include('user.order.shipping_me') - @endif + @if ($for === 'ot') +

    Lieferland des Kunden

    + @include('user.order.shipping_ot') + @endif + @if ($for === 'me' || $for === 'mp') +

    Mein Lieferland

    + @include('user.order.shipping_me') + @endif
    @endif - @if($comp_products) + @if ($comp_products)
    @include('user.order.comp_product')
    - @endif
    @@ -169,13 +190,14 @@
    {!! Form::close() !!} - +
    {{__('Bild')}}{{__('Produkt')}}{{__('Kategorie')}}{{__('Preis netto')}}{{__('Preis brutto')}}{{__('Provision')}}{{__('Gewicht')}}{{__('Inhalt (ml)')}}{{__('Artikelnummer')}}
    {{ __('Bild') }}{{ __('Produkt') }}{{ __('Kategorie') }}{{ __('Preis netto') }}{{ __('Preis brutto') }}{{ __('Provision') }}{{ __('Gewicht') }}{{ __('Inhalt (ml)') }}{{ __('Artikelnummer') }}