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 <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-06-03 11:04:22 +00:00
parent 78679e0c55
commit 3ee2d756e9
63 changed files with 5968 additions and 901 deletions

View file

@ -6,13 +6,6 @@
"artisan", "artisan",
"boost:mcp" "boost:mcp"
] ]
},
"sequential-thinking": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sequential-thinking"
]
} }
} }
} }

View file

@ -30,16 +30,6 @@
}, },
"mounts": [ "mounts": [
"source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached", "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", "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"
}
}
} }

View file

@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Illuminate\View\View;
class NoticeController extends Controller
{
public function index(): View
{
$path = resource_path('docs/hinweise.md');
$markdown = File::exists($path) ? File::get($path) : __('Noch keine Hinweise hinterlegt.');
return view('admin.inventory.notices.index', [
'content' => Str::markdown($markdown),
]);
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use Illuminate\View\View;
class ProductDevelopmentController extends Controller
{
public function index(): View
{
return view('admin.inventory.product-development.index');
}
}

View file

@ -0,0 +1,142 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Http\Requests\Inventory\StoreProductStockMovementRequest;
use App\Models\Product;
use App\Models\ProductStockMovement;
use App\Services\ProductStockService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProductStockController extends Controller
{
public function __construct(
protected ProductStockService $productStockService,
) {}
public function index(): View
{
$products = Product::query()
->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<int, string>
*/
protected function manualReasons(): array
{
return [
__('Initialbestand'),
__('Korrektur'),
__('Inventur'),
__('Retoure'),
__('Testervergabe'),
__('Verlust / Bruch'),
__('Sonstiges'),
];
}
/**
* @return array<int, int>
*/
protected function yearOptions(): array
{
$current = (int) now()->year;
return range($current, $current - 5);
}
}

View file

@ -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') $defaultLocationId = Location::query()->where('name', 'like', '%öln%')->value('id')
?? Location::query()->where('active', true)->first()?->id; ?? Location::query()->where('active', true)->first()?->id;
$defaultProductId = (int) $request->query('product_id') ?: null;
return view('admin.inventory.productions.create', [ 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(), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId, 'defaultLocationId' => $defaultLocationId,
'defaultProductId' => $defaultProductId,
'model' => null, 'model' => null,
]); ]);
} }
@ -99,7 +102,8 @@ class ProductionController extends Controller
return view('admin.inventory.productions.edit', [ return view('admin.inventory.productions.edit', [
'model' => $production, '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) ->orWhere('id', $production->product_id)
->orderBy('name')->get(['id', 'name']), ->orderBy('name')->get(['id', 'name']),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
@ -148,7 +152,7 @@ class ProductionController extends Controller
return view('admin.inventory.productions.create', [ return view('admin.inventory.productions.create', [
'model' => $production, '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(), 'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId, 'defaultLocationId' => $defaultLocationId,
]); ]);
@ -158,12 +162,13 @@ class ProductionController extends Controller
{ {
$locationId = (int) $request->query('location_id', 0); $locationId = (int) $request->query('location_id', 0);
$quantity = (int) $request->query('quantity', 1); $quantity = (int) $request->query('quantity', 1);
$excludeProductionId = (int) $request->query('exclude_production', 0) ?: null;
if ($locationId < 1) { if ($locationId < 1) {
return response()->json(['message' => __('location_id erforderlich')], 422); return response()->json(['message' => __('location_id erforderlich')], 422);
} }
return response()->json( return response()->json(
$this->productionService->buildRecipePayload($product, $locationId, max(1, $quantity)) $this->productionService->buildRecipePayload($product, $locationId, max(1, $quantity), $excludeProductionId)
); );
} }
} }

View file

@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\StockEntry;
use App\Models\TaxRate;
use App\Services\InventoryService;
use App\Services\ProductionService;
use App\Services\ProductStockService;
use Illuminate\View\View;
class RawMaterialStockController extends Controller
{
public function __construct(
protected InventoryService $inventoryService,
protected ProductionService $productionService,
protected ProductStockService $productStockService,
) {}
public function index(): View
{
$ingredients = Ingredient::query()
->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<int, float> [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<int, string> [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)'),
];
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Http\Requests\Inventory\StoreStockDisposalRequest;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use App\Services\InventoryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class StockDisposalController extends Controller
{
public function __construct(
protected InventoryService $inventoryService,
) {}
public function index(Request $request): View
{
$query = StockDisposal::query()
->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<int, string>
*/
protected function reasons(): array
{
return [
__('Bruch / Beschädigung'),
__('Verfall / MHD überschritten'),
__('Qualitätsmangel'),
__('Schwund / Inventurdifferenz'),
__('Muster / Testverbrauch'),
__('Sonstiges'),
];
}
}

View file

@ -32,17 +32,29 @@ class StockEntryController extends Controller
])); ]));
} }
public function create(): View|RedirectResponse public function create(Request $request): View|RedirectResponse
{ {
if (! auth()->user()->isAdmin()) { if (! auth()->user()->isAdmin()) {
return redirect()->route('home'); return redirect()->route('home');
} }
return view('admin.inventory.stock-entries.create', array_merge($this->formSharedData(), [ $model = new StockEntry([
'model' => new StockEntry([
'ordered_at' => now()->toDateString(), 'ordered_at' => now()->toDateString(),
'entry_type' => 'ingredient', 'entry_type' => 'ingredient',
]), ]);
$ingredientId = (int) $request->query('ingredient_id');
if ($ingredientId > 0) {
$ingredient = Ingredient::query()->find($ingredientId);
if ($ingredient) {
$model->entry_type = 'ingredient';
$model->ingredient_id = $ingredient->id;
$model->setRelation('ingredient', $ingredient);
}
}
return view('admin.inventory.stock-entries.create', array_merge($this->formSharedData(), [
'model' => $model,
])); ]));
} }

View file

@ -9,6 +9,7 @@ use App\Models\Product;
use App\Models\ProductImage; use App\Models\ProductImage;
use App\Models\ProductIngredient; use App\Models\ProductIngredient;
use App\Repositories\ProductRepository; use App\Repositories\ProductRepository;
use Illuminate\Database\Eloquent\Collection;
use Request; use Request;
use Validator; use Validator;
@ -47,7 +48,7 @@ class ProductController extends Controller
$model->active = true; $model->active = true;
} else { } else {
$model = Product::findOrFail($id); $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(); $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, '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']), '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(), 'packaging_catalog' => PackagingItem::query()->where('active', true)->with('packagingMaterial')->orderBy('name')->get(),
'set_product_catalog' => $this->setProductCatalog($model),
]; ];
return view('admin.product.edit', $data); return view('admin.product.edit', $data);
} }
/**
* Auswählbare Set-Bestandteile: aktive Einzelprodukte (keine Sets, nicht das Produkt selbst).
*
* @return Collection<int, Product>
*/
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() public function store()
{ {
@ -87,15 +104,18 @@ class ProductController extends Controller
$model = new Product; $model = new Product;
} else { } else {
$model = Product::findOrFail($data['id']); $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(); $country_for_prices = Country::where('own_eur', '=', true)->orWhere('currency', '=', true)->get();
$this->validateSetItems($validator, Request::all(), $model);
$data = [ $data = [
'product' => $model, 'product' => $model,
'country_for_prices' => $country_for_prices, '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']), '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(), 'packaging_catalog' => PackagingItem::query()->where('active', true)->with('packagingMaterial')->orderBy('name')->get(),
'set_product_catalog' => $this->setProductCatalog($model),
]; ];
if ($validator->fails()) { 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<string, mixed> $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) public function copy($id)
{ {
$model = Product::findOrFail($id); $model = Product::findOrFail($id);

View file

@ -1,30 +1,28 @@
<?php <?php
namespace App\Http\Controllers\User; namespace App\Http\Controllers\User;
use Auth;
use Yard; use App\Http\Controllers\Controller;
use Request;
use App\User;
use Validator;
use App\Services\Shop;
use App\Services\Util;
use App\Models\Product; use App\Models\Product;
use App\Models\UserShop;
use App\Services\Payment;
use App\Models\ProductBuy; use App\Models\ProductBuy;
use App\Models\UserHistory;
use App\Models\ShoppingUser;
use App\Models\ShoppingOrder;
use App\Services\UserService;
use App\Models\ProductCategory; use App\Models\ProductCategory;
use App\Models\Setting;
use App\Models\ShippingCountry; use App\Models\ShippingCountry;
use App\Models\ShoppingInstance; use App\Models\ShoppingInstance;
use App\Http\Controllers\Controller; use App\Models\UserHistory;
use App\Services\MyLog;
use App\Services\Payment;
use App\Services\Shop;
use App\Services\UserService;
use App\Services\Util;
use App\User;
use Auth;
use Request;
use Validator;
use Yard;
class OrderController extends Controller class OrderController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('active.account'); $this->middleware('active.account');
@ -32,7 +30,7 @@ class OrderController extends Controller
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; $shopping_user = null;
$delivery_id = null; $delivery_id = null;
if (strpos($for, 'ot') !== false) { if (strpos($for, 'ot') !== false) {
@ -40,6 +38,7 @@ class OrderController extends Controller
$delivery_id = $shopping_user->id; $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')); \Session()->flash('custom-error', __('validation.custom.shipping_not_found'));
return redirect(route('user_order_my_delivery', [$for, $delivery_id])); return redirect(route('user_order_my_delivery', [$for, $delivery_id]));
} }
} }
@ -49,6 +48,7 @@ class OrderController extends Controller
if (strpos(Request::get('switchers-radio-is-for'), 'ot') !== false) { if (strpos(Request::get('switchers-radio-is-for'), 'ot') !== false) {
$delivery_id = $id; $delivery_id = $id;
} }
return redirect(route('user_order_my_list', [Request::get('switchers-radio-is-for'), $delivery_id])); return redirect(route('user_order_my_list', [Request::get('switchers-radio-is-for'), $delivery_id]));
} }
$data = [ $data = [
@ -58,13 +58,14 @@ class OrderController extends Controller
'for' => $for, 'for' => $for,
'delivery_id' => $delivery_id, 'delivery_id' => $delivery_id,
]; ];
return view('user.order.delivery', $data); 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; $shopping_user = null;
$delivery_id = null; $delivery_id = null;
@ -81,6 +82,7 @@ class OrderController extends Controller
$shipping_country_id = Shop::checkShoppingCountry($for, $id); $shipping_country_id = Shop::checkShoppingCountry($for, $id);
if (! $shipping_country_id) { if (! $shipping_country_id) {
\Session()->flash('custom-error', __('validation.custom.shipping_not_found')); \Session()->flash('custom-error', __('validation.custom.shipping_not_found'));
return redirect(route('user_order_my_delivery', [$for, $delivery_id])); return redirect(route('user_order_my_delivery', [$for, $delivery_id]));
} }
UserService::initUserYard($user, $shipping_country_id, $for); UserService::initUserYard($user, $shipping_country_id, $for);
@ -102,15 +104,17 @@ class OrderController extends Controller
'delivery_id' => $delivery_id, 'delivery_id' => $delivery_id,
'comp_products' => $this->getCompProducts($for), 'comp_products' => $this->getCompProducts($for),
]; ];
return view('user.order.list', $data); return view('user.order.list', $data);
} }
public function payment($for, $id=null){ public function payment($for, $id = null)
{
$data = Request::all(); $data = Request::all();
$user = User::find(Auth::user()->id); $user = User::find(Auth::user()->id);
$rules = array( $rules = [
'shipping_salutation' => 'required', 'shipping_salutation' => 'required',
'shipping_firstname' => 'required', 'shipping_firstname' => 'required',
'shipping_lastname' => 'required', 'shipping_lastname' => 'required',
@ -118,7 +122,7 @@ class OrderController extends Controller
'shipping_zipcode' => 'required', 'shipping_zipcode' => 'required',
'shipping_city' => 'required', 'shipping_city' => 'required',
'shipping_state' => 'required', 'shipping_state' => 'required',
); ];
$validator = Validator::make(Request::all(), $rules); $validator = Validator::make(Request::all(), $rules);
@ -176,15 +180,16 @@ class OrderController extends Controller
// add to DB // add to DB
// $path = route('checkout.checkout_card', ['identifier'=>$identifier]); // $path = route('checkout.checkout_card', ['identifier'=>$identifier]);
UserHistory::create(['user_id' => $user->id, 'action' => 'user_order_payment', 'status' => 1, 'product_id' => null, '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); // $path = str_replace('http', 'https', $path);
// return redirect()->secure($path); // return redirect()->secure($path);
return redirect(route('user_checkout', [$identifier])); 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; $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); $shopping_user = Shop::checkShoppingUser($id, $user);
@ -196,7 +201,7 @@ class OrderController extends Controller
Yard::instance('shopping')->store($identifier); Yard::instance('shopping')->store($identifier);
$data['user_id'] = Auth::user()->id; $data['user_id'] = Auth::user()->id;
$data['shopping_user_id'] = $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')); abort(403, __('msg.shipping_country_was_not_found'));
} }
// must be the same shipping country // must be the same shipping country
@ -205,7 +210,7 @@ class OrderController extends Controller
Yard::instance('shopping')->store($identifier); Yard::instance('shopping')->store($identifier);
$data['user_id'] = Auth::user()->id; $data['user_id'] = Auth::user()->id;
$data['shopping_user_id'] = $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')); abort(403, __('msg.shipping_country_was_not_correctly'));
} }
@ -215,7 +220,7 @@ class OrderController extends Controller
Yard::instance('shopping')->store($identifier); Yard::instance('shopping')->store($identifier);
$data['user_id'] = Auth::user()->id; $data['user_id'] = Auth::user()->id;
$data['shopping_user_id'] = $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')); abort(403, __('msg.shopping_cart_was_shipping_free'));
} }
} }
@ -226,7 +231,7 @@ class OrderController extends Controller
Yard::instance('shopping')->store($identifier); Yard::instance('shopping')->store($identifier);
$data['user_id'] = Auth::user()->id; $data['user_id'] = Auth::user()->id;
$data['shopping_user_id'] = $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')); abort(403, __('msg.shopping_cart_was_not_user_shop'));
} }
} }
@ -239,7 +244,7 @@ class OrderController extends Controller
Yard::instance('shopping')->store($identifier); Yard::instance('shopping')->store($identifier);
$data['user_id'] = Auth::user()->id; $data['user_id'] = Auth::user()->id;
$data['shopping_user_id'] = $id; $data['shopping_user_id'] = $id;
\App\Services\MyLog::writeLog('payment', 'error', 'Yard OT shipping_price is 0 or | 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')); abort(403, __('msg.shipping_cost_cannot_be_0'));
} }
if (Yard::instance('shopping')->getShippingPrice() != $shipping_price->price) { if (Yard::instance('shopping')->getShippingPrice() != $shipping_price->price) {
@ -247,7 +252,7 @@ class OrderController extends Controller
Yard::instance('shopping')->store($identifier); Yard::instance('shopping')->store($identifier);
$data['user_id'] = Auth::user()->id; $data['user_id'] = Auth::user()->id;
$data['shopping_user_id'] = $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 not the same from shipping_price | Yard identifier: '.$identifier, $data);
abort(403, __('msg.shipping_costs_were_not_calculated_correctly')); abort(403, __('msg.shipping_costs_were_not_calculated_correctly'));
} }
} }
@ -258,7 +263,7 @@ class OrderController extends Controller
Yard::instance('shopping')->store($identifier); Yard::instance('shopping')->store($identifier);
$data['user_id'] = Auth::user()->id; $data['user_id'] = Auth::user()->id;
$data['shopping_user_id'] = $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')); 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) {
@ -266,7 +271,7 @@ class OrderController extends Controller
Yard::instance('shopping')->store($identifier); Yard::instance('shopping')->store($identifier);
$data['user_id'] = Auth::user()->id; $data['user_id'] = Auth::user()->id;
$data['shopping_user_id'] = $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')); abort(403, __('msg.shipping_costs_were_not_calculated_correctly'));
} }
@ -275,7 +280,7 @@ class OrderController extends Controller
Yard::instance('shopping')->store($identifier); Yard::instance('shopping')->store($identifier);
$data['user_id'] = Auth::user()->id; $data['user_id'] = Auth::user()->id;
$data['shopping_user_id'] = $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')); abort(403, __('msg.compensation_products_cannot_be_0'));
} }
@ -283,8 +288,8 @@ class OrderController extends Controller
} }
public function datatable()
public function datatable(){ {
$not_show_pids = ProductBuy::getNotShowProductIDs(Auth::user()->id); $not_show_pids = ProductBuy::getNotShowProductIDs(Auth::user()->id);
@ -303,7 +308,6 @@ class OrderController extends Controller
break; break;
} }
foreach ($not_show_pids as $not_show_pid) { foreach ($not_show_pids as $not_show_pid) {
$query->where('id', '!=', $not_show_pid); $query->where('id', '!=', $not_show_pid);
} }
@ -314,7 +318,11 @@ class OrderController extends Controller
$cartItem = Yard::instance('shopping')->getCartItemByProduct($product->id); $cartItem = Yard::instance('shopping')->getCartItemByProduct($product->id);
$qty = isset($cartItem->qty) ? $cartItem->qty : 0; $qty = isset($cartItem->qty) ? $cartItem->qty : 0;
$rowId = isset($cartItem->rowId) ? $cartItem->rowId : ''; $rowId = isset($cartItem->rowId) ? $cartItem->rowId : '';
return '<strong>'.$product->name.'</strong><br><div class="no-line-break input-group-min-w">
if ($product->isOutOfStock()) {
$controls = '<div class="product-stock-hint">'.e($product->outOfStockNotice()).'</div>';
} else {
$controls = '<div class="no-line-break input-group-min-w">
<div class="input-group d-inline-flex w-auto"> <div class="input-group d-inline-flex w-auto">
<span class="input-group-prepend"> <span class="input-group-prepend">
<button type="button" class="btn btn-secondary icon-btn md-btn-extra remove-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">-</button> <button type="button" class="btn btn-secondary icon-btn md-btn-extra remove-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">-</button>
@ -325,6 +333,9 @@ class OrderController extends Controller
</span> </span>
</div> </div>
</div>'; </div>';
}
return '<strong>'.$product->name.'</strong><br>'.$controls;
}) })
/* /*
->addColumn('add_card', function (Product $product) { ->addColumn('add_card', function (Product $product) {
@ -351,7 +362,7 @@ class OrderController extends Controller
})*/ })*/
->addColumn('category', function (Product $product) { ->addColumn('category', function (Product $product) {
$ret = ""; $ret = '';
foreach ($product->categories as $category) { foreach ($product->categories as $category) {
$ret .= '<span><div style="white-space: nowrap">'.$category->category->name.'</div></span>'; $ret .= '<span><div style="white-space: nowrap">'.$category->category->name.'</div></span>';
} }
@ -365,16 +376,17 @@ class OrderController extends Controller
<img class="img-fluid img-extra" alt="" src="'.route('product_image', [$product->images->first()->slug]).'"> <img class="img-fluid img-extra" alt="" src="'.route('product_image', [$product->images->first()->slug]).'">
<div class="text-center"><i class="ion ion-md-eye"></i></div></a>'; <div class="text-center"><i class="ion ion-md-eye"></i></div></a>';
} }
return "";
return '';
}) })
->addColumn('price_net', function (Product $product) { ->addColumn('price_net', function (Product $product) {
return $product->getFormattedPriceWith(true, false). ""; return $product->getFormattedPriceWith(true, false).'€';
}) })
->addColumn('price_gross', function (Product $product) { ->addColumn('price_gross', function (Product $product) {
return $product->getFormattedPriceWith(false, false). ""; return $product->getFormattedPriceWith(false, false).'€';
}) })
->addColumn('price_vk_gross', function (Product $product) { ->addColumn('price_vk_gross', function (Product $product) {
return $product->getFormattedPriceWith(false, false). ""; return $product->getFormattedPriceWith(false, false).'€';
}) })
->addColumn('single_commission', function (Product $product) { ->addColumn('single_commission', function (Product $product) {
return $product->single_commission ? '<span class="badge badge-warning">Handelspanne: '.$product->getFormattedValueCommission().' %</span>' : '<span class="badge badge-primary">Staffelprovision</span> <button class="btn btn-default btn-xs icon-btn md-btn-flat product-tooltip" title="details" data-modal="modal-lg" return $product->single_commission ? '<span class="badge badge-warning">Handelspanne: '.$product->getFormattedValueCommission().' %</span>' : '<span class="badge badge-primary">Staffelprovision</span> <button class="btn btn-default btn-xs icon-btn md-btn-flat product-tooltip" title="details" data-modal="modal-lg"
@ -387,7 +399,7 @@ class OrderController extends Controller
data-action="user-order-show-product" data-view="customer"><i class="ion ion-md-eye"></i></button>'; data-action="user-order-show-product" data-view="customer"><i class="ion ion-md-eye"></i></button>';
}) })
->filterColumn('product', function ($query, $keyword) { ->filterColumn('product', function ($query, $keyword) {
if($keyword != ""){ if ($keyword != '') {
$query->where('name', 'LIKE', '%'.$keyword.'%'); $query->where('name', 'LIKE', '%'.$keyword.'%');
} }
}) })
@ -414,12 +426,12 @@ class OrderController extends Controller
, $order); , $order);
})*/ })*/
->rawColumns(['add_card', 'category', 'product', 'quantity', 'picture', 'action', 'single_commission']) ->rawColumns(['add_card', 'category', 'product', 'quantity', 'picture', 'action', 'single_commission'])
->make(true); ->make(true);
} }
public function performRequest(){ public function performRequest()
{
if (Request::ajax()) { if (Request::ajax()) {
@ -429,7 +441,7 @@ class OrderController extends Controller
if ($data['action'] === 'updateCart' && isset($data['product_id'])) { if ($data['action'] === 'updateCart' && isset($data['product_id'])) {
if ($product = Product::find($data['product_id'])) { if ($product = Product::find($data['product_id'])) {
$image = ""; $image = '';
if ($product->images->count()) { if ($product->images->count()) {
$image = $product->images->first()->slug; $image = $product->images->first()->slug;
} }
@ -457,7 +469,6 @@ class OrderController extends Controller
// Yard::setTax($cartItem->rowId, $product->getTaxWith(Yard::instance('shopping')->getUserCountry())); // 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']); Yard::instance('shopping')->update($cartItem->rowId, $data['qty']);
} else { } else {
@ -467,9 +478,8 @@ class OrderController extends Controller
// //
Yard::instance('shopping')->reCalculate(); Yard::instance('shopping')->reCalculate();
$this->checkCompProduct(Yard::instance('shopping')->getNumComp()); $this->checkCompProduct(Yard::instance('shopping')->getNumComp());
$html_card = view("user.order.yard_view_form", $data)->render(); $html_card = view('user.order.yard_view_form', $data)->render();
$html_comp = view("user.order.comp_product", $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]);
} }
@ -479,13 +489,15 @@ class OrderController extends Controller
$data['reduce_payment_credit'] = $data['reduce_payment_credit'] == 'true' ? true : false; $data['reduce_payment_credit'] = $data['reduce_payment_credit'] == 'true' ? true : false;
Yard::instance('shopping')->setReducePaymentCredit($data['reduce_payment_credit']); Yard::instance('shopping')->setReducePaymentCredit($data['reduce_payment_credit']);
Yard::instance('shopping')->reCalculate(); Yard::instance('shopping')->reCalculate();
$html_card = view("user.order.yard_view_form", $data)->render(); $html_card = view('user.order.yard_view_form', $data)->render();
$html_comp = view("user.order.comp_product", $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'] === 'clearCart') { if ($data['action'] === 'clearCart') {
Yard::instance('shopping')->destroy(); 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' => '']);
} }
@ -496,8 +508,9 @@ class OrderController extends Controller
$this->checkCompProduct(Yard::instance('shopping')->getNumComp()); $this->checkCompProduct(Yard::instance('shopping')->getNumComp());
} }
} }
$html_card = view("user.order.yard_view_form", $data)->render(); $html_card = view('user.order.yard_view_form', $data)->render();
$html_comp = view("user.order.comp_product", $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'] === 'updateCompProduct') { if ($data['action'] === 'updateCompProduct') {
@ -506,17 +519,19 @@ class OrderController extends Controller
// count_comp_products // count_comp_products
$this->updateCompProduct($data); $this->updateCompProduct($data);
Yard::instance('shopping')->reCalculateShippingPrice(); Yard::instance('shopping')->reCalculateShippingPrice();
$html_card = view("user.order.yard_view_form", $data)->render(); $html_card = view('user.order.yard_view_form', $data)->render();
$html_comp = view("user.order.comp_product", $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) { foreach (Yard::instance('shopping')->content() as $row) {
// wenn gleich löschen, da neue Versandkosten // wenn gleich löschen, da neue Versandkosten
if ($row->options->comp > $count_comp_products) { if ($row->options->comp > $count_comp_products) {
@ -524,7 +539,9 @@ class OrderController extends Controller
} }
} }
} }
private function updateCompProduct($data){
private function updateCompProduct($data)
{
// clear old // clear old
foreach (Yard::instance('shopping')->content() as $row) { foreach (Yard::instance('shopping')->content() as $row) {
// wenn kleiner wurde ein produkt entfernt aufgrund der Anzahl // wenn kleiner wurde ein produkt entfernt aufgrund der Anzahl
@ -538,7 +555,7 @@ class OrderController extends Controller
if (isset($data['comp_product_id'])) { if (isset($data['comp_product_id'])) {
if ($product = Product::find($data['comp_product_id'])) { if ($product = Product::find($data['comp_product_id'])) {
$image = ""; $image = '';
if ($product->images->count()) { if ($product->images->count()) {
$image = $product->images->first()->slug; $image = $product->images->first()->slug;
} }
@ -552,15 +569,16 @@ class OrderController extends Controller
'value_commission' => 0, 'value_commission' => 0,
'partner_commission' => 0, 'partner_commission' => 0,
'comp' => $data['comp_num'], 'comp' => $data['comp_num'],
'product_id' => $product->id 'product_id' => $product->id,
]); ]);
Yard::setTax($cartItem->rowId, 0); Yard::setTax($cartItem->rowId, 0);
} }
} }
} }
private function getCompProducts($for) { private function getCompProducts($for)
if($for === 'me' && \App\Models\Setting::getContentBySlug('order_partner_is_comp_me')) { {
if ($for === 'me' && Setting::getContentBySlug('order_partner_is_comp_me')) {
return Product::whereActive(true) return Product::whereActive(true)
->where(function ($query) { ->where(function ($query) {
$query->whereRaw("JSON_CONTAINS(show_on, '\"2\"')") $query->whereRaw("JSON_CONTAINS(show_on, '\"2\"')")
@ -571,7 +589,7 @@ class OrderController extends Controller
->get(); ->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) return Product::whereActive(true)
->where(function ($query) { ->where(function ($query) {
$query->whereRaw("JSON_CONTAINS(show_on, '\"1\"')") $query->whereRaw("JSON_CONTAINS(show_on, '\"1\"')")
@ -584,6 +602,4 @@ class OrderController extends Controller
return null; return null;
} }
} }

View file

@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Inventory;
use Illuminate\Foundation\Http\FormRequest;
class StoreProductStockMovementRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user()?->isAdmin();
}
/**
* @return array<string, mixed>
*/
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<string, string>
*/
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.'),
];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests\Inventory; namespace App\Http\Requests\Inventory;
use App\Models\Product;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class StoreProductionRequest extends FormRequest class StoreProductionRequest extends FormRequest
@ -16,19 +17,28 @@ class StoreProductionRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
$recipeRequired = $this->recipeRequired();
return [ return [
'product_id' => ['required', 'integer', 'exists:products,id'], 'product_id' => ['required', 'integer', 'exists:products,id'],
'location_id' => ['required', 'integer', 'exists:locations,id'], 'location_id' => ['required', 'integer', 'exists:locations,id'],
'produced_at' => ['required', 'date'], 'produced_at' => ['required', 'date'],
'quantity' => ['required', 'integer', 'min:1'], 'quantity' => ['required', 'integer', 'min:1'],
'notes' => ['nullable', 'string', 'max:2000'], '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.*.ingredient_id' => ['required', 'integer', 'exists:ingredients,id'],
'ingredient_lines.*.stock_entry_id' => ['required', 'integer', 'exists:stock_entries,id'], 'ingredient_lines.*.stock_entry_id' => ['required', 'integer', 'exists:stock_entries,id'],
'ingredient_lines.*.quantity_used' => ['required', 'string'], '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<string, mixed> * @return array<string, mixed>
*/ */
@ -48,7 +58,7 @@ class StoreProductionRequest extends FormRequest
'stock_entry_id' => (int) $line['stock_entry_id'], 'stock_entry_id' => (int) $line['stock_entry_id'],
'quantity_used' => $line['quantity_used'], 'quantity_used' => $line['quantity_used'],
]; ];
}, $data['ingredient_lines']), }, $data['ingredient_lines'] ?? []),
]; ];
} }
} }

View file

@ -0,0 +1,95 @@
<?php
namespace App\Http\Requests\Inventory;
use Carbon\Carbon;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreStockDisposalRequest extends FormRequest
{
public function authorize(): bool
{
return (bool) $this->user()?->isAdmin();
}
/**
* @return array<string, array<int, mixed>>
*/
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<string, string>
*/
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<string, mixed>
*/
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;
}
}

View file

@ -5,8 +5,10 @@ namespace App\Models;
use App\Services\Type; use App\Services\Type;
use App\Services\Util; use App\Services\Util;
use Cviebrock\EloquentSluggable\Sluggable; use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@ -200,6 +202,14 @@ class Product extends Model
'max_buy' => 'bool', 'max_buy' => 'bool',
'max_buy_num' => 'int', 'max_buy_num' => 'int',
'whitelabel' => 'bool', '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; use Sluggable;
@ -247,6 +257,14 @@ class Product extends Model
'max_buy_num', 'max_buy_num',
'shelf_life_type', 'shelf_life_type',
'shelf_life_months', '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); return $this->hasMany(Production::class);
} }
/**
* Produktbestands-Bewegungen (Eingang/Ausgang).
*
* @return HasMany<ProductStockMovement, $this>
*/
public function stockMovements(): HasMany
{
return $this->hasMany(ProductStockMovement::class);
}
/**
* Set-Bestandteile (Einzelprodukte) dieses Sets mit Menge und Reihenfolge.
*
* @return BelongsToMany<Product, $this>
*/
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<Product, $this>
*/
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<Product, $this>
*/
public function mainProduct(): BelongsTo
{
return $this->belongsTo(Product::class, 'main_product_id');
}
/**
* Untergeordnete Varianten, die auf dieses Produkt als Hauptprodukt zeigen.
*
* @return HasMany<Product, $this>
*/
public function variants(): HasMany
{
return $this->hasMany(Product::class, 'main_product_id');
}
/**
* Nur Einzelprodukte (keine Sets).
*
* @param Builder<Product> $query
* @return Builder<Product>
*/
public function scopeSingleProducts(Builder $query): Builder
{
return $query->where('is_set', false);
}
/**
* Sets.
*
* @param Builder<Product> $query
* @return Builder<Product>
*/
public function scopeSets(Builder $query): Builder
{
return $query->where('is_set', true);
}
/**
* Haupt-/Einzelprodukte, die keinem übergeordneten Hauptprodukt zugeordnet sind.
*
* @param Builder<Product> $query
* @return Builder<Product>
*/
public function scopeMainProducts(Builder $query): Builder
{
return $query->whereNull('main_product_id');
}
public function getShortCopy($clean = false, $len = false) public function getShortCopy($clean = false, $len = false)
{ {
$ret = $this->short_copy ? $this->short_copy : $this->description; $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']) : ''; 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() public function getFormattedPriceEk()
{ {
return isset($this->attributes['price_ek']) ? Util::formatNumber($this->attributes['price_ek']) : ''; return isset($this->attributes['price_ek']) ? Util::formatNumber($this->attributes['price_ek']) : '';

View file

@ -0,0 +1,70 @@
<?php
namespace App\Models;
use App\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ProductStockMovement extends Model
{
protected $fillable = [
'product_id',
'direction',
'quantity',
'reason',
'source',
'note',
'user_id',
'reference_type',
'reference_id',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'quantity' => 'integer',
];
}
/**
* @return BelongsTo<Product, $this>
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return MorphTo<Model, $this>
*/
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;
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace App\Models;
use App\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StockDisposal extends Model
{
protected $fillable = [
'disposal_type',
'ingredient_id',
'packaging_item_id',
'stock_entry_id',
'location_id',
'quantity',
'unit',
'reason',
'note',
'user_id',
'disposed_at',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'disposed_at' => 'date',
'quantity' => 'decimal:2',
];
}
/**
* @return BelongsTo<Ingredient, $this>
*/
public function ingredient(): BelongsTo
{
return $this->belongsTo(Ingredient::class);
}
/**
* @return BelongsTo<PackagingItem, $this>
*/
public function packagingItem(): BelongsTo
{
return $this->belongsTo(PackagingItem::class);
}
/**
* @return BelongsTo<StockEntry, $this>
*/
public function stockEntry(): BelongsTo
{
return $this->belongsTo(StockEntry::class);
}
/**
* @return BelongsTo<Location, $this>
*/
public function location(): BelongsTo
{
return $this->belongsTo(Location::class);
}
/**
* @return BelongsTo<User, $this>
*/
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 ?? '—');
}
}

View file

@ -2,9 +2,12 @@
namespace App\Providers; namespace App\Providers;
use App\Services\InventoryService;
use App\Services\ProductStockService;
use Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider; use Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@ -18,6 +21,18 @@ class AppServiceProvider extends ServiceProvider
{ {
Schema::defaultStringLength(191); Schema::defaultStringLength(191);
URL::forceScheme('https'); 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);
});
} }
/** /**

View file

@ -33,8 +33,39 @@ class ProductRepository extends BaseRepository
$data['whitelabel'] = isset($data['whitelabel']) ? 1 : 0; $data['whitelabel'] = isset($data['whitelabel']) ? 1 : 0;
$data['shipping_addon'] = isset($data['shipping_addon']) ? 1 : 0; $data['shipping_addon'] = isset($data['shipping_addon']) ? 1 : 0;
$data['max_buy'] = isset($data['max_buy']) ? 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['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 (array_key_exists('shelf_life_type', $data)) {
if ($data['shelf_life_type'] === '' || $data['shelf_life_type'] === null) { if ($data['shelf_life_type'] === '' || $data['shelf_life_type'] === null) {
$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->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->updateWLImageAttributs(isset($data['image_wl_attributes']) ? $data['image_wl_attributes'] : [], isset($data['whitelabel_variants']) ? $data['whitelabel_variants'] : []);
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->updateIngredients($data);
$this->updateManufacturerIngredients($data); $this->updateManufacturerIngredients($data);
$this->updatePackagings($data); $this->updatePackagings($data);
}
$this->updateCountryPrices($data); $this->updateCountryPrices($data);
return $this->model; 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 public function updatePackagings(array $data = []): bool
{ {
if (! isset($data['pp_packaging_item_id']) || ! is_array($data['pp_packaging_item_id'])) { 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); $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) { foreach ($model->country_prices as $cp) {
CountryPrice::create([ CountryPrice::create([
'country_id' => $cp->country_id, 'country_id' => $cp->country_id,

View file

@ -0,0 +1,309 @@
<?php
namespace App\Services;
use App\Models\Ingredient;
use App\Models\ProductionIngredient;
use App\Models\ProductionPackaging;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use Carbon\Carbon;
class InventoryService
{
/**
* Lookback-Fenster (Tage) für die Berechnung des durchschnittlichen Tagesverbrauchs
* aus der Produktionshistorie.
*/
public const CONSUMPTION_WINDOW_DAYS = 90;
/**
* Standard-Lieferzeit (Tage), falls weder am Rohstoff noch am Lieferanten ein Wert gepflegt ist.
*/
public const DEFAULT_LEAD_DAYS = 14;
/**
* Gesamter Restbestand je Rohstoff in Gramm (nur eingegangene Chargen, abzüglich Produktionsverbrauch).
*
* @param array<int, int>|null $ingredientIds Optionaler Filter; null = alle.
* @return array<int, float> [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<int, int>|null $ingredientIds
* @return array<int, float> [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<int, float> [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<int, int>|null $packagingItemIds
* @return array<int, float> [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<int, int>|null $packagingItemIds
* @return array<int, float> [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<int, int>|null $ingredientIds
* @return array<int, float> [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<int, int>|null $ingredientIds
* @return array<int, float> [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();
}
}

View file

@ -0,0 +1,146 @@
<?php
namespace App\Services;
use App\Models\Product;
use App\Models\Production;
use App\Models\ProductStockMovement;
use Illuminate\Database\Eloquent\Model;
class ProductStockService
{
/**
* Aktueller Bestand je Produkt (Summe Eingänge Summe Ausgänge).
*
* @param array<int, int>|null $productIds
* @return array<int, int> [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();
}
}

View file

@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\Product; use App\Models\Product;
use App\Models\Production; use App\Models\Production;
use App\Models\ProductionIngredient;
use App\Models\StockEntry; use App\Models\StockEntry;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -12,6 +13,10 @@ use Illuminate\Validation\ValidationException;
class ProductionService class ProductionService
{ {
public function __construct(
protected ProductStockService $productStockService,
) {}
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
* @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used: float|int|string}> $ingredientLines * @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used: float|int|string}> $ingredientLines
@ -20,7 +25,7 @@ class ProductionService
{ {
return DB::transaction(function () use ($data, $ingredientLines, $userId) { return DB::transaction(function () use ($data, $ingredientLines, $userId) {
$product = Product::query() $product = Product::query()
->with(['p_ingredients', 'packagings']) ->with(['manufacturer_ingredients', 'packagings'])
->findOrFail($data['product_id']); ->findOrFail($data['product_id']);
$locationId = (int) $data['location_id']; $locationId = (int) $data['location_id'];
@ -29,12 +34,146 @@ class ProductionService
throw ValidationException::withMessages(['quantity' => __('Die Stückzahl muss mindestens 1 sein.')]); throw ValidationException::withMessages(['quantity' => __('Die Stückzahl muss mindestens 1 sein.')]);
} }
if ($product->p_ingredients->isEmpty()) { $this->assertNotASet($product);
throw ValidationException::withMessages(['product_id' => __('Das Produkt hat keine Rezeptur (Inhaltsstoffe).')]);
} if ($product->no_recipe_required) {
$ingredientLines = [];
} else {
$this->assertManufacturerRecipe($product);
$requiredGrams = $this->requiredGramsByIngredient($product, $producedQty); $requiredGrams = $this->requiredGramsByIngredient($product, $producedQty);
$this->assertLinesMatchRecipe($requiredGrams, $ingredientLines);
foreach ($ingredientLines as $line) {
$this->assertStockEntryMatchesLine($line, $locationId);
}
}
$mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines);
$production = Production::query()->create([
'product_id' => $product->id,
'location_id' => $locationId,
'produced_by' => $userId,
'produced_at' => $data['produced_at'],
'quantity' => $producedQty,
'notes' => $data['notes'] ?? null,
'mhd_warning' => $mhdWarning,
]);
foreach ($ingredientLines as $line) {
$production->productionIngredients()->create([
'ingredient_id' => (int) $line['ingredient_id'],
'stock_entry_id' => (int) $line['stock_entry_id'],
'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null),
]);
}
$this->syncPackagingSnapshot($production, $product, $producedQty);
$production->setRelation('product', $product);
$this->productStockService->recordProductionStock($production, $userId);
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']);
});
}
/**
* @param array<string, mixed> $data
* @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used: float|int|string}> $ingredientLines
*/
public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId): Production
{
return DB::transaction(function () use ($production, $data, $ingredientLines, $userId) {
$product = Product::query()
->with(['manufacturer_ingredients', 'packagings'])
->findOrFail($data['product_id']);
$locationId = (int) $data['location_id'];
$producedQty = (int) $data['quantity'];
if ($producedQty < 1) {
throw ValidationException::withMessages(['quantity' => __('Die Stückzahl muss mindestens 1 sein.')]);
}
$this->assertNotASet($product);
if ($product->no_recipe_required) {
$ingredientLines = [];
} else {
$this->assertManufacturerRecipe($product);
$requiredGrams = $this->requiredGramsByIngredient($product, $producedQty);
$this->assertLinesMatchRecipe($requiredGrams, $ingredientLines);
foreach ($ingredientLines as $line) {
$this->assertStockEntryMatchesLine($line, $locationId);
}
}
$mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines);
$production->update([
'product_id' => $product->id,
'location_id' => $locationId,
'produced_at' => $data['produced_at'],
'quantity' => $producedQty,
'notes' => $data['notes'] ?? null,
'mhd_warning' => $mhdWarning,
]);
$production->productionIngredients()->delete();
foreach ($ingredientLines as $line) {
$production->productionIngredients()->create([
'ingredient_id' => (int) $line['ingredient_id'],
'stock_entry_id' => (int) $line['stock_entry_id'],
'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null),
]);
}
$production->productionPackagings()->delete();
$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<int, float> $requiredGrams
* @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used?: mixed}> $ingredientLines
*/
private function assertLinesMatchRecipe(array $requiredGrams, array $ingredientLines): void
{
$sums = []; $sums = [];
foreach ($ingredientLines as $line) { foreach ($ingredientLines as $line) {
$iid = (int) $line['ingredient_id']; $iid = (int) $line['ingredient_id'];
@ -62,115 +201,10 @@ class ProductionService
]); ]);
} }
} }
foreach ($ingredientLines as $line) {
$this->assertStockEntryMatchesLine($line, $locationId);
} }
$mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines); private function syncPackagingSnapshot(Production $production, Product $product, int $producedQty): void
$production = Production::query()->create([
'product_id' => $product->id,
'location_id' => $locationId,
'produced_by' => $userId,
'produced_at' => $data['produced_at'],
'quantity' => $producedQty,
'notes' => $data['notes'] ?? null,
'mhd_warning' => $mhdWarning,
]);
foreach ($ingredientLines as $line) {
$production->productionIngredients()->create([
'ingredient_id' => (int) $line['ingredient_id'],
'stock_entry_id' => (int) $line['stock_entry_id'],
'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null),
]);
}
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 $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']);
});
}
/**
* @param array<string, mixed> $data
* @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used: float|int|string}> $ingredientLines
*/
public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId): Production
{ {
return DB::transaction(function () use ($production, $data, $ingredientLines) {
$product = Product::query()
->with(['p_ingredients', 'packagings'])
->findOrFail($data['product_id']);
$locationId = (int) $data['location_id'];
$producedQty = (int) $data['quantity'];
if ($producedQty < 1) {
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).')]);
}
$requiredGrams = $this->requiredGramsByIngredient($product, $producedQty);
$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 ($ingredientLines as $line) {
$this->assertStockEntryMatchesLine($line, $locationId);
}
$mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines);
$production->update([
'product_id' => $product->id,
'location_id' => $locationId,
'produced_at' => $data['produced_at'],
'quantity' => $producedQty,
'notes' => $data['notes'] ?? null,
'mhd_warning' => $mhdWarning,
]);
$production->productionIngredients()->delete();
foreach ($ingredientLines as $line) {
$production->productionIngredients()->create([
'ingredient_id' => (int) $line['ingredient_id'],
'stock_entry_id' => (int) $line['stock_entry_id'],
'quantity_used' => $this->parseQuantity($line['quantity_used'] ?? null),
]);
}
$production->productionPackagings()->delete();
foreach ($product->packagings as $bom) { foreach ($product->packagings as $bom) {
$perUnit = (float) ($bom->pivot->quantity ?? 1); $perUnit = (float) ($bom->pivot->quantity ?? 1);
$pieces = (int) round($perUnit * $producedQty); $pieces = (int) round($perUnit * $producedQty);
@ -182,9 +216,6 @@ class ProductionService
'quantity_used' => $pieces, 'quantity_used' => $pieces,
]); ]);
} }
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']);
});
} }
/** /**
@ -193,11 +224,11 @@ class ProductionService
public function requiredGramsByIngredient(Product $product, int $producedQuantity): array public function requiredGramsByIngredient(Product $product, int $producedQuantity): array
{ {
$required = []; $required = [];
foreach ($product->p_ingredients as $ing) { foreach ($product->manufacturer_ingredients as $ing) {
$gram = $ing->pivot->gram; $gram = $ing->pivot->gram;
if ($gram === null || $gram === '') { if ($gram === null || $gram === '') {
throw ValidationException::withMessages([ 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); $factor = (float) ($ing->pivot->factor ?? 1.1);
@ -251,50 +282,105 @@ 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<int, int> $stockEntryIds
* @return array<int, float>
*/
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<int, StockEntry> * @return Collection<int, StockEntry>
*/ */
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('status', 'received')
->where('entry_type', 'ingredient') ->where('entry_type', 'ingredient')
->where('ingredient_id', $ingredientId) ->where('ingredient_id', $ingredientId)
->where('location_id', $locationId) ->where('location_id', $locationId)
->orderByDesc('received_at') ->orderByRaw('best_before is null, best_before asc')
->orderByDesc('id') ->orderBy('id')
->limit($limit)
->get(); ->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<string, mixed> * @return array<string, mixed>
*/ */
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); $qty = max(1, $productionQuantity);
$recipeRequired = ! (bool) $product->no_recipe_required;
$hasRecipe = $product->manufacturer_ingredients->isNotEmpty();
$ingredients = []; $ingredients = [];
foreach ($product->p_ingredients as $ing) { foreach ($product->manufacturer_ingredients as $ing) {
$gram = $ing->pivot->gram; $gram = $ing->pivot->gram;
$factor = (float) ($ing->pivot->factor ?? 1.1); $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[] = [ $ingredients[] = [
'id' => $ing->id, 'id' => $ing->id,
'name' => $ing->name, 'name' => $ing->name,
'gram' => $gram !== null && $gram !== '' ? (float) $gram : null, 'gram' => $hasGram ? (float) $gram : null,
'factor' => $factor, 'factor' => $factor,
'required_grams_total' => $req, 'required_grams_total' => $req,
'stock_entries' => $this->latestStockEntriesForIngredient((int) $ing->id, $locationId)->map(function (StockEntry $se) { 'stock_entries' => $this->availableStockEntriesForIngredient((int) $ing->id, $locationId, $excludeProductionId)
->map(function (StockEntry $se) {
return [ return [
'id' => $se->id, 'id' => $se->id,
'label' => $this->stockEntryLabel($se),
'batch_number' => $se->batch_number, 'batch_number' => $se->batch_number,
'best_before' => $se->best_before?->format('Y-m-d'), 'best_before' => $se->best_before?->format('Y-m-d'),
'received_at' => $se->received_at?->format('Y-m-d'), 'remaining' => (float) $se->getAttribute('remaining_quantity'),
'received_quantity' => $se->received_quantity !== null ? (float) $se->received_quantity : null,
]; ];
})->values()->all(), })->values()->all(),
]; ];
@ -307,7 +393,7 @@ class ProductionService
'id' => $pk->id, 'id' => $pk->id,
'name' => $pk->name, 'name' => $pk->name,
'quantity_per_product' => $perUnit, '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, 'weight_grams' => $pk->weight_grams !== null ? (float) $pk->weight_grams : null,
'material_name' => $pk->packagingMaterial?->name, 'material_name' => $pk->packagingMaterial?->name,
]; ];
@ -320,8 +406,10 @@ class ProductionService
'shelf_life_type' => $product->shelf_life_type, 'shelf_life_type' => $product->shelf_life_type,
'shelf_life_months' => $product->shelf_life_months, 'shelf_life_months' => $product->shelf_life_months,
], ],
'recipe_required' => $recipeRequired,
'has_recipe' => $hasRecipe,
'location_id' => $locationId, 'location_id' => $locationId,
'production_quantity' => $productionQuantity, 'production_quantity' => $qty,
'ingredients' => $ingredients, 'ingredients' => $ingredients,
'packagings' => $packagings, 'packagings' => $packagings,
]; ];

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->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']);
});
}
};

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('product_set_items', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->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']);
});
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->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']);
});
}
};

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('product_stock_movements', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('stock_disposals', function (Blueprint $table) {
$table->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');
}
};

View file

@ -21,6 +21,9 @@ Geprüfte Dateien u. a.: `routes/web.php`, `app/Http/Controllers/Admin/Inventory
| Datum | AP | Kurzbeschreibung | Tests | | 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=EingangAusgang, 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=EingangVerbrauch, 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-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-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) | | 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-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-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 | | 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, `<h2>`/„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 `<input type="text" class="form-control datepicker-base">` (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 `<datalist>` (`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 | | 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 `<datalist>` (`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=EingangAusgang, 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. > **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. **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 ### 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). **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 ### 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. **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) ### 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. **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 ### 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. **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) ### AP-12 — Ausgang / Ausschuss (Rohstoffe/Verpackung)
- `stock_disposals` (Typ, Artikel, Charge optional, Lagerort, Menge, Einheit, Grund Pflicht, User, Datum) + Controller/Views; Integration in `InventoryService`. - `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. - **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 ### AP-13 — Shop-Anbindung: Bestand bei Verkauf/Versand reduzieren (Entwicklungskonzept)
- **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). > **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.
- **Akzeptanz:** Versand reduziert Produktbestand; Set-Versand reduziert Einzelprodukte; jede Buchung in der Historie.
#### 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. 421454). 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 #<wp_order_number>"
└─ 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 #<wp_order_number>", `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 ~23 Tage; Szenario B (Webhook/Pull) ~35 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. - **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. > **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) ## 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:** **➡️ 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. 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-14AP-17).
2. **AP-18** Hinweise-Doku (Einstellungen → Hinweise) — kann parallel/früh als Platzhalter angelegt und laufend gepflegt werden. 2. **AP-18** mit jedem weiteren AP fortschreiben (Hinweise-Seite aktuell halten).
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-12AP-17).
--- ---
@ -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`). - 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). - 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=...`). - Vor jedem Commit: `vendor/bin/pint --dirty` und betroffene Tests (`php artisan test --filter=...`).
- **UI-Konvention Datumsfelder:** Datumsfelder in Formularen immer als `<input type="text" class="form-control datepicker-base" value="dd.mm.yyyy">` (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.

124
resources/docs/hinweise.md Normal file
View file

@ -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.

View file

@ -0,0 +1,44 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">{{ __('Hinweise') }}</h4>
<div class="card">
<div class="card-body wawi-notices">
{!! $content !!}
</div>
</div>
@endsection
@section('styles')
<style>
.wawi-notices h1 {
font-size: 1.5rem;
}
.wawi-notices h2 {
font-size: 1.25rem;
margin-top: 1.5rem;
}
.wawi-notices h3 {
font-size: 1.05rem;
margin-top: 1rem;
}
.wawi-notices blockquote {
border-left: 3px solid #d4d8dd;
padding: 0.25rem 0 0.25rem 1rem;
color: #6c757d;
}
.wawi-notices hr {
margin: 1.5rem 0;
}
.wawi-notices ul {
padding-left: 1.25rem;
}
</style>
@endsection

View file

@ -0,0 +1,19 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">{{ __('Produktentwicklung') }}</h4>
<div class="card">
<div class="card-body">
<div class="alert alert-info mb-0">
<h6 class="alert-heading font-weight-bold">{{ __('Briefing ausstehend') }}</h6>
<p class="mb-0">
{{ __('Dieser Bereich ist als Platzhalter angelegt. Zur konkreten Funktionsweise der Produktentwicklung steht noch ein genaues Briefing aus.') }}
</p>
<p class="mb-0 mt-2 text-muted small">
{{ __('Es findet hier aktuell keine Bestandsbuchung und keine Verarbeitungslogik statt.') }}
</p>
</div>
</div>
</div>
@endsection

View file

@ -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')
<div class="card">
<h6 class="card-header">{{ __('Produktbestand Historie') }}</h6>
<div class="card-body pb-0">
<form method="get" action="{{ route('admin.inventory.product-stock.history') }}">
<div class="form-row">
<div class="form-group col-md-3">
<label class="small text-muted">{{ __('Produkt') }}</label>
<select name="product_id" class="form-control" onchange="this.form.submit()">
<option value="">{{ __('Filter aus') }}</option>
@foreach ($products as $p)
<option value="{{ $p->id }}" @selected($filters['product_id'] === $p->id)>{{ $p->name }}</option>
@endforeach
</select>
</div>
<div class="form-group col-md-2">
<label class="small text-muted">{{ __('Eingang / Ausgang') }}</label>
<select name="direction" class="form-control" onchange="this.form.submit()">
<option value="">{{ __('Filter aus') }}</option>
<option value="in" @selected($filters['direction'] === 'in')>{{ __('Eingang') }}</option>
<option value="out" @selected($filters['direction'] === 'out')>{{ __('Ausgang') }}</option>
</select>
</div>
<div class="form-group col-md-3">
<label class="small text-muted">{{ __('Grund') }}</label>
<select name="reason" class="form-control" onchange="this.form.submit()">
<option value="">{{ __('Filter aus') }}</option>
@foreach ($reasonOptions as $reason)
<option value="{{ $reason }}" @selected($filters['reason'] === $reason)>{{ $reason }}</option>
@endforeach
</select>
</div>
<div class="form-group col-md-2">
<label class="small text-muted">{{ __('Monat') }}</label>
<select name="month" class="form-control" onchange="this.form.submit()">
<option value="">{{ __('Alle') }}</option>
@foreach ($months as $num => $label)
<option value="{{ $num }}" @selected($filters['month'] === $num)>{{ $label }}</option>
@endforeach
</select>
</div>
<div class="form-group col-md-2">
<label class="small text-muted">{{ __('Jahr') }}</label>
<select name="year" class="form-control" onchange="this.form.submit()">
<option value="">{{ __('Alle') }}</option>
@foreach ($years as $y)
<option value="{{ $y }}" @selected($filters['year'] === $y)>{{ $y }}</option>
@endforeach
</select>
</div>
</div>
@if ($filters['product_id'] || $filters['direction'] || $filters['reason'] || $filters['month'] || $filters['year'])
<a href="{{ route('admin.inventory.product-stock.history') }}" class="btn btn-sm btn-outline-secondary mb-3">{{ __('Filter zurücksetzen') }}</a>
@endif
</form>
</div>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Produkt / Material') }}</th>
<th>{{ __('Art') }}</th>
<th class="text-right">{{ __('Stückzahl') }}</th>
<th>{{ __('Datum') }}</th>
<th>{{ __('Grund') }}</th>
<th>{{ __('Hinweis') }}</th>
</tr>
</thead>
<tbody>
@forelse ($movements as $movement)
<tr>
<td>{{ $movement->product?->name ?? '—' }}</td>
<td>
@if ($movement->isIn())
<span class="text-success">{{ __('Eingang') }}</span>
@else
<span class="text-danger">{{ __('Ausgang') }}</span>
@endif
</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($movement->quantity, 0) }} {{ __('Stück') }}</td>
<td>{{ $movement->created_at?->translatedFormat('l, d.m.Y') }}</td>
<td>{{ $movement->reason ?: '—' }}</td>
<td class="text-muted">
{{ $movement->note }}
@if ($movement->user)
<span class="d-block small">{{ __('Mitarbeiter') }}: {{ $movement->user->getFullName(false) ?: $movement->user->email }}</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center text-muted py-4">{{ __('Keine Bewegungen gefunden.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endsection

View file

@ -0,0 +1,144 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.table-actions-style')
<div class="card">
<div class="card-header">
<div class="d-flex flex-wrap justify-content-between align-items-center">
<h6 class="mb-0">{{ __('Produktbestand') }}</h6>
<label class="form-check d-inline-flex align-items-center mb-0">
<input type="checkbox" id="ps-only-critical" class="form-check-input mr-2">
<span>{{ __('nur kritische anzeigen') }}</span>
</label>
</div>
<div class="form-group row mt-3 mb-0 align-items-center">
<label for="ps-search" class="col-sm-1 col-form-label">{{ __('Suchen') }}</label>
<div class="col-sm-5">
<input type="text" id="ps-search" class="form-control" autocomplete="off">
</div>
</div>
</div>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0" id="ps-table">
<thead>
<tr>
<th style="width:4rem"></th>
<th>{{ __('Name') }}</th>
<th class="text-right">{{ __('Bestand') }}</th>
<th style="width:18rem">{{ __('Aktion') }}</th>
</tr>
</thead>
<tbody>
@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
<tr class="ps-row {{ $rowClass }}" data-name="{{ Str::lower($product->name) }} {{ Str::lower($product->number ?? '') }}" data-status="{{ $row['status'] }}">
<td>
@if ($product->images->count())
<img class="img-fluid" alt="" style="max-height: 42px"
src="{{ route('product_image', [$product->images->first()->slug]) }}">
@endif
</td>
<td>{{ $product->name }}</td>
<td class="text-right {{ $stockClass }}">{{ \App\Services\Util::formatNumber($row['stock'], 0) }} {{ __('Stück') }}</td>
<td>
@if (Auth::user()->isAdmin())
<button type="button" class="btn icon-btn btn-sm btn-success js-ps-move"
data-product="{{ $product->id }}" data-name="{{ $product->name }}" data-direction="in"
title="{{ __('Eingang buchen') }}"><span class="fas fa-plus"></span></button>
<button type="button" class="btn icon-btn btn-sm btn-outline-danger js-ps-move"
data-product="{{ $product->id }}" data-name="{{ $product->name }}" data-direction="out"
title="{{ __('Ausgang buchen') }}"><span class="fas fa-minus"></span></button>
@endif
<a href="{{ route('admin.inventory.productions.create', ['product_id' => $product->id]) }}"
class="btn btn-sm btn-dark">{{ __('Produzieren') }}</a>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-center text-muted py-4">{{ __('Keine Produkte vorhanden.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@if (Auth::user()->isAdmin())
<div class="modal fade" id="ps-move-modal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog" role="document">
<form method="post" id="ps-move-form">
@csrf
<input type="hidden" name="direction" id="ps-move-direction">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ps-move-title">{{ __('Bestandsbewegung') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">
<p class="text-muted mb-3" id="ps-move-product"></p>
<div class="form-group">
<label for="ps-move-quantity">{{ __('Stückzahl') }} <span class="text-danger">*</span></label>
<input type="number" min="1" step="1" name="quantity" id="ps-move-quantity" class="form-control" required>
</div>
<div class="form-group">
<label for="ps-move-reason">{{ __('Grund') }} <span class="text-danger">*</span></label>
<select name="reason" id="ps-move-reason" class="form-control" required>
@foreach ($reasons as $reason)
<option value="{{ $reason }}">{{ $reason }}</option>
@endforeach
</select>
</div>
<div class="form-group mb-0">
<label for="ps-move-note">{{ __('Hinweis') }}</label>
<input type="text" name="note" id="ps-move-note" class="form-control" maxlength="255" autocomplete="off">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">{{ __('Abbrechen') }}</button>
<button type="submit" class="btn btn-primary">{{ __('Buchen') }}</button>
</div>
</div>
</form>
</div>
</div>
@endif
<script>
$(function () {
var $rows = $('#ps-table tbody tr.ps-row');
function applyFilter() {
var term = ($('#ps-search').val() || '').toLowerCase().trim();
var onlyCritical = $('#ps-only-critical').is(':checked');
$rows.each(function () {
var $row = $(this);
var matchesTerm = term === '' || ($row.data('name') || '').toString().indexOf(term) !== -1;
var status = $row.data('status');
var matchesCritical = !onlyCritical || (status === 'critical' || status === 'warning');
$row.toggle(matchesTerm && matchesCritical);
});
}
$('#ps-search').on('keyup', applyFilter);
$('#ps-only-critical').on('change', applyFilter);
var moveTemplate = "{{ route('admin.inventory.product-stock.movement', ['product' => '__ID__']) }}";
$('.js-ps-move').on('click', function () {
var id = $(this).data('product');
var name = $(this).data('name');
var direction = $(this).data('direction');
$('#ps-move-form').attr('action', moveTemplate.replace('__ID__', id));
$('#ps-move-direction').val(direction);
$('#ps-move-product').text(name);
$('#ps-move-title').text(direction === 'in' ? '{{ __('Eingang buchen') }}' : '{{ __('Ausgang buchen') }}');
$('#ps-move-quantity').val('');
$('#ps-move-note').val('');
$('#ps-move-modal').modal('show');
});
});
</script>
@endsection

View file

@ -0,0 +1,71 @@
@php($isEdit = $isEdit ?? false)
<div class="form-row">
<div class="form-group col-12 col-md-6">
<label for="product_id">{{ __('Produkt') }}</label>
<select name="product_id" id="product_id" class="form-control @error('product_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($products as $p)
<option value="{{ $p->id }}" @selected(old('product_id', $model?->product_id ?? ($defaultProductId ?? null)) == $p->id)>{{ $p->name }}</option>
@endforeach
</select>
@error('product_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group col-12 col-md-6">
<label for="location_id">{{ __('Lagerort / Produktionsstandort') }}</label>
<select name="location_id" id="location_id" class="form-control @error('location_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($locations as $loc)
<option value="{{ $loc->id }}" @selected(old('location_id', $model?->location_id ?? $defaultLocationId) == $loc->id)>{{ $loc->name }}</option>
@endforeach
</select>
@error('location_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div class="form-row">
<div class="form-group col-12 col-sm-6">
<label for="produced_at">{{ __('Produktionsdatum') }}</label>
<input type="text" name="produced_at" id="produced_at" autocomplete="off"
class="form-control datepicker-base @error('produced_at') is-invalid @enderror"
value="{{ old('produced_at', $isEdit ? $model?->produced_at?->format('d.m.Y') : now()->format('d.m.Y')) }}" required>
@error('produced_at')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group col-12 col-sm-6">
<label for="quantity">{{ __('Produzierte Stückzahl') }}</label>
<input type="number" name="quantity" id="quantity" min="1" step="1" class="form-control @error('quantity') is-invalid @enderror"
value="{{ old('quantity', $model?->quantity ?? 1) }}" required>
@error('quantity')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div id="recipe-warning" class="alert alert-warning" style="display:none;"></div>
<div id="recipe-area" class="mb-3" style="display:none;">
<hr>
<h6>{{ __('Chargen zuordnen') }}</h6>
<p class="text-muted small" id="recipe-hint"></p>
<div id="recipe-ingredient-lines"></div>
@error('ingredient_lines')
<div class="text-danger small">{{ $message }}</div>
@enderror
<h6 class="mt-3">{{ __('Verpackung (Vorschau)') }}</h6>
<div class="table-responsive border rounded">
<table class="table table-sm mb-0">
<thead><tr><th>{{ __('Artikel') }}</th><th>{{ __('Stück gesamt') }}</th></tr></thead>
<tbody id="recipe-packaging-preview"></tbody>
</table>
</div>
</div>
<div class="form-group">
<label for="notes">{{ __('Notizen') }}</label>
<textarea name="notes" id="notes" class="form-control" rows="2">{{ old('notes', $model?->notes) }}</textarea>
</div>

View file

@ -0,0 +1,213 @@
<script>
(function ($) {
var recipeBase = @json(url('/admin/inventory/api/products'));
var existingLines = @json($existingLines ?? (object) []);
var excludeProductionId = @json($excludeProductionId ?? null);
var lineIndex = 0;
var recipeIngredients = [];
var loadedKey = null;
function currentKey() {
return ($('#product_id').val() || '') + '|' + ($('#location_id').val() || '');
}
function recipeUrl() {
var pid = $('#product_id').val();
var lid = $('#location_id').val();
var qty = $('#quantity').val() || 1;
if (!pid || !lid) {
return null;
}
var url = recipeBase + '/' + pid + '/recipe?location_id=' + encodeURIComponent(lid) + '&quantity=' + encodeURIComponent(qty);
if (excludeProductionId) {
url += '&exclude_production=' + encodeURIComponent(excludeProductionId);
}
return url;
}
function fmt(value) {
return Number(value).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function buildStockSelect(idx, stockEntries, preselectedEntryId) {
var select = $('<select class="form-control form-control-sm" name="ingredient_lines[' + idx + '][stock_entry_id]" required></select>');
select.append('<option value="">{{ __('Charge wählen') }}</option>');
(stockEntries || []).forEach(function (se) {
var label = se.label;
if (se.remaining != null) {
label += ' — {{ __('Rest') }} ' + fmt(se.remaining) + ' g';
}
var opt = $('<option></option>').attr('value', se.id).text(label);
if (preselectedEntryId && String(se.id) === String(preselectedEntryId)) {
opt.prop('selected', true);
}
select.append(opt);
});
return select;
}
function addIngredientRow($wrap, ing, preselectedEntryId, preselectedQty) {
var idx = lineIndex++;
var $row = $('<div class="form-row align-items-center mb-1 recipe-row"></div>');
var $colSelect = $('<div class="col-12 col-sm-7 mb-1"></div>');
$colSelect.append($('<input type="hidden" name="ingredient_lines[' + idx + '][ingredient_id]">').val(ing.id));
$colSelect.append(buildStockSelect(idx, ing.stock_entries, preselectedEntryId));
$row.append($colSelect);
var $colQty = $('<div class="col-12 col-sm-5 mb-1"></div>');
var $group = $('<div class="input-group input-group-sm"></div>');
$group.append($('<input type="text" class="form-control" required placeholder="0" name="ingredient_lines[' + idx + '][quantity_used]">').val(preselectedQty || ''));
$group.append('<div class="input-group-append"><span class="input-group-text">g</span></div>');
$colQty.append($group);
$row.append($colQty);
$wrap.find('.recipe-rows').first().append($row);
}
function recomputeSoll() {
var qty = parseInt($('#quantity').val(), 10) || 1;
$('#recipe-ingredient-lines [data-ingredient-id]').each(function () {
var $wrap = $(this);
var ing = $wrap.data('recipe-ing');
if (!ing || ing.gram == null) {
return;
}
var soll = ing.gram * (ing.factor || 1) * qty;
$wrap.find('.recipe-soll').text(fmt(soll));
});
}
function renderRecipe(data) {
recipeIngredients = data.ingredients || [];
$('#recipe-ingredient-lines').empty();
lineIndex = 0;
if (data.recipe_required === false) {
$('#recipe-warning').hide();
$('#btn-submit-production').prop('disabled', false);
$('#recipe-area').show();
$('#recipe-hint')
.text('{{ __('Dieses Produkt benötigt keine Rezeptur. Es wird ohne Rohstoffverbrauch produziert.') }}')
.removeClass('text-danger').addClass('text-muted');
var $pkOnly = $('#recipe-packaging-preview').empty();
(data.packagings || []).forEach(function (pk) {
$pkOnly.append($('<tr></tr>')
.append($('<td></td>').text(pk.name))
.append($('<td></td>').text(pk.total_pieces)));
});
return;
}
if (data.has_recipe === false) {
$('#recipe-area').hide();
$('#recipe-warning')
.text('{{ __('Für dieses Produkt ist keine Hersteller-Rezeptur gepflegt. Eine Produktion ist erst möglich, wenn eine Hersteller-Rezeptur hinterlegt wurde.') }}')
.show();
$('#btn-submit-production').prop('disabled', true);
return;
}
$('#recipe-warning').hide();
$('#btn-submit-production').prop('disabled', false);
$('#recipe-area').show();
var qty = parseInt($('#quantity').val(), 10) || 1;
var hintParts = [];
var hasMissingGram = false;
recipeIngredients.forEach(function (ing) {
if (ing.gram == null) {
hasMissingGram = true;
hintParts.push(ing.name + ': {{ __('Anteil in der Hersteller-Rezeptur fehlt') }}');
return;
}
var soll = ing.gram * (ing.factor || 1) * qty;
var $wrap = $('<div class="border rounded p-2 mb-2" data-ingredient-id="' + ing.id + '"></div>');
$wrap.data('recipe-ing', ing);
var $head = $('<div class="d-flex justify-content-between flex-wrap"></div>');
$head.append($('<strong></strong>').text(ing.name));
$head.append($('<span class="text-muted small"></span>').html('{{ __('Soll') }}: <span class="recipe-soll">' + fmt(soll) + '</span> g'));
$wrap.append($head);
$wrap.append('<div class="recipe-rows mt-1"></div>');
$wrap.append('<button type="button" class="btn btn-sm btn-outline-secondary btn-add-split mt-1">{{ __('Weitere Charge') }}</button>');
$('#recipe-ingredient-lines').append($wrap);
var existing = existingLines[String(ing.id)] || [];
if (existing.length > 0) {
existing.forEach(function (ex) {
addIngredientRow($wrap, ing, ex.stock_entry_id, ex.quantity_used);
});
} else {
addIngredientRow($wrap, ing);
}
});
if (hasMissingGram) {
$('#recipe-hint').text(hintParts.join(' · ')).removeClass('text-muted').addClass('text-danger');
} else {
$('#recipe-hint')
.text('{{ __('Pro Charge die entnommene Menge in Gramm eintragen. Summe je Inhaltsstoff muss dem Soll entsprechen.') }}')
.removeClass('text-danger').addClass('text-muted');
}
var $pk = $('#recipe-packaging-preview').empty();
(data.packagings || []).forEach(function (pk) {
$pk.append($('<tr></tr>')
.append($('<td></td>').text(pk.name))
.append($('<td></td>').text(pk.total_pieces)));
});
}
$(document).on('click', '.btn-add-split', function (e) {
e.preventDefault();
var $wrap = $(this).closest('[data-ingredient-id]');
var ing = $wrap.data('recipe-ing');
if (!ing) {
return;
}
addIngredientRow($wrap, ing);
});
function loadRecipe() {
var url = recipeUrl();
if (!url) {
$('#recipe-area').hide();
$('#recipe-warning').hide();
return;
}
$('#recipe-warning').hide();
$('#recipe-area').show();
$('#recipe-hint').text('{{ __('Lade ') }}');
$.getJSON(url)
.done(function (data) {
loadedKey = currentKey();
renderRecipe(data);
})
.fail(function () {
$('#recipe-area').show();
$('#recipe-hint').text('{{ __('Rezept konnte nicht geladen werden.') }}').addClass('text-danger');
});
}
$('#product_id, #location_id').on('change', function () {
existingLines = {};
loadRecipe();
});
$('#quantity').on('change input', function () {
if (loadedKey && loadedKey === currentKey() && recipeIngredients.length > 0) {
recomputeSoll();
} else {
loadRecipe();
}
});
$(document).ready(function () {
if ($('#product_id').val() && $('#location_id').val()) {
loadRecipe();
}
});
})(jQuery);
</script>

View file

@ -7,73 +7,7 @@
<div class="card-body"> <div class="card-body">
<form method="post" action="{{ route('admin.inventory.productions.store') }}" id="form-production"> <form method="post" action="{{ route('admin.inventory.productions.store') }}" id="form-production">
@csrf @csrf
<div class="form-row"> @include('admin.inventory.productions._form_fields', ['isEdit' => false])
<div class="form-group col-md-6">
<label for="product_id">{{ __('Produkt') }}</label>
<select name="product_id" id="product_id" class="form-control @error('product_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($products as $p)
<option value="{{ $p->id }}" @selected(old('product_id', $model?->product_id) == $p->id)>{{ $p->name }}</option>
@endforeach
</select>
@error('product_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group col-md-6">
<label for="location_id">{{ __('Lagerort / Produktionsstandort') }}</label>
<select name="location_id" id="location_id" class="form-control @error('location_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($locations as $loc)
<option value="{{ $loc->id }}" @selected(old('location_id', $model?->location_id ?? $defaultLocationId) == $loc->id)>{{ $loc->name }}</option>
@endforeach
</select>
@error('location_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div class="form-row">
<div class="form-group col-md-4">
<label for="produced_at">{{ __('Produktionsdatum') }}</label>
<input type="date" name="produced_at" id="produced_at" class="form-control @error('produced_at') is-invalid @enderror"
value="{{ old('produced_at', now()->toDateString()) }}" required>
@error('produced_at')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group col-md-4">
<label for="quantity">{{ __('Produzierte Stückzahl') }}</label>
<input type="number" name="quantity" id="quantity" min="1" step="1" class="form-control @error('quantity') is-invalid @enderror"
value="{{ old('quantity', $model?->quantity ?? 1) }}" required>
@error('quantity')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div id="recipe-area" class="mb-3" style="display:none;">
<hr>
<h6>{{ __('Chargen zuordnen') }}</h6>
<p class="text-muted small" id="recipe-hint"></p>
<div id="recipe-ingredient-lines"></div>
@error('ingredient_lines')
<div class="text-danger small">{{ $message }}</div>
@enderror
<h6 class="mt-3">{{ __('Verpackung (Vorschau)') }}</h6>
<div class="table-responsive border rounded">
<table class="table table-sm mb-0">
<thead><tr><th>{{ __('Artikel') }}</th><th>{{ __('Stück gesamt') }}</th></tr></thead>
<tbody id="recipe-packaging-preview"></tbody>
</table>
</div>
</div>
<div class="form-group">
<label for="notes">{{ __('Notizen') }}</label>
<textarea name="notes" id="notes" class="form-control" rows="2">{{ old('notes') }}</textarea>
</div>
<button type="submit" class="btn btn-primary" id="btn-submit-production">{{ __('Produktion speichern') }}</button> <button type="submit" class="btn btn-primary" id="btn-submit-production">{{ __('Produktion speichern') }}</button>
<a href="{{ route('admin.inventory.productions.index') }}" class="btn btn-outline-secondary">{{ __('Abbrechen') }}</a> <a href="{{ route('admin.inventory.productions.index') }}" class="btn btn-outline-secondary">{{ __('Abbrechen') }}</a>
@ -83,109 +17,12 @@
@endsection @endsection
@section('scripts') @section('scripts')
<script> @php
(function ($) { $existingLines = $model
var recipeBase = @json(url('/admin/inventory/api/products')); ? $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) {
var lineIndex = 0; return $lines->map(fn ($l) => ['stock_entry_id' => $l->stock_entry_id, 'quantity_used' => (float) $l->quantity_used])->values();
function recipeUrl() {
var pid = $('#product_id').val();
var lid = $('#location_id').val();
var qty = $('#quantity').val() || 1;
if (!pid || !lid) return null;
return recipeBase + '/' + pid + '/recipe?location_id=' + encodeURIComponent(lid) + '&quantity=' + encodeURIComponent(qty);
}
function addIngredientRow($tbody, ing, stockEntries) {
var idx = lineIndex;
lineIndex++;
var tr = $('<tr></tr>');
var select = $('<select class="form-control form-control-sm" name="ingredient_lines[' + idx + '][stock_entry_id]" required></select>');
select.append('<option value="">{{ __('Charge wählen') }}</option>');
(stockEntries || []).forEach(function (se) {
var label = '#' + se.id;
if (se.batch_number) label += ' — ' + se.batch_number;
if (se.best_before) label += ' (MHD ' + se.best_before + ')';
select.append($('<option></option>').attr('value', se.id).text(label));
});
var td1 = $('<td></td>');
td1.append($('<input type="hidden" name="ingredient_lines[' + idx + '][ingredient_id]" value="' + ing.id + '">'));
td1.append(select);
tr.append(td1);
tr.append($('<td></td>').append(
$('<input type="text" class="form-control form-control-sm" required name="ingredient_lines[' + idx + '][quantity_used]" placeholder="0">')
));
$tbody.append(tr);
}
function renderRecipe(data) {
$('#recipe-ingredient-lines').empty();
lineIndex = 0;
var hintParts = [];
var hasMissingGram = false;
(data.ingredients || []).forEach(function (ing) {
if (ing.required_grams_total === null) {
hasMissingGram = true;
hintParts.push(ing.name + ': {{ __('Gramm in der Rezeptur fehlt') }}');
return;
}
var wrap = $('<div class="border rounded p-2 mb-2" data-ingredient-id="' + ing.id + '"></div>');
wrap.data('recipe-ing', ing);
var soll = '<strong>' + $('<div/>').text(ing.name).html() + '</strong> — {{ __('Soll') }}: ' +
ing.required_grams_total.toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' g';
wrap.append(soll);
var tbody = $('<tbody></tbody>');
var tbl = $('<table class="table table-sm table-bordered mb-1"><thead><tr><th>{{ __('Charge') }}</th><th>{{ __('Menge (g)') }}</th></tr></thead></table>');
tbl.append(tbody);
wrap.append(tbl);
wrap.append('<button type="button" class="btn btn-sm btn-outline-secondary btn-add-split">{{ __('Weitere Charge') }}</button>');
$('#recipe-ingredient-lines').append(wrap);
addIngredientRow(tbody, ing, ing.stock_entries || []);
});
if (hasMissingGram) {
$('#recipe-hint').text(hintParts.join(' · ')).removeClass('text-muted').addClass('text-danger');
} else {
$('#recipe-hint').text('{{ __('Pro Charge die entnommene Menge in Gramm eintragen. Summe je Inhaltsstoff muss dem Soll entsprechen.') }}').removeClass('text-danger').addClass('text-muted');
}
var $pk = $('#recipe-packaging-preview').empty();
(data.packagings || []).forEach(function (pk) {
$pk.append('<tr><td>' + $('<div/>').text(pk.name).html() + '</td><td>' + pk.total_pieces + '</td></tr>');
});
}
$(document).on('click', '.btn-add-split', function () {
var $wrap = $(this).closest('[data-ingredient-id]');
var ing = $wrap.data('recipe-ing');
if (!ing) return;
var $tbody = $wrap.find('tbody').first();
addIngredientRow($tbody, ing, ing.stock_entries || []);
});
function loadRecipe() {
var url = recipeUrl();
if (!url) {
$('#recipe-area').hide();
return;
}
$('#recipe-hint').text('{{ __('Lade ') }}');
$.getJSON(url)
.done(function (data) {
$('#recipe-area').show();
renderRecipe(data);
}) })
.fail(function () { : [];
$('#recipe-area').show(); @endphp
$('#recipe-hint').text('{{ __('Rezept konnte nicht geladen werden.') }}').addClass('text-danger'); @include('admin.inventory.productions._scripts', ['existingLines' => $existingLines, 'excludeProductionId' => null])
});
}
$('#product_id, #location_id, #quantity').on('change', loadRecipe);
$(document).ready(function () {
if ($('#product_id').val() && $('#location_id').val()) {
loadRecipe();
}
});
})(jQuery);
</script>
@endsection @endsection

View file

@ -8,73 +8,7 @@
<form method="post" action="{{ route('admin.inventory.productions.update', $model) }}" id="form-production"> <form method="post" action="{{ route('admin.inventory.productions.update', $model) }}" id="form-production">
@csrf @csrf
@method('PUT') @method('PUT')
<div class="form-row"> @include('admin.inventory.productions._form_fields', ['isEdit' => true])
<div class="form-group col-md-6">
<label for="product_id">{{ __('Produkt') }}</label>
<select name="product_id" id="product_id" class="form-control @error('product_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($products as $p)
<option value="{{ $p->id }}" @selected(old('product_id', $model->product_id) == $p->id)>{{ $p->name }}</option>
@endforeach
</select>
@error('product_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group col-md-6">
<label for="location_id">{{ __('Lagerort / Produktionsstandort') }}</label>
<select name="location_id" id="location_id" class="form-control @error('location_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach($locations as $loc)
<option value="{{ $loc->id }}" @selected(old('location_id', $model->location_id) == $loc->id)>{{ $loc->name }}</option>
@endforeach
</select>
@error('location_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div class="form-row">
<div class="form-group col-md-4">
<label for="produced_at">{{ __('Produktionsdatum') }}</label>
<input type="date" name="produced_at" id="produced_at" class="form-control @error('produced_at') is-invalid @enderror"
value="{{ old('produced_at', $model->produced_at?->toDateString()) }}" required>
@error('produced_at')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group col-md-4">
<label for="quantity">{{ __('Produzierte Stückzahl') }}</label>
<input type="number" name="quantity" id="quantity" min="1" step="1" class="form-control @error('quantity') is-invalid @enderror"
value="{{ old('quantity', $model->quantity) }}" required>
@error('quantity')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
<div id="recipe-area" class="mb-3" style="display:none;">
<hr>
<h6>{{ __('Chargen zuordnen') }}</h6>
<p class="text-muted small" id="recipe-hint"></p>
<div id="recipe-ingredient-lines"></div>
@error('ingredient_lines')
<div class="text-danger small">{{ $message }}</div>
@enderror
<h6 class="mt-3">{{ __('Verpackung (Vorschau)') }}</h6>
<div class="table-responsive border rounded">
<table class="table table-sm mb-0">
<thead><tr><th>{{ __('Artikel') }}</th><th>{{ __('Stück gesamt') }}</th></tr></thead>
<tbody id="recipe-packaging-preview"></tbody>
</table>
</div>
</div>
<div class="form-group">
<label for="notes">{{ __('Notizen') }}</label>
<textarea name="notes" id="notes" class="form-control" rows="2">{{ old('notes', $model->notes) }}</textarea>
</div>
<button type="submit" class="btn btn-primary" id="btn-submit-production">{{ __('Produktion speichern') }}</button> <button type="submit" class="btn btn-primary" id="btn-submit-production">{{ __('Produktion speichern') }}</button>
<a href="{{ route('admin.inventory.productions.show', $model) }}" class="btn btn-outline-secondary">{{ __('Abbrechen') }}</a> <a href="{{ route('admin.inventory.productions.show', $model) }}" class="btn btn-outline-secondary">{{ __('Abbrechen') }}</a>
@ -84,120 +18,10 @@
@endsection @endsection
@section('scripts') @section('scripts')
<script> @php
(function ($) { $existingLines = $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) {
var recipeBase = @json(url('/admin/inventory/api/products')); return $lines->map(fn ($l) => ['stock_entry_id' => $l->stock_entry_id, 'quantity_used' => (float) $l->quantity_used])->values();
var lineIndex = 0;
function recipeUrl() {
var pid = $('#product_id').val();
var lid = $('#location_id').val();
var qty = $('#quantity').val() || 1;
if (!pid || !lid) return null;
return recipeBase + '/' + pid + '/recipe?location_id=' + encodeURIComponent(lid) + '&quantity=' + encodeURIComponent(qty);
}
function addIngredientRow($tbody, ing, stockEntries, preselectedEntryId, preselectedQty) {
var idx = lineIndex;
lineIndex++;
var tr = $('<tr></tr>');
var select = $('<select class="form-control form-control-sm" name="ingredient_lines[' + idx + '][stock_entry_id]" required></select>');
select.append('<option value="">{{ __('Charge wählen') }}</option>');
(stockEntries || []).forEach(function (se) {
var label = '#' + se.id;
if (se.batch_number) label += ' — ' + se.batch_number;
if (se.best_before) label += ' (MHD ' + se.best_before + ')';
var opt = $('<option></option>').attr('value', se.id).text(label);
if (preselectedEntryId && String(se.id) === String(preselectedEntryId)) opt.prop('selected', true);
select.append(opt);
}); });
var td1 = $('<td></td>'); @endphp
td1.append($('<input type="hidden" name="ingredient_lines[' + idx + '][ingredient_id]" value="' + ing.id + '">')); @include('admin.inventory.productions._scripts', ['existingLines' => $existingLines, 'excludeProductionId' => $model->id])
td1.append(select);
tr.append(td1);
tr.append($('<td></td>').append(
$('<input type="text" class="form-control form-control-sm" required name="ingredient_lines[' + idx + '][quantity_used]" placeholder="0">').val(preselectedQty || '')
));
$tbody.append(tr);
}
function renderRecipe(data) {
$('#recipe-ingredient-lines').empty();
lineIndex = 0;
var existingLines = @json($model->productionIngredients->groupBy('ingredient_id')->map(function($lines) {
return $lines->map(function($l) {
return ['stock_entry_id' => $l->stock_entry_id, 'quantity_used' => (float) $l->quantity_used];
})->values();
}));
var hintParts = [];
var hasMissingGram = false;
(data.ingredients || []).forEach(function (ing) {
if (ing.required_grams_total === null) {
hasMissingGram = true;
hintParts.push(ing.name + ': {{ __('Gramm in der Rezeptur fehlt') }}');
return;
}
var wrap = $('<div class="border rounded p-2 mb-2" data-ingredient-id="' + ing.id + '"></div>');
wrap.data('recipe-ing', ing);
var soll = '<strong>' + $('<div/>').text(ing.name).html() + '</strong> — {{ __('Soll') }}: ' +
ing.required_grams_total.toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' g';
wrap.append(soll);
var tbody = $('<tbody></tbody>');
var tbl = $('<table class="table table-sm table-bordered mb-1"><thead><tr><th>{{ __('Charge') }}</th><th>{{ __('Menge (g)') }}</th></tr></thead></table>');
tbl.append(tbody);
wrap.append(tbl);
wrap.append('<button type="button" class="btn btn-sm btn-outline-secondary btn-add-split">{{ __('Weitere Charge') }}</button>');
$('#recipe-ingredient-lines').append(wrap);
var existing = existingLines[String(ing.id)] || [];
if (existing.length > 0) {
existing.forEach(function (ex) {
addIngredientRow(tbody, ing, ing.stock_entries || [], ex.stock_entry_id, ex.quantity_used);
});
} else {
addIngredientRow(tbody, ing, ing.stock_entries || []);
}
});
if (hasMissingGram) {
$('#recipe-hint').text(hintParts.join(' · ')).removeClass('text-muted').addClass('text-danger');
} else {
$('#recipe-hint').text('{{ __('Pro Charge die entnommene Menge in Gramm eintragen.') }}').removeClass('text-danger').addClass('text-muted');
}
var $pk = $('#recipe-packaging-preview').empty();
(data.packagings || []).forEach(function (pk) {
$pk.append('<tr><td>' + $('<div/>').text(pk.name).html() + '</td><td>' + pk.total_pieces + '</td></tr>');
});
}
$(document).on('click', '.btn-add-split', function () {
var $wrap = $(this).closest('[data-ingredient-id]');
var ing = $wrap.data('recipe-ing');
if (!ing) return;
var $tbody = $wrap.find('tbody').first();
addIngredientRow($tbody, ing, ing.stock_entries || []);
});
function loadRecipe() {
var url = recipeUrl();
if (!url) { $('#recipe-area').hide(); return; }
$('#recipe-hint').text('{{ __('Lade ') }}');
$.getJSON(url)
.done(function (data) { $('#recipe-area').show(); renderRecipe(data); })
.fail(function () {
$('#recipe-area').show();
$('#recipe-hint').text('{{ __('Rezept konnte nicht geladen werden.') }}').addClass('text-danger');
});
}
$('#product_id, #location_id, #quantity').on('change', loadRecipe);
$(document).ready(function () {
if ($('#product_id').val() && $('#location_id').val()) {
loadRecipe();
}
});
})(jQuery);
</script>
@endsection @endsection

View file

@ -0,0 +1,156 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.table-actions-style')
<style>
#rms-table tbody tr.rms-row {
cursor: pointer;
}
#rms-table tbody tr.rms-row:hover {
background-color: rgba(0, 0, 0, 0.05);
}
#rms-table tbody tr.rms-row:hover .fa-edit {
color: #26b4ff !important;
}
</style>
<div class="card">
<div class="card-header">
<div class="d-flex flex-wrap justify-content-between align-items-center">
<h6 class="mb-0">{{ __('Rohstoffbestand') }}</h6>
<label class="form-check d-inline-flex align-items-center mb-0">
<input type="checkbox" id="rms-only-critical" class="form-check-input mr-2">
<span>{{ __('nur kritische anzeigen') }}</span>
</label>
</div>
<div class="form-group row mt-3 mb-0 align-items-center">
<label for="rms-search" class="col-sm-1 col-form-label">{{ __('Suchen') }}</label>
<div class="col-sm-5">
<input type="text" id="rms-search" class="form-control" autocomplete="off">
</div>
</div>
</div>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0" id="rms-table">
<thead>
<tr>
<th>{{ __('Name') }}</th>
<th>{{ __('Qualität') }}</th>
<th class="text-right">{{ __('Bestand') }}</th>
<th class="text-right">{{ __('Offen bestellt') }}</th>
<th class="text-right">{{ __('Verbrauch / Tag') }}</th>
<th>{{ __('Voraussichtlich auf Null') }}</th>
<th class="text-right" style="min-width: 13rem;">
<select id="rms-horizon" class="form-control form-control-sm">
@foreach($horizonOptions as $days => $label)
<option value="{{ $days }}" @selected($days === $defaultHorizon)>{{ $label }}</option>
@endforeach
</select>
</th>
</tr>
</thead>
<tbody>
@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
<tr class="rms-row {{ $rowClass }}"
data-name="{{ Str::lower($ingredient->name) }} {{ Str::lower($ingredient->inci ?? '') }}"
data-status="{{ $row['status'] }}"
data-href="{{ route('admin.inventory.raw-material-stock.show', $ingredient) }}">
<td>
<i class="far fa-edit text-muted mr-2" title="{{ __('Bestellung öffnen') }}"></i>
{{ $ingredient->name }}
</td>
<td class="text-muted">{{ $ingredient->materialQuality?->name ?? '—' }}</td>
<td class="text-right {{ in_array($row['status'], ['critical', 'critical_ordered'], true) ? 'text-danger font-weight-bold' : '' }}">
{{ \App\Services\Util::formatNumber($row['remaining'], 0) }} g
@if($row['status'] === 'critical_ordered')
<span class="badge badge-info ml-1">{{ __('bestellt') }}</span>
@endif
</td>
<td class="text-right">
@if($row['open_order'] > 0)
{{ \App\Services\Util::formatNumber($row['open_order'], 0) }} g
@else
<span class="text-muted"></span>
@endif
</td>
<td class="text-right">
@if($row['daily'] !== null && $row['daily'] > 0)
{{ \App\Services\Util::formatNumber($row['daily'], 0) }} g
@else
<span class="text-muted"></span>
@endif
</td>
<td>
@if($row['expected_empty'] !== null)
{{ $row['expected_empty']->format('d.m.Y') }}
<span class="text-muted">({{ $row['days_until_empty'] }} {{ trans_choice('Tag|Tagen', $row['days_until_empty']) }})</span>
@else
<span class="text-muted"></span>
@endif
</td>
<td class="text-right rms-forecast" data-daily="{{ $row['daily'] !== null ? $row['daily'] : 0 }}">
@if($row['daily'] !== null && $row['daily'] > 0)
{{ \App\Services\Util::formatNumber($row['daily'] * $defaultHorizon, 0) }} g
@else
<span class="text-muted"></span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="7" class="text-center text-muted py-4">{{ __('Keine aktiven Rohstoffe vorhanden.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<script>
$(function () {
var $rows = $('#rms-table tbody tr.rms-row');
function applyFilter() {
var term = ($('#rms-search').val() || '').toLowerCase().trim();
var onlyCritical = $('#rms-only-critical').is(':checked');
$rows.each(function () {
var $row = $(this);
var matchesTerm = term === '' || ($row.data('name') || '').toString().indexOf(term) !== -1;
var status = $row.data('status');
var matchesCritical = !onlyCritical || (status === 'critical' || status === 'warning');
$row.toggle(matchesTerm && matchesCritical);
});
}
function applyForecast() {
var days = parseInt($('#rms-horizon').val(), 10) || 0;
$('.rms-forecast').each(function () {
var daily = parseFloat($(this).data('daily')) || 0;
if (daily > 0) {
var total = Math.round(daily * days);
$(this).text(total.toLocaleString('de-DE') + ' g');
} else {
$(this).html('<span class="text-muted">—</span>');
}
});
}
$('#rms-search').on('keyup', applyFilter);
$('#rms-only-critical').on('change', applyFilter);
$('#rms-horizon').on('change', applyForecast);
$rows.on('click', function () {
var href = $(this).data('href');
if (href) {
window.location = href;
}
});
});
</script>
@endsection

View file

@ -0,0 +1,256 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.table-actions-style')
@php
$statusBadge = match ($status) {
'critical' => '<span class="badge badge-danger">' . __('Kritisch') . '</span>',
'critical_ordered' => '<span class="badge badge-warning">' . __('Kritisch · bereits bestellt') . '</span>',
'warning' => '<span class="badge badge-warning">' . __('Bald nachbestellen') . '</span>',
default => '<span class="badge badge-success">' . __('Ausreichend') . '</span>',
};
@endphp
<div class="card mb-3">
<h6 class="card-header d-flex justify-content-between align-items-center">
<span>{{ __('Rohstoffbestellung') }}: {{ $ingredient->name }} {!! $statusBadge !!}</span>
<a href="{{ route('admin.inventory.raw-material-stock.index') }}" class="btn btn-sm btn-outline-secondary">{{ __('Zurück') }}</a>
</h6>
<div class="card-body">
<div class="row">
<div class="col-md-3 col-6 mb-2">
<div class="text-muted small">{{ __('Qualität') }}</div>
<div>{{ $ingredient->materialQuality?->name ?? '—' }}</div>
</div>
<div class="col-md-3 col-6 mb-2">
<div class="text-muted small">{{ __('Gesamtbestand') }}</div>
<div class="{{ in_array($status, ['critical', 'critical_ordered'], true) ? 'text-danger font-weight-bold' : 'font-weight-bold' }}">
{{ \App\Services\Util::formatNumber($remaining, 0) }} g
</div>
</div>
<div class="col-md-3 col-6 mb-2">
<div class="text-muted small">{{ __('Offen bestellt') }}</div>
<div>
@if($openTotal > 0)
{{ \App\Services\Util::formatNumber($openTotal, 0) }} g
@else
@endif
</div>
</div>
<div class="col-md-3 col-6 mb-2">
<div class="text-muted small">{{ __('Verbrauch / Tag') }}</div>
<div>
@if($daily !== null && $daily > 0)
{{ \App\Services\Util::formatNumber($daily, 0) }} g
@else
@endif
</div>
</div>
<div class="col-md-3 col-6 mb-2">
<div class="text-muted small">{{ __('Voraussichtlich auf Null') }}</div>
<div>
@if($expectedEmpty !== null)
{{ $expectedEmpty->format('d.m.Y') }}
<span class="text-muted">({{ $daysUntilEmpty }} {{ trans_choice('Tag|Tagen', $daysUntilEmpty) }})</span>
@else
@endif
</div>
</div>
</div>
@if($ingredient->min_stock_alert !== null)
<div class="text-muted small mt-2">
{{ __('Meldebestand') }}: {{ \App\Services\Util::formatNumber($ingredient->min_stock_alert, 0) }} g
</div>
@endif
</div>
</div>
<div class="card mb-3">
<h6 class="card-header">{{ __('Enthalten in') }}</h6>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Produkt') }}</th>
<th class="text-right">{{ __('Produktbestand') }}</th>
<th class="text-right">{{ __('Rezeptur-Anteil (g/Stück)') }}</th>
</tr>
</thead>
<tbody>
@forelse($ingredient->products as $product)
<tr>
<td>{{ $product->name }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($productStock[$product->id] ?? 0, 0) }} {{ __('Stück') }}</td>
<td class="text-right">
@if($product->pivot->gram !== null && $product->pivot->gram !== '')
{{ \App\Services\Util::formatNumber($product->pivot->gram, 2) }} g
@else
<span class="text-muted"></span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="3" class="text-center text-muted py-3">{{ __('Dieser Rohstoff wird in keiner aktiven Rezeptur verwendet.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="card mb-3">
<h6 class="card-header">{{ __('Lieferanten & Bestellung') }}</h6>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Lieferant') }}</th>
<th>{{ __('Lieferzeit') }}</th>
<th class="text-right">{{ __('Letzter Einkauf (Netto/kg)') }}</th>
<th class="text-right" style="width: 11rem;"></th>
</tr>
</thead>
<tbody>
@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
<tr>
<td>
{{ $supplier->name }}
@if($supplier->pivot->preferred)
<span class="badge badge-success ml-1">{{ __('bevorzugt') }}</span>
@endif
@if($supplier->pivot->supplier_sku)
<div class="text-muted small">{{ __('Art.-Nr.') }}: {{ $supplier->pivot->supplier_sku }}</div>
@endif
</td>
<td>{{ $deliveryTime ?: '—' }}</td>
<td class="text-right">
@if($lastPrice !== null)
{{ \App\Services\Util::formatNumber($lastPrice, 2) }}
@else
<span class="text-muted"></span>
@endif
</td>
<td class="text-right">
@if($supplier->order_method === 'online_shop' && $orderUrl)
<a href="{{ $orderUrl }}" target="_blank" rel="noopener" class="btn btn-sm btn-dark">{{ __('Zum Shop') }}</a>
@elseif($orderEmail)
<a href="mailto:{{ $orderEmail }}?subject={{ rawurlencode(__('Bestellung') . ': ' . $ingredient->name) }}" class="btn btn-sm btn-dark">{{ __('Per Mail') }}</a>
@elseif($orderUrl)
<a href="{{ $orderUrl }}" target="_blank" rel="noopener" class="btn btn-sm btn-dark">{{ __('Zum Shop') }}</a>
@else
<span class="text-muted"></span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="4" class="text-center text-muted py-3">{{ __('Diesem Rohstoff ist kein Lieferant zugeordnet.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if(Auth::user()->isAdmin())
<div class="card-footer">
<a href="{{ route('admin.inventory.stock-entries.create', ['ingredient_id' => $ingredient->id]) }}" class="btn btn-sm btn-primary">{{ __('Einkauf erfassen') }}</a>
<a href="{{ route('admin.inventory.stock-disposals.create', ['ingredient_id' => $ingredient->id]) }}" class="btn btn-sm btn-outline-danger">{{ __('Ausschuss erfassen') }}</a>
</div>
@endif
</div>
<div class="card mb-3">
<h6 class="card-header">{{ __('Offene Bestellungen / unterwegs') }}</h6>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Bestellt am') }}</th>
<th>{{ __('Lieferant') }}</th>
<th>{{ __('Lagerort') }}</th>
<th class="text-right">{{ __('Bestellte Menge') }}</th>
<th class="text-right" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
@forelse($openOrders as $order)
<tr>
<td>{{ $order->ordered_at?->format('d.m.Y') ?? '—' }}</td>
<td>{{ $order->supplier?->name ?? '—' }}</td>
<td>{{ $order->location?->name ?? '—' }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($order->ordered_quantity, 0) }} g</td>
<td class="text-right">
<a href="{{ route('admin.inventory.stock-entries.show', $order) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Wareneingang buchen') }}">
<span class="far fa-eye"></span>
</a>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center text-muted py-3">{{ __('Keine offenen Bestellungen.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($openOrders->isNotEmpty())
<div class="card-footer text-muted small">
{{ __('Offene Bestellungen zählen erst nach gebuchtem Wareneingang zum Bestand.') }}
</div>
@endif
</div>
<div class="card mb-3">
<h6 class="card-header">{{ __('Verfügbare Chargen') }}</h6>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Charge') }}</th>
<th>{{ __('Lieferant') }}</th>
<th>{{ __('Lagerort') }}</th>
<th>{{ __('MHD') }}</th>
<th class="text-right">{{ __('Restbestand') }}</th>
</tr>
</thead>
<tbody>
@forelse($charges as $charge)
<tr>
<td>{{ $charge->batch_number ?: '#' . $charge->id }}</td>
<td>{{ $charge->supplier?->name ?? '—' }}</td>
<td>{{ $charge->location?->name ?? '—' }}</td>
<td>{{ $charge->best_before?->format('d.m.Y') ?? '—' }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($charge->getAttribute('remaining_quantity'), 0) }} g</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center text-muted py-3">{{ __('Kein Restbestand vorhanden.') }}</td>
</tr>
@endforelse
</tbody>
@if($charges->isNotEmpty() && count($remainingByLocation) > 0)
<tfoot>
@foreach($locations as $location)
@if(isset($remainingByLocation[$location->id]) && $remainingByLocation[$location->id] > 0)
<tr>
<td colspan="4" class="text-right text-muted">{{ __('Bestand') }} {{ $location->name }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($remainingByLocation[$location->id], 0) }} g</td>
</tr>
@endif
@endforeach
</tfoot>
@endif
</table>
</div>
</div>
@endsection

View file

@ -0,0 +1,194 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">{{ __('Ausgang / Ausschuss erfassen') }}</h4>
<div class="card">
<div class="card-body">
<p class="text-muted small">{{ __('Reduziert den Bestand des gewählten Rohstoffs bzw. Verpackungsartikels. Der Grund ist Pflicht und erscheint in der Ausgangsliste.') }}</p>
<form method="post" action="{{ route('admin.inventory.stock-disposals.store') }}">
@csrf
<div class="form-group">
<label for="disposal_type">{{ __('Art') }} <span class="text-danger">*</span></label>
<select name="disposal_type" id="disposal_type" class="form-control" required>
<option value="ingredient" @selected(old('disposal_type', $prefill['disposal_type']) === 'ingredient')>{{ __('Rohstoff') }}</option>
<option value="packaging" @selected(old('disposal_type', $prefill['disposal_type']) === 'packaging')>{{ __('Verpackung') }}</option>
</select>
</div>
<div id="disposal-ingredient-block" class="form-group" style="display:none;">
<label for="ingredient_id">{{ __('Rohstoff') }} <span class="text-danger">*</span></label>
<div class="light-style">
<select name="ingredient_id" id="ingredient_id" class="w-100"
data-search-url="{{ route('admin.inventory.api.ingredients.search') }}"
data-charges-url="{{ route('admin.inventory.api.disposals.ingredient-charges', ['ingredient' => '__ID__']) }}">
@if ($prefill['ingredient_id'])
<option value="{{ $prefill['ingredient_id'] }}" selected>{{ $prefill['ingredient_label'] }}</option>
@elseif(old('ingredient_id'))
<option value="{{ old('ingredient_id') }}" selected>{{ old('ingredient_id') }}</option>
@endif
</select>
</div>
@error('ingredient_id')
<div class="text-danger small">{{ $message }}</div>
@enderror
</div>
<div id="disposal-charge-block" class="form-group" style="display:none;">
<label for="stock_entry_id">{{ __('Charge') }} <span class="text-muted small">({{ __('optional') }})</span></label>
<select name="stock_entry_id" id="stock_entry_id" class="form-control">
<option value="">{{ __('— keine bestimmte Charge —') }}</option>
</select>
<small class="text-muted">{{ __('Bei Auswahl wird der Lagerort automatisch gesetzt.') }}</small>
</div>
<div id="disposal-packaging-block" class="form-group" style="display:none;">
<label for="packaging_item_id">{{ __('Verpackungsartikel') }} <span class="text-danger">*</span></label>
<div class="light-style">
<select name="packaging_item_id" id="packaging_item_id" class="w-100"
data-search-url="{{ route('admin.inventory.api.packaging-items.search') }}">
@if (old('packaging_item_id'))
<option value="{{ old('packaging_item_id') }}" selected>{{ old('packaging_item_id') }}</option>
@endif
</select>
</div>
@error('packaging_item_id')
<div class="text-danger small">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="location_id">{{ __('Lagerort') }} <span class="text-danger">*</span></label>
<select name="location_id" id="location_id" class="form-control @error('location_id') is-invalid @enderror" required>
<option value="">{{ __('Bitte wählen') }}</option>
@foreach ($locations as $loc)
<option value="{{ $loc->id }}" @selected((string) old('location_id') === (string) $loc->id)>{{ $loc->name }}</option>
@endforeach
</select>
@error('location_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="quantity">{{ __('Menge') }} <span class="text-danger">*</span></label>
<input type="text" name="quantity" id="quantity" autocomplete="off"
class="form-control @error('quantity') is-invalid @enderror" value="{{ old('quantity') }}" required>
<small class="text-muted" id="quantity-hint">{{ __('Bei Rohstoff in Gramm, bei Verpackung in Stück.') }}</small>
@error('quantity')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="reason">{{ __('Grund') }} <span class="text-danger">*</span></label>
<select name="reason" id="reason" class="form-control @error('reason') is-invalid @enderror" required>
@foreach ($reasons as $reason)
<option value="{{ $reason }}" @selected(old('reason') === $reason)>{{ $reason }}</option>
@endforeach
</select>
@error('reason')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="note">{{ __('Hinweis') }}</label>
<input type="text" name="note" id="note" maxlength="255" autocomplete="off"
class="form-control @error('note') is-invalid @enderror" value="{{ old('note') }}">
@error('note')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="disposed_at">{{ __('Datum') }} <span class="text-danger">*</span></label>
<input type="text" name="disposed_at" id="disposed_at" autocomplete="off"
class="form-control datepicker-base @error('disposed_at') is-invalid @enderror"
value="{{ old('disposed_at', now()->format('d.m.Y')) }}" required>
@error('disposed_at')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<button type="submit" class="btn btn-primary">{{ __('Buchen') }}</button>
<a href="{{ route('admin.inventory.stock-disposals.index') }}" class="btn btn-outline-secondary">{{ __('Zurück') }}</a>
</form>
</div>
</div>
@endsection
@section('scripts')
<script>
(function ($) {
function toggleBlocks() {
var isIng = $('#disposal_type').val() === 'ingredient';
$('#disposal-ingredient-block').toggle(isIng);
$('#disposal-charge-block').toggle(isIng);
$('#disposal-packaging-block').toggle(!isIng);
$('#quantity-hint').text(isIng
? '{{ __('Menge in Gramm.') }}'
: '{{ __('Menge in Stück.') }}');
}
function initSearchSelect2(id, placeholder) {
var $el = $('#' + id);
if ($el.data('select2')) {
$el.select2('destroy');
}
$el.select2({
theme: 'default',
width: '100%',
placeholder: placeholder,
allowClear: true,
ajax: {
url: $el.data('search-url'),
dataType: 'json',
delay: 250,
data: function (params) { return {q: params.term || ''}; },
processResults: function (data) { return {results: data.results || []}; },
cache: true
},
minimumInputLength: 1
});
}
function loadCharges(ingredientId) {
var $charge = $('#stock_entry_id');
$charge.empty().append($('<option>').val('').text('{{ __(' keine bestimmte Charge ') }}'));
if (!ingredientId) {
return;
}
var url = $('#ingredient_id').data('charges-url').replace('__ID__', ingredientId);
$.getJSON(url, function (data) {
(data.charges || []).forEach(function (c) {
$charge.append($('<option>').val(c.id).text(c.text).attr('data-location', c.location_id));
});
});
}
$(document).ready(function () {
toggleBlocks();
initSearchSelect2('ingredient_id', '{{ __('Rohstoff suchen…') }}');
initSearchSelect2('packaging_item_id', '{{ __('Verpackungsartikel suchen…') }}');
$('#disposal_type').on('change', toggleBlocks);
$('#ingredient_id').on('change', function () {
loadCharges($(this).val());
});
$('#stock_entry_id').on('change', function () {
var loc = $(this).find('option:selected').data('location');
if (loc) {
$('#location_id').val(String(loc));
}
});
@if ($prefill['ingredient_id'])
loadCharges('{{ $prefill['ingredient_id'] }}');
@endif
});
})(jQuery);
</script>
@endsection

View file

@ -0,0 +1,76 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.table-actions-style')
<div class="card">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center">
<h6 class="mb-0">{{ __('Ausgang / Ausschuss') }}</h6>
<div>
<form method="get" class="d-inline-block mr-2">
<select name="type" class="form-control form-control-sm d-inline-block" style="width:auto"
onchange="this.form.submit()">
<option value="">{{ __('Alle Arten') }}</option>
<option value="ingredient" @selected($typeFilter === 'ingredient')>{{ __('Rohstoff') }}</option>
<option value="packaging" @selected($typeFilter === 'packaging')>{{ __('Verpackung') }}</option>
</select>
</form>
@if (Auth::user()->isAdmin())
<a href="{{ route('admin.inventory.stock-disposals.create') }}" class="btn btn-sm btn-primary">
{{ __('Ausschuss erfassen') }}
</a>
@endif
</div>
</div>
<div class="card-datatable table-responsive">
<table class="table table-striped wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Datum') }}</th>
<th>{{ __('Art') }}</th>
<th>{{ __('Artikel') }}</th>
<th>{{ __('Charge') }}</th>
<th>{{ __('Lagerort') }}</th>
<th class="text-right">{{ __('Menge') }}</th>
<th>{{ __('Grund') }}</th>
<th>{{ __('Hinweis') }}</th>
<th>{{ __('Mitarbeiter') }}</th>
</tr>
</thead>
<tbody>
@forelse($values as $disposal)
<tr>
<td>{{ $disposal->disposed_at?->format('d.m.Y') }}</td>
<td>
@if ($disposal->isIngredient())
<span class="badge badge-info">{{ __('Rohstoff') }}</span>
@else
<span class="badge badge-secondary">{{ __('Verpackung') }}</span>
@endif
</td>
<td>{{ $disposal->articleName() }}</td>
<td>
@if ($disposal->stockEntry)
{{ $disposal->stockEntry->batch_number ?: '#'.$disposal->stockEntry->id }}
@else
<span class="text-muted"></span>
@endif
</td>
<td>{{ $disposal->location?->name ?? '—' }}</td>
<td class="text-right text-danger font-weight-bold">
{{ \App\Services\Util::formatNumber($disposal->quantity, $disposal->unit === 'piece' ? 0 : 2) }}
{{ $disposal->unit === 'piece' ? __('Stück') : 'g' }}
</td>
<td>{{ $disposal->reason }}</td>
<td class="text-muted">{{ $disposal->note }}</td>
<td>{{ $disposal->user?->getFullName(false) ?: ($disposal->user?->email ?? '—') }}</td>
</tr>
@empty
<tr>
<td colspan="9" class="text-center text-muted py-4">{{ __('Noch keine Ausgänge erfasst.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endsection

View file

@ -95,9 +95,9 @@
<div class="form-group"> <div class="form-group">
<label for="ordered_at">{{ __('Bestelldatum') }} <span class="text-danger">*</span></label> <label for="ordered_at">{{ __('Bestelldatum') }} <span class="text-danger">*</span></label>
<input type="date" name="ordered_at" id="ordered_at" <input type="text" name="ordered_at" id="ordered_at" autocomplete="off"
class="form-control @error('ordered_at') is-invalid @enderror" class="form-control datepicker-base @error('ordered_at') is-invalid @enderror"
value="{{ old('ordered_at', $model->ordered_at ? $model->ordered_at->format('Y-m-d') : '') }}" required> value="{{ old('ordered_at', $model->ordered_at ? $model->ordered_at->format('d.m.Y') : '') }}" required>
@error('ordered_at') @error('ordered_at')
<div class="invalid-feedback">{{ $message }}</div> <div class="invalid-feedback">{{ $message }}</div>
@enderror @enderror

View file

@ -137,9 +137,9 @@
<div class="form-group"> <div class="form-group">
<label for="received_at">{{ __('Eingangsdatum') }} <span class="text-danger">*</span></label> <label for="received_at">{{ __('Eingangsdatum') }} <span class="text-danger">*</span></label>
<input type="date" name="received_at" id="received_at" required <input type="text" name="received_at" id="received_at" required autocomplete="off"
class="form-control @error('received_at') is-invalid @enderror" class="form-control datepicker-base @error('received_at') is-invalid @enderror"
value="{{ old('received_at', now()->toDateString()) }}"> value="{{ old('received_at', now()->format('d.m.Y')) }}">
@error('received_at') @error('received_at')
<div class="invalid-feedback">{{ $message }}</div> <div class="invalid-feedback">{{ $message }}</div>
@enderror @enderror
@ -175,8 +175,8 @@
<div class="form-group"> <div class="form-group">
<label for="best_before">{{ __('Mindesthaltbarkeit') }} <span class="text-danger">*</span></label> <label for="best_before">{{ __('Mindesthaltbarkeit') }} <span class="text-danger">*</span></label>
<input type="date" name="best_before" id="best_before" <input type="text" name="best_before" id="best_before" autocomplete="off"
class="form-control @error('best_before') is-invalid @enderror" class="form-control datepicker-base @error('best_before') is-invalid @enderror"
value="{{ old('best_before') }}"> value="{{ old('best_before') }}">
@error('best_before') @error('best_before')
<div class="invalid-feedback">{{ $message }}</div> <div class="invalid-feedback">{{ $message }}</div>

View file

@ -21,6 +21,11 @@
<h4 class="mb-2"> <h4 class="mb-2">
<a href="#" class="text-body">{{ $product->name }}</a> <a href="#" class="text-body">{{ $product->name }}</a>
</h4> </h4>
@if ($product->isOutOfStock())
<div class="alert alert-danger py-2 my-3 font-weight-bold">
<i class="fa fa-clock-o"></i> {{ $product->outOfStockNotice() }}
</div>
@endif
{!! $product->copy !!} {!! $product->copy !!}
<table class="table my-4"> <table class="table my-4">

View file

@ -62,12 +62,21 @@
'material_name' => $item->packagingMaterial?->name ?? '', '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 @endphp
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
<script> <script>
(function ($) { (function ($) {
var catalog = @json($ingredient_catalog_for_js); var catalog = @json($ingredient_catalog_for_js);
var packagingCatalog = @json($packaging_catalog_for_js); var packagingCatalog = @json($packaging_catalog_for_js);
var setCatalog = @json($set_product_catalog_for_js);
function parseDeNumber(val) { function parseDeNumber(val) {
if (val === undefined || val === null || val === '') return NaN; if (val === undefined || val === null || val === '') return NaN;
@ -449,6 +458,124 @@
} }
$(document).on('change', '.js-shelf-life-type', toggleShelfMonths); $(document).on('change', '.js-shelf-life-type', toggleShelfMonths);
toggleShelfMonths(); toggleShelfMonths();
function toggleRecipeFields() {
var noRecipe = $('#no_recipe_required').is(':checked');
$('.js-recipe-fields').toggle(!noRecipe);
}
$(document).on('change', '#no_recipe_required', toggleRecipeFields);
toggleRecipeFields();
// === Set-Bestandteile ===
var $setTbody = document.getElementById('set-sortable-rows');
if ($setTbody && typeof Sortable !== 'undefined') {
new Sortable($setTbody, {
handle: '.set-drag-handle',
animation: 150,
});
}
function appendSetRow(id) {
id = String(id);
var sp = setCatalog[id];
if (!sp) {
return;
}
var row = $('<tr data-set-product-id="' + id + '">' +
'<td class="text-muted align-middle set-drag-handle" style="cursor:grab">&#9776;</td>' +
'<td class="align-middle"></td><td class="align-middle small text-muted"></td>' +
'<td class="align-middle text-right small text-muted"></td>' +
'<td class="align-middle text-right small text-muted"></td>' +
'<td><input type="hidden" name="set_component_id[]" value="' + id + '">' +
'<input type="number" min="1" step="1" name="set_quantity[]" class="form-control form-control-sm set-quantity" value="1" autocomplete="off"></td>' +
'<td class="align-middle"><a class="text-danger set-row-remove" href="#" title="{{ __('Entfernen') }}"><i class="far fa-trash-alt"></i></a></td></tr>');
row.find('td').eq(1).text(sp.name || '');
row.find('td').eq(2).text(sp.number || '—');
row.find('td').eq(3).text(sp.weight || '—');
row.find('td').eq(4).text(sp.price || '—');
$('#set-sortable-rows').append(row);
}
function syncSetModalRows() {
var usedIds = {};
$('#set-sortable-rows tr').each(function () {
var sid = $(this).data('set-product-id');
if (sid) {
usedIds[String(sid)] = true;
}
});
$('#modal-set-pick .set-modal-row').each(function () {
var sid = String($(this).data('set-product-id'));
var inUse = !!usedIds[sid];
var $cb = $(this).find('.js-set-modal-cb');
$cb.prop('disabled', inUse);
$cb.attr('title', inUse ? '{{ __('Bereits im Set') }}' : '');
if (inUse) {
$cb.prop('checked', false);
}
$(this).css('opacity', inUse ? 0.65 : 1);
$(this).find('.js-set-modal-name').toggleClass('text-muted', inUse);
$(this).find('.js-set-modal-number').toggleClass('text-muted', inUse);
});
}
$('#set-modal-search').on('input', function () {
var q = $(this).val().trim().toLowerCase();
var visible = 0;
$('#set-modal-tbody .set-modal-row').each(function () {
var hay = $(this).attr('data-set-search') || '';
var match = !q || hay.indexOf(q) !== -1;
$(this).toggle(match);
if (match) {
visible++;
}
});
$('#set-modal-no-results').toggleClass('d-none', !(q.length > 0 && visible === 0));
});
$('#modal-set-pick').on('show.bs.modal', function () {
$('#set-modal-search').val('');
$('#set-modal-tbody .set-modal-row').show();
$('#set-modal-no-results').addClass('d-none');
syncSetModalRows();
});
$('#btn-set-modal-add').on('click', function () {
var added = 0;
$('#modal-set-pick .js-set-modal-cb:checked:not(:disabled)').each(function () {
appendSetRow(String($(this).val()));
$(this).prop('checked', false);
added++;
});
if (added > 0) {
syncSetModalRows();
$('#modal-set-pick').modal('hide');
}
});
$(document).on('click', '.set-row-remove', function (ev) {
ev.preventDefault();
$(this).closest('tr').remove();
syncSetModalRows();
});
function toggleSetMode() {
var isSet = $('#is_set').is(':checked');
$('.js-set-fields').toggle(isSet);
$('#product-section-rezeptur, #product-section-herstellerrezeptur, #product-section-verpackung, #product-section-warenwirtschaft').toggle(!isSet);
$('.js-nav-recipe').toggleClass('d-none', isSet);
}
$(document).on('change', '#is_set', toggleSetMode);
toggleSetMode();
function toggleOutOfStock() {
var indefinite = $('#out_of_stock_indefinite').is(':checked');
var active = $('#out_of_stock_active').is(':checked');
$('#out_of_stock_active').prop('disabled', indefinite);
$('.js-out-of-stock-days').toggle(active && !indefinite);
}
$(document).on('change', '#out_of_stock_active, #out_of_stock_indefinite', toggleOutOfStock);
toggleOutOfStock();
}); });
})(jQuery); })(jQuery);
</script> </script>

View file

@ -6,13 +6,16 @@
id="product-form-section-nav" aria-label="{{ __('Sprungmarken Produktformular') }}"> id="product-form-section-nav" aria-label="{{ __('Sprungmarken Produktformular') }}">
<span class="navbar-text small font-weight-bold text-muted mr-2 mb-1">{{ __('Bereiche') }}:</span> <span class="navbar-text small font-weight-bold text-muted mr-2 mb-1">{{ __('Bereiche') }}:</span>
<a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-produkt">{{ __('Produkt') }}</a> <a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-produkt">{{ __('Produkt') }}</a>
<a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-verfuegbarkeit">{{ __('Verfügbarkeit') }}</a>
<a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-set">{{ __('Set') }}</a>
<a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-preise">{{ __('Preise in EUR') }}</a> <a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-preise">{{ __('Preise in EUR') }}</a>
<a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-laenderpreise">{{ __('Landesspezifische Preise') }}</a> <a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-laenderpreise">{{ __('Landesspezifische Preise') }}</a>
<a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-whitelabel">{{ __('White-Label') }}</a> <a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-whitelabel">{{ __('White-Label') }}</a>
<a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-details">{{ __('Details') }}</a> <a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-details">{{ __('Details') }}</a>
<a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-rezeptur">{{ __('Inhaltsstoffe') }} / {{ __('Rezeptur') }}</a> <a class="btn btn-sm btn-outline-secondary mb-1 mr-1 js-nav-recipe" href="#product-section-rezeptur">{{ __('Inhaltsstoffe') }} / {{ __('Rezeptur') }}</a>
<a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-herstellerrezeptur">{{ __('Hersteller Rezeptur') }}</a> <a class="btn btn-sm btn-outline-secondary mb-1 mr-1 js-nav-recipe" href="#product-section-herstellerrezeptur">{{ __('Hersteller Rezeptur') }}</a>
<a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-warenwirtschaft">{{ __('Warenwirtschaft') }}</a> <a class="btn btn-sm btn-outline-secondary mb-1 mr-1 js-nav-recipe" href="#product-section-verpackung">{{ __('Verpackung') }}</a>
<a class="btn btn-sm btn-outline-secondary mb-1 mr-1 js-nav-recipe" href="#product-section-warenwirtschaft">{{ __('Warenwirtschaft') }}</a>
@if (Auth::user()->isSySAdmin()) @if (Auth::user()->isSySAdmin())
<a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-sysadmin">{{ __('SySAdmin Einstellungen') }}</a> <a class="btn btn-sm btn-outline-secondary mb-1 mr-1" href="#product-section-sysadmin">{{ __('SySAdmin Einstellungen') }}</a>
@endif @endif
@ -97,6 +100,171 @@
</div> </div>
</div> </div>
<div class="card mb-2" id="product-section-verfuegbarkeit">
<h5 class="card-header">{{ __('Verfügbarkeit') }}</h5>
<div class="card-body">
@php($outOfStockDays = $product->outOfStockRemainingDays())
<div class="form-group">
<label class="custom-control custom-checkbox">
{!! Form::checkbox('out_of_stock_active', 1, old('out_of_stock_active', $outOfStockDays !== null), ['class' => 'custom-control-input', 'id' => 'out_of_stock_active']) !!}
<span class="custom-control-label">{{ __('Vorübergehend nicht vorrätig (mit Zeitangabe)') }}</span>
</label>
<p class="text-muted small mb-0">{{ __('Zeigt im Shop den Hinweis „In ca. X Tagen wieder da!". Der Kauf bleibt weiterhin möglich.') }}</p>
</div>
<div class="form-row js-out-of-stock-days" style="display:none;">
<div class="form-group col-sm-4">
<label class="form-label" for="out_of_stock_days">{{ __('Wieder verfügbar in (Tagen)') }}</label>
{{ Form::number('out_of_stock_days', old('out_of_stock_days', $outOfStockDays), ['placeholder' => __('z. B. 14'), 'class' => 'form-control', 'id' => 'out_of_stock_days', 'min' => 0, 'step' => 1]) }}
</div>
</div>
<hr>
<div class="form-group">
<label class="custom-control custom-checkbox">
{!! Form::checkbox('out_of_stock_indefinite', 1, old('out_of_stock_indefinite', $product->out_of_stock_indefinite), ['class' => 'custom-control-input', 'id' => 'out_of_stock_indefinite']) !!}
<span class="custom-control-label">{{ __('Auf unbestimmte Zeit nicht vorrätig') }}</span>
</label>
<p class="text-muted small mb-0">{{ __('Daueranzeige ohne Zeitangabe (hat Vorrang vor der Tagesangabe). Der Kauf bleibt weiterhin möglich.') }}</p>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<hr>
<button type="submit" class=" float-right btn btn-sm btn-submit">{{ __('save') }}</button>&nbsp;
</div>
</div>
</div>
</div>
<div class="card mb-2" id="product-section-set">
<h5 class="card-header">{{ __('Set / Produktart') }}</h5>
<div class="card-body">
<div class="form-group">
<label class="custom-control custom-checkbox">
{!! Form::checkbox('is_set', 1, old('is_set', $product->is_set), ['class' => 'custom-control-input', 'id' => 'is_set']) !!}
<span class="custom-control-label">{{ __('Dieses Produkt ist ein Set (Bündel mehrerer Einzelprodukte)') }}</span>
</label>
<p class="text-muted small mb-0">{{ __('Bei aktivem Set werden Rezeptur, Verpackung und Warenwirtschaft ausgeblendet. Ein Set wird nicht produziert; beim Verkauf werden später die enthaltenen Einzelprodukte abgebucht.') }}</p>
</div>
<div class="js-set-fields" style="display:none;">
<p class="text-muted small mb-2">{{ __('Set-Bestandteile (nur Einzelprodukte). Reihenfolge per Drag & Drop ändern, danach speichern.') }}</p>
<div class="table-responsive">
<table class="table table-striped table-bordered mb-3">
<thead>
<tr>
<th style="width:2rem"></th>
<th>{{ __('Name') }}</th>
<th>{{ __('Artikelnr.') }}</th>
<th class="text-right" style="width:8rem">{{ __('Gewicht') }}</th>
<th class="text-right" style="width:10rem">{{ __('Preis VK in EUR (Brutto)') }}</th>
<th style="width:8rem">{{ __('Menge') }}</th>
<th style="width:3rem"></th>
</tr>
</thead>
<tbody id="set-sortable-rows">
@foreach ($product->setItems as $component)
<tr data-set-product-id="{{ $component->id }}">
<td class="text-muted align-middle set-drag-handle" style="cursor:grab"
title="{{ __('Verschieben') }}">&#9776;</td>
<td class="align-middle">{{ $component->name }}</td>
<td class="align-middle small text-muted">{{ $component->number ?? '—' }}</td>
<td class="align-middle text-right small text-muted">{{ $component->weight ? $component->weight . ' g' : '—' }}</td>
<td class="align-middle text-right small text-muted">{{ $component->getFormattedPrice() !== '' ? $component->getFormattedPrice() . ' €' : '—' }}</td>
<td>
<input type="hidden" name="set_component_id[]" value="{{ $component->id }}">
<input type="number" min="1" step="1" name="set_quantity[]"
class="form-control form-control-sm set-quantity"
value="{{ (int) ($component->pivot->quantity ?? 1) }}" autocomplete="off">
</td>
<td class="align-middle">
<a class="text-danger set-row-remove" href="#"
title="{{ __('Entfernen') }}"><i class="far fa-trash-alt"></i></a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="form-group">
<button type="button" class="btn btn-default" data-toggle="modal"
data-target="#modal-set-pick">{{ __('Einzelprodukte hinzufügen') }}</button>
</div>
<div class="modal fade" id="modal-set-pick" tabindex="-1" role="dialog"
aria-labelledby="modal-set-pick-title" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modal-set-pick-title">{{ __('Einzelprodukte auswählen') }}</h5>
<button type="button" class="close" data-dismiss="modal"
aria-label="{{ __('Schließen') }}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="form-group mb-2">
<input type="search" id="set-modal-search" class="form-control" autocomplete="off"
placeholder="{{ __('Name oder Artikelnr. filtern …') }}"
aria-label="{{ __('Name oder Artikelnr. filtern …') }}">
</div>
<p class="small text-muted mb-2 d-none" id="set-modal-no-results">{{ __('Keine Treffer.') }}
</p>
<div class="table-responsive border rounded"
style="max-height: min(60vh, 480px); overflow-y: auto;">
<table class="table table-sm table-striped mb-0">
<thead class="thead-light sticky-top">
<tr style="background-color: #f8f9fa;">
<th style="width:2.5rem"></th>
<th>{{ __('Name') }}</th>
<th>{{ __('Artikelnr.') }}</th>
<th class="text-right">{{ __('Gewicht') }}</th>
<th class="text-right">{{ __('Preis VK in EUR (Brutto)') }}</th>
</tr>
</thead>
<tbody id="set-modal-tbody">
@foreach ($set_product_catalog as $sp)
<tr class="set-modal-row" data-set-product-id="{{ $sp->id }}"
data-set-search="{{ e(mb_strtolower(trim(($sp->name ?? '') . ' ' . ($sp->number ?? '')), 'UTF-8')) }}">
<td class="align-middle">
<label class="custom-control custom-checkbox mb-0">
<input type="checkbox" class="custom-control-input js-set-modal-cb"
value="{{ $sp->id }}"
@if ($product->setItems->contains('id', $sp->id)) disabled @endif>
<span class="custom-control-label"></span>
</label>
</td>
<td class="align-middle js-set-modal-name">{{ $sp->name }}</td>
<td class="align-middle small text-muted js-set-modal-number">{{ $sp->number ?? '—' }}</td>
<td class="align-middle small text-muted text-right">{{ $sp->weight ? $sp->weight . ' g' : '—' }}</td>
<td class="align-middle small text-muted text-right">{{ $sp->getFormattedPrice() !== '' ? $sp->getFormattedPrice() . ' €' : '—' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default"
data-dismiss="modal">{{ __('Abbrechen') }}</button>
<button type="button" class="btn btn-primary"
id="btn-set-modal-add">{{ __('Hinzufügen') }}</button>
</div>
</div>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-12">
<hr>
<button type="submit" class=" float-right btn btn-sm btn-submit">{{ __('save') }}</button>&nbsp;
</div>
</div>
</div>
</div>
<div class="card mb-2" id="product-section-preise"> <div class="card mb-2" id="product-section-preise">
<h5 class="card-header"> <h5 class="card-header">
{{ __('Preise in EUR') }} {{ __('Preise in EUR') }}
@ -419,6 +587,16 @@
</h5> </h5>
<div class="card-body"> <div class="card-body">
<input type="hidden" name="product_inci_sync_sent" value="1"> <input type="hidden" name="product_inci_sync_sent" value="1">
<div class="form-group">
<label class="custom-control custom-checkbox">
{!! Form::checkbox('no_recipe_required', 1, old('no_recipe_required', $product->no_recipe_required), ['class' => 'custom-control-input', 'id' => 'no_recipe_required']) !!}
<span class="custom-control-label">{{ __('Dieses Produkt benötigt keine Rezeptur (Eigenprodukt, z. B. Broschüre, Etikett)') }}</span>
</label>
<p class="text-muted small mb-0">{{ __('Bei aktiver Option werden die Rezeptur-Felder ausgeblendet und in der Produktion wird keine Rezeptur abgefragt.') }}</p>
</div>
<div class="js-recipe-fields">
<p class="text-muted small mb-2">{{ __('Reihenfolge per Drag & Drop ändern, danach speichern.') }}</p> <p class="text-muted small mb-2">{{ __('Reihenfolge per Drag & Drop ändern, danach speichern.') }}</p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-bordered mb-3" id="recipe-table-product"> <table class="table table-striped table-bordered mb-3" id="recipe-table-product">
@ -553,6 +731,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>{{-- /.js-recipe-fields --}}
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12"> <div class="form-group col-md-12">
<hr> <hr>
@ -568,6 +747,7 @@
</h5> </h5>
<div class="card-body"> <div class="card-body">
<input type="hidden" name="manufacturer_inci_sync_sent" value="1"> <input type="hidden" name="manufacturer_inci_sync_sent" value="1">
<div class="js-recipe-fields">
<p class="text-muted small mb-2">{{ __('Eigene Hersteller-Rezeptur (separate INCI-Liste). Reihenfolge per Drag & Drop ändern, danach speichern.') }}</p> <p class="text-muted small mb-2">{{ __('Eigene Hersteller-Rezeptur (separate INCI-Liste). Reihenfolge per Drag & Drop ändern, danach speichern.') }}</p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-bordered mb-3" id="recipe-table-manufacturer"> <table class="table table-striped table-bordered mb-3" id="recipe-table-manufacturer">
@ -683,6 +863,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>{{-- /.js-recipe-fields --}}
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12"> <div class="form-group col-md-12">
<hr> <hr>
@ -846,6 +1027,40 @@
@endforeach @endforeach
</select> </select>
</div> </div>
<hr>
<p class="text-muted small mb-2">{{ __('Produktbestand-Schwellwerte (Stück). Bei Erreichen wird das Produkt im Produktbestand farblich markiert.') }}</p>
<div class="form-row">
<div class="form-group col-sm-6">
<label class="form-label" for="min_product_stock">{{ __('Meldebestand (gelb)') }}</label>
{{ 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]) }}
</div>
<div class="form-group col-sm-6">
<label class="form-label" for="critical_product_stock">{{ __('Kritischer Bestand (rot)') }}</label>
{{ 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]) }}
</div>
</div>
<hr>
<p class="text-muted small mb-2">{{ __('Optional: dieses Einzelprodukt einem Hauptprodukt zuordnen (z. B. „50 × 15 ml"). Der Produktbestand führt nur Haupt-/Einzelprodukte.') }}</p>
<div class="form-row">
<div class="form-group col-sm-8">
<label class="form-label" for="main_product_id">{{ __('Hauptprodukt') }}</label>
<select name="main_product_id" id="main_product_id" class="form-control">
<option value="">{{ __('— kein Hauptprodukt —') }}</option>
@foreach ($set_product_catalog as $mp)
<option value="{{ $mp->id }}" @selected((int) old('main_product_id', $product->main_product_id) === (int) $mp->id)>
{{ $mp->name }}@if ($mp->number) ({{ $mp->number }})@endif
</option>
@endforeach
</select>
</div>
<div class="form-group col-sm-4">
<label class="form-label" for="main_product_quantity">{{ __('Menge im Hauptprodukt') }}</label>
{{ 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]) }}
</div>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group col-md-12"> <div class="form-group col-md-12">
<hr> <hr>

View file

@ -6,7 +6,8 @@
<h6 class="card-header"> <h6 class="card-header">
{{ __('Produkte') }} {{ __('Produkte') }}
<label class="custom-control custom-checkbox float-right mb-0"> <label class="custom-control custom-checkbox float-right mb-0">
<input type="checkbox" class="custom-control-input show-active-products" name="show_active_products" @if(get_user_attr('show_active_products') === "true") checked @endif> <input type="checkbox" class="custom-control-input show-active-products" name="show_active_products"
@if (get_user_attr('show_active_products') === 'true') checked @endif>
<span class="custom-control-label font-style-normal font-weight-normal">nur aktive anzeigen</span> <span class="custom-control-label font-style-normal font-weight-normal">nur aktive anzeigen</span>
</label> </label>
</h6> </h6>
@ -25,12 +26,26 @@
<th>{{ __('Einheit') }}</th> <th>{{ __('Einheit') }}</th>
<th>{{ __('Grundpreis') }}</th> <th>{{ __('Grundpreis') }}</th>
<th>{{ __('Gewicht') }}</th> <th>{{ __('Gewicht') }}</th>
<th>{{ __('Verfügbarkeit') }}</th>
<th>{{ __('sichbar') }}</th> <th>{{ __('sichbar') }}</th>
<th><div data-toggle="tooltip" title data-original-title="White Label">{{__('WL')}}</div></th> <th>
<th><div data-toggle="tooltip" title data-original-title="Kompensationsprodukt">{{__('KP')}}</div></th> <div data-toggle="tooltip" title data-original-title="White Label">{{ __('WL') }}</div>
<th><div data-toggle="tooltip" title data-original-title="Maximaler Kauf pro Berater">{{__('MK')}}</div></th> </th>
<th><div data-toggle="tooltip" title data-original-title="Einzelrabatt">{{__('ER')}}</div></th> <th>
<th><div data-toggle="tooltip" title data-original-title="Auswertung Absatzmengen ausschließen ">{{__('AA')}}</div></th> <div data-toggle="tooltip" title data-original-title="Kompensationsprodukt">{{ __('KP') }}
</div>
</th>
<th>
<div data-toggle="tooltip" title data-original-title="Maximaler Kauf pro Berater">
{{ __('MK') }}</div>
</th>
<th>
<div data-toggle="tooltip" title data-original-title="Einzelrabatt">{{ __('ER') }}</div>
</th>
<th>
<div data-toggle="tooltip" title data-original-title="Auswertung Absatzmengen ausschließen ">
{{ __('AA') }}</div>
</th>
<th>{{ __('Status') }}</th> <th>{{ __('Status') }}</th>
<th></th> <th></th>
@ -40,14 +55,16 @@
@foreach ($values as $value) @foreach ($values as $value)
<tr> <tr>
<td> <td>
<a href="{{route('admin_product_edit', [$value->id])}}" class="btn icon-btn btn-sm btn-primary"> <a href="{{ route('admin_product_edit', [$value->id]) }}"
class="btn icon-btn btn-sm btn-primary">
<span class="far fa-edit"></span> <span class="far fa-edit"></span>
</a> </a>
</td> </td>
<td>{{ $value->pos }}</td> <td>{{ $value->pos }}</td>
<td> <td>
@if (count($value->images)) @if (count($value->images))
<img class="img-fluid" alt="" style="max-height: 80px" src="{{ route('product_image', [$value->images->first()->slug]) }}"> <img class="img-fluid" alt="" style="max-height: 80px"
src="{{ route('product_image', [$value->images->first()->slug]) }}">
@endif @endif
</td> </td>
<td>{{ $value->name }}</td> <td>{{ $value->name }}</td>
@ -62,6 +79,14 @@
<td>{{ $value->getUnitType() }}</td> <td>{{ $value->getUnitType() }}</td>
<td>{{ $value->getBasePriceFormatted() }}</td> <td>{{ $value->getBasePriceFormatted() }}</td>
<td>{{ $value->weight }}</td> <td>{{ $value->weight }}</td>
<td data-sort="{{ $value->isOutOfStock() ? 1 : 0 }}">
@if ($value->isOutOfStock())
<span class="badge badge-danger"
style="white-space: normal;">{{ $value->outOfStockNotice() }}</span>
@else
<span class="text-muted small">{{ __('vorrätig') }}</span>
@endif
</td>
<td>{!! $value->getShowOnTypes('<br>') !!}</td> <td>{!! $value->getShowOnTypes('<br>') !!}</td>
<td data-sort="{{ $value->whitelabel }}">{!! get_active_badge($value->whitelabel, $value->whitelabel_name) !!}</td> <td data-sort="{{ $value->whitelabel }}">{!! get_active_badge($value->whitelabel, $value->whitelabel_name) !!}</td>
<td data-sort="{{ $value->shipping_addon }}">{!! get_active_badge($value->shipping_addon) !!}</td> <td data-sort="{{ $value->shipping_addon }}">{!! get_active_badge($value->shipping_addon) !!}</td>
@ -69,8 +94,13 @@
<td data-sort="{{ $value->single_commission }}">{!! get_active_badge($value->single_commission) !!}</td> <td data-sort="{{ $value->single_commission }}">{!! get_active_badge($value->single_commission) !!}</td>
<td data-sort="{{ $value->exclude_stats_sales }}">{!! get_active_badge($value->exclude_stats_sales) !!}</td> <td data-sort="{{ $value->exclude_stats_sales }}">{!! get_active_badge($value->exclude_stats_sales) !!}</td>
<td data-sort="{{ $value->active }}">{!! get_active_badge($value->active) !!}</td> <td data-sort="{{ $value->active }}">{!! get_active_badge($value->active) !!}</td>
<td><a class="text-info" href="{{ route('admin_product_copy', [$value->id]) }}" onclick="return confirm('{{__('Eintrag kopieren?')}}');"><i class="far fa-copy"></i></a> &nbsp; <td><a class="text-info" href="{{ route('admin_product_copy', [$value->id]) }}"
<a class="text-danger" href="{{ route('admin_product_delete', [$value->id]) }}" onclick="return confirm('{{__('Really delete entry?')}}');"><i class="far fa-trash-alt"></i></a></td> onclick="return confirm('{{ __('Eintrag kopieren?') }}');"><i
class="far fa-copy"></i></a> &nbsp;
<a class="text-danger" href="{{ route('admin_product_delete', [$value->id]) }}"
onclick="return confirm('{{ __('Really delete entry?') }}');"><i
class="far fa-trash-alt"></i></a>
</td>
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>
@ -100,7 +130,5 @@
}) })
}); });
</script> </script>
@endsection @endsection

View file

@ -211,17 +211,70 @@
<li class="sidenav-header small font-weight-semibold">WARENWIRTSCHAFT</li> <li class="sidenav-header small font-weight-semibold">WARENWIRTSCHAFT</li>
@if (Auth::user()->isCopyReader()) @if (Auth::user()->isCopyReader())
<li class="sidenav-item @if (Request::is('admin/inventory/product-stock*')) open @endif">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
<i class="sidenav-icon ion ion-md-cube"></i>
<div>{{ __('Produktbestand') }}
@if (($criticalProductCount ?? 0) > 0)
<span class="badge badge-danger ml-1">{{ $criticalProductCount }}</span>
@endif
</div>
</a>
<ul class="sidenav-menu">
<li class="sidenav-item{{ Request::is('admin/inventory/product-stock') ? ' active' : '' }}">
<a href="{{ route('admin.inventory.product-stock.index') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-list"></i>
<div>{{ __('Übersicht') }}</div>
</a>
</li>
<li class="sidenav-item{{ Request::is('admin/inventory/product-stock/history') ? ' active' : '' }}">
<a href="{{ route('admin.inventory.product-stock.history') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-time"></i>
<div>{{ __('Historie') }}</div>
</a>
</li>
</ul>
</li>
<li class="sidenav-item{{ Request::is('admin/inventory/raw-material-stock*') ? ' active' : '' }}">
<a href="{{ route('admin.inventory.raw-material-stock.index') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-beaker"></i>
<div>{{ __('Rohstoffbestand') }}</div>
@if (($criticalIngredientCount ?? 0) > 0)
<span class="badge badge-danger ml-1">{{ $criticalIngredientCount }}</span>
@endif
</a>
</li>
<li class="sidenav-item{{ Request::is('admin/inventory/stock-entries*') ? ' active' : '' }}"> <li class="sidenav-item{{ Request::is('admin/inventory/stock-entries*') ? ' active' : '' }}">
<a href="{{ route('admin.inventory.stock-entries.index') }}" class="sidenav-link"><i <a href="{{ route('admin.inventory.stock-entries.index') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-download"></i> class="sidenav-icon ion ion-md-download"></i>
<div>{{ __('Einkauf & Wareneingang') }}</div> <div>{{ __('Einkauf & Wareneingang') }}</div>
</a> </a>
</li> </li>
<li class="sidenav-item{{ Request::is('admin/inventory/productions*') ? ' active' : '' }}"> <li class="sidenav-item{{ Request::is('admin/inventory/stock-disposals*') ? ' active' : '' }}">
<a href="{{ route('admin.inventory.productions.index') }}" class="sidenav-link"><i <a href="{{ route('admin.inventory.stock-disposals.index') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-construct"></i> class="sidenav-icon ion ion-md-trash"></i>
<div>{{ __('Ausgang / Ausschuss') }}</div>
</a>
</li>
<li class="sidenav-item @if (Request::is('admin/inventory/productions*', 'admin/inventory/product-development*')) open @endif">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
<i class="sidenav-icon ion ion-md-construct"></i>
<div>{{ __('Produktion') }}</div> <div>{{ __('Produktion') }}</div>
</a> </a>
<ul class="sidenav-menu">
<li class="sidenav-item{{ Request::is('admin/inventory/productions*') ? ' active' : '' }}">
<a href="{{ route('admin.inventory.productions.index') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-list"></i>
<div>{{ __('Produktionen') }}</div>
</a>
</li>
<li class="sidenav-item{{ Request::is('admin/inventory/product-development*') ? ' active' : '' }}">
<a href="{{ route('admin.inventory.product-development') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-flask"></i>
<div>{{ __('Produktentwicklung') }}</div>
</a>
</li>
</ul>
</li> </li>
@endif @endif
@if (Auth::user()->isAdmin()) @if (Auth::user()->isAdmin())
@ -268,7 +321,8 @@
'admin/inventory/delivery-times*', 'admin/inventory/delivery-times*',
'admin/inventory/locations*', 'admin/inventory/locations*',
'admin/inventory/material-qualities*', 'admin/inventory/material-qualities*',
'admin/inventory/packaging-materials*')) open @endif"> 'admin/inventory/packaging-materials*',
'admin/inventory/notices*')) open @endif">
<a href="javascript:void(0)" class="sidenav-link sidenav-toggle"> <a href="javascript:void(0)" class="sidenav-link sidenav-toggle">
<i class="sidenav-icon ion ion-md-cog"></i> <i class="sidenav-icon ion ion-md-cog"></i>
<div>Einstellungen</div> <div>Einstellungen</div>
@ -300,6 +354,12 @@
<div>{{ __('Verpackungsmaterial') }}</div> <div>{{ __('Verpackungsmaterial') }}</div>
</a> </a>
</li> </li>
<li class="sidenav-item{{ Request::is('admin/inventory/notices*') ? ' active' : '' }}">
<a href="{{ route('admin.inventory.notices') }}" class="sidenav-link"><i
class="sidenav-icon ion ion-md-information-circle"></i>
<div>{{ __('Hinweise') }}</div>
</a>
</li>
</ul> </ul>
</li> </li>
@endif @endif

View file

@ -8,10 +8,12 @@
line-height: 1.5; line-height: 1.5;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.md-btn-extra { .md-btn-extra {
width: calc(1.7rem + 2px) !important; width: calc(1.7rem + 2px) !important;
line-height: 1.5rem; line-height: 1.5rem;
} }
.form-control.input-extra { .form-control.input-extra {
padding: 0.28rem 0.6rem; padding: 0.28rem 0.6rem;
font-size: 0.8rem; font-size: 0.8rem;
@ -20,28 +22,41 @@
height: calc(1.8rem + 2px); height: calc(1.8rem + 2px);
width: 44px; width: 44px;
} }
.input-group-min-w { .input-group-min-w {
min-width: 102px; min-width: 102px;
} }
.img-extra { .img-extra {
min-width: 55px; min-width: 55px;
max-height: 160px; max-height: 160px;
} }
.product-stock-hint {
display: inline-block;
background-color: #c81031;
color: #fff;
font-weight: 700;
padding: 0.5rem 0.85rem;
border-radius: 0.25rem;
white-space: normal;
line-height: 1.3;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.default-style:not([dir=rtl]) div.card-datatable table.dataTable thead th:first-child, .default-style:not([dir=rtl]) div.card-datatable table.dataTable thead th:first-child,
.default-style:not([dir=rtl]) div.card-datatable table.dataTable tbody td:first-child, .default-style:not([dir=rtl]) div.card-datatable table.dataTable tbody td:first-child,
.default-style:not([dir=rtl]) div.card-datatable table.dataTable tfoot th:first-child { .default-style:not([dir=rtl]) div.card-datatable table.dataTable tfoot th:first-child {
padding-left: 0.6rem !important; padding-left: 0.6rem !important;
} }
.img-extra { .img-extra {
min-width: 35px; min-width: 35px;
max-height: 160px; max-height: 160px;
} }
} }
</style> </style>
@ -49,17 +64,20 @@
@if ($for === 'cr') @if ($for === 'cr')
<h4 class="font-weight-bold py-2 mb-2"> <h4 class="font-weight-bold py-2 mb-2">
{{ __('navigation.my_orders') }} / Mein Guthaben aufladen {{ __('navigation.my_orders') }} / Mein Guthaben aufladen
<a href="{{ route('user_order_my_delivery', [$for, $delivery_id]) }}" class="btn btn-sm btn-default float-right">zurück</a> <a href="{{ route('user_order_my_delivery', [$for, $delivery_id]) }}"
class="btn btn-sm btn-default float-right">zurück</a>
<div class="clearfix"></div> <div class="clearfix"></div>
</h4> </h4>
@else @else
<h4 class="font-weight-bold py-2 mb-2"> <h4 class="font-weight-bold py-2 mb-2">
{{ __('navigation.my_orders') }} / {{ __('navigation.do_order') }} {{ __('navigation.my_orders') }} / {{ __('navigation.do_order') }}
<a href="{{ route('user_order_my_delivery', [$for, $delivery_id]) }}" class="btn btn-sm btn-default float-right">zurück</a> <a href="{{ route('user_order_my_delivery', [$for, $delivery_id]) }}"
class="btn btn-sm btn-default float-right">zurück</a>
<div class="clearfix"></div> <div class="clearfix"></div>
</h4> </h4>
@if ($user->user_level) @if ($user->user_level)
<p>Die Produktpreise werden entsprechend Deiner Rolle: <strong>{{$user->user_level->name}}</strong> angezeigt.<br> <p>Die Produktpreise werden entsprechend Deiner Rolle: <strong>{{ $user->user_level->name }}</strong>
angezeigt.<br>
Hinweis: Wenn Du den Warenkorb verlässt, gehen alle Einstellungen verloren.</p> Hinweis: Wenn Du den Warenkorb verlässt, gehen alle Einstellungen verloren.</p>
@else @else
<p>Hinweis: Dir wurde noch keine Rolle zugewisen. Bitte wende dich an serivce@gruene-seele.bio</p> <p>Hinweis: Dir wurde noch keine Rolle zugewisen. Bitte wende dich an serivce@gruene-seele.bio</p>
@ -104,7 +122,10 @@
<i>{!! __('order.delivery_country_changed_info', ['link' => route('user_edit')]) !!}</i> <i>{!! __('order.delivery_country_changed_info', ['link' => route('user_edit')]) !!}</i>
<hr> <hr>
@if ($user->user_level) @if ($user->user_level)
<p>{!! __('order.product_prices_career_level_info', ['user_level_name'=>$user->user_level->getLang('name'), 'user_level_margin'=>$user->user_level->getFormattedMargin()]) !!}</p> <p>{!! __('order.product_prices_career_level_info', [
'user_level_name' => $user->user_level->getLang('name'),
'user_level_margin' => $user->user_level->getFormattedMargin(),
]) !!}</p>
@else @else
<p>{{ __('order.no_career_level_info') }}</p> <p>{{ __('order.no_career_level_info') }}</p>
@endif @endif
@ -113,7 +134,8 @@
<div class="card"> <div class="card">
<div class="card-datatable table-responsive"> <div class="card-datatable table-responsive">
<table class="datatables-order-list table table-striped table-bordered" id="datatables-order-list" data-url="{{route('user_order_my_perform_request')}}"> <table class="datatables-order-list table table-striped table-bordered" id="datatables-order-list"
data-url="{{ route('user_order_my_perform_request') }}">
<thead> <thead>
<tr> <tr>
<th>{{ __('Bild') }}</th> <th>{{ __('Bild') }}</th>
@ -157,7 +179,6 @@
<div id="holder_html_view_comp_product"> <div id="holder_html_view_comp_product">
@include('user.order.comp_product') @include('user.order.comp_product')
</div> </div>
@endif @endif
<div class="card mt-4"> <div class="card mt-4">
@ -169,7 +190,8 @@
</div> </div>
</div> </div>
<div class="mt-2"> <div class="mt-2">
<a href="{{ route('user_order_my_delivery', [$for, $delivery_id]) }}" class="btn btn-sm btn-default float-right">zurück</a> <a href="{{ route('user_order_my_delivery', [$for, $delivery_id]) }}"
class="btn btn-sm btn-default float-right">zurück</a>
</div> </div>
{!! Form::close() !!} {!! Form::close() !!}
@ -189,17 +211,55 @@
// d.filter_customer_member = $('select[name=filter_customer_member]').val(); // d.filter_customer_member = $('select[name=filter_customer_member]').val();
} }
}, },
"order": [[8, "asc" ]], "order": [
"columns": [ [8, "asc"]
{ data: 'picture', name: 'picture', searchable: false, width: 35 }, ],
{ data: 'product', name: 'product' }, "columns": [{
{ data: 'category', name: 'category', orderable: true }, data: 'picture',
{ data: 'price_net', name: 'price_net', searchable: false, orderable: false }, name: 'picture',
{ data: 'price_gross', name: 'price_gross', searchable: false, orderable: false }, searchable: false,
{ data: 'single_commission', name: 'single_commission', searchable: false }, width: 35
{ data: 'weight', name: 'weight', searchable: false }, },
{ data: 'contents_total', name: 'contents_total', searchable: false }, {
{ data: 'number', name: 'number' }, data: 'product',
name: 'product'
},
{
data: 'category',
name: 'category',
orderable: true
},
{
data: 'price_net',
name: 'price_net',
searchable: false,
orderable: false
},
{
data: 'price_gross',
name: 'price_gross',
searchable: false,
orderable: false
},
{
data: 'single_commission',
name: 'single_commission',
searchable: false
},
{
data: 'weight',
name: 'weight',
searchable: false
},
{
data: 'contents_total',
name: 'contents_total',
searchable: false
},
{
data: 'number',
name: 'number'
},
], ],
"bLengthChange": false, "bLengthChange": false,
"iDisplayLength": 1000, "iDisplayLength": 1000,

View file

@ -55,7 +55,11 @@
<div class="product-item-price mt-2 mt-2 pb-3"> <div class="product-item-price mt-2 mt-2 pb-3">
{{ $product->getFormattedPrice() }} &euro;* {{ $product->getFormattedPrice() }} &euro;*
<br><span class="small text-muted">@if($product->unit) {{ $product->getBasePriceFormattedFull() }} &euro; @else &nbsp; @endif</span> <br><span class="small text-muted">@if($product->unit) {{ $product->getBasePriceFormattedFull() }} &euro; @else &nbsp; @endif</span>
@if ($product->isOutOfStock())
<div class="small text-warning font-weight-bold mt-1">
<i class="fa fa-clock-o"></i> {{ $product->outOfStockNotice() }}
</div>
@endif
</div> </div>
</div> </div>
</div> </div>

View file

@ -16,6 +16,11 @@
<div class="media-body py-4 px-3 px-md-4"> <div class="media-body py-4 px-3 px-md-4">
{!! $product->copy !!} {!! $product->copy !!}
@if ($product->isOutOfStock())
<div class="alert alert-warning py-2 my-3">
<i class="fa fa-clock-o"></i> <strong>{{ $product->outOfStockNotice() }}</strong>
</div>
@endif
<table class="table table-striped my-4"> <table class="table table-striped my-4">
<tbody> <tbody>
<tr> <tr>

View file

@ -4,9 +4,14 @@ use App\Http\Controllers\Admin\Inventory\DeliveryTimeController as InventoryDeli
use App\Http\Controllers\Admin\Inventory\GeneralSettingController as InventoryGeneralSettingController; use App\Http\Controllers\Admin\Inventory\GeneralSettingController as InventoryGeneralSettingController;
use App\Http\Controllers\Admin\Inventory\LocationController as InventoryLocationController; use App\Http\Controllers\Admin\Inventory\LocationController as InventoryLocationController;
use App\Http\Controllers\Admin\Inventory\MaterialQualityController as InventoryMaterialQualityController; use App\Http\Controllers\Admin\Inventory\MaterialQualityController as InventoryMaterialQualityController;
use App\Http\Controllers\Admin\Inventory\NoticeController as InventoryNoticeController;
use App\Http\Controllers\Admin\Inventory\PackagingItemController as InventoryPackagingItemController; use App\Http\Controllers\Admin\Inventory\PackagingItemController as InventoryPackagingItemController;
use App\Http\Controllers\Admin\Inventory\PackagingMaterialController as InventoryPackagingMaterialController; use App\Http\Controllers\Admin\Inventory\PackagingMaterialController as InventoryPackagingMaterialController;
use App\Http\Controllers\Admin\Inventory\ProductDevelopmentController as InventoryProductDevelopmentController;
use App\Http\Controllers\Admin\Inventory\ProductionController as InventoryProductionController; use App\Http\Controllers\Admin\Inventory\ProductionController as InventoryProductionController;
use App\Http\Controllers\Admin\Inventory\ProductStockController as InventoryProductStockController;
use App\Http\Controllers\Admin\Inventory\RawMaterialStockController as InventoryRawMaterialStockController;
use App\Http\Controllers\Admin\Inventory\StockDisposalController as InventoryStockDisposalController;
use App\Http\Controllers\Admin\Inventory\StockEntryController as InventoryStockEntryController; use App\Http\Controllers\Admin\Inventory\StockEntryController as InventoryStockEntryController;
use App\Http\Controllers\Admin\Inventory\SupplierCategoryController as InventorySupplierCategoryController; use App\Http\Controllers\Admin\Inventory\SupplierCategoryController as InventorySupplierCategoryController;
use App\Http\Controllers\Admin\Inventory\SupplierController as InventorySupplierController; use App\Http\Controllers\Admin\Inventory\SupplierController as InventorySupplierController;
@ -278,6 +283,7 @@ Route::domain(config('app.domain'))->group(function () {
Route::resource('material-qualities', InventoryMaterialQualityController::class)->except(['show']); Route::resource('material-qualities', InventoryMaterialQualityController::class)->except(['show']);
Route::resource('packaging-materials', InventoryPackagingMaterialController::class)->except(['show']); Route::resource('packaging-materials', InventoryPackagingMaterialController::class)->except(['show']);
Route::get('general', [InventoryGeneralSettingController::class, 'index'])->name('general'); Route::get('general', [InventoryGeneralSettingController::class, 'index'])->name('general');
Route::get('notices', [InventoryNoticeController::class, 'index'])->name('notices');
Route::resource('tax-rates', InventoryTaxRateController::class)->except(['index', 'show']); Route::resource('tax-rates', InventoryTaxRateController::class)->except(['index', 'show']);
Route::resource('delivery-times', InventoryDeliveryTimeController::class)->except(['index', 'show']); Route::resource('delivery-times', InventoryDeliveryTimeController::class)->except(['index', 'show']);
}); });
@ -293,14 +299,24 @@ Route::domain(config('app.domain'))->group(function () {
}); });
Route::middleware(['copyreader'])->prefix('admin/inventory')->name('admin.inventory.')->group(function () { Route::middleware(['copyreader'])->prefix('admin/inventory')->name('admin.inventory.')->group(function () {
Route::get('product-stock', [InventoryProductStockController::class, 'index'])->name('product-stock.index');
Route::get('product-stock/history', [InventoryProductStockController::class, 'history'])->name('product-stock.history');
Route::post('product-stock/{product}/movement', [InventoryProductStockController::class, 'storeMovement'])->name('product-stock.movement');
Route::get('raw-material-stock', [InventoryRawMaterialStockController::class, 'index'])->name('raw-material-stock.index');
Route::get('raw-material-stock/{ingredient}', [InventoryRawMaterialStockController::class, 'show'])->name('raw-material-stock.show');
Route::get('api/ingredients/search', [InventoryStockEntryController::class, 'searchIngredients'])->name('api.ingredients.search'); Route::get('api/ingredients/search', [InventoryStockEntryController::class, 'searchIngredients'])->name('api.ingredients.search');
Route::get('api/packaging-items/search', [InventoryStockEntryController::class, 'searchPackagingItems'])->name('api.packaging-items.search'); Route::get('api/packaging-items/search', [InventoryStockEntryController::class, 'searchPackagingItems'])->name('api.packaging-items.search');
Route::get('api/products/{product}/recipe', [InventoryProductionController::class, 'recipeJson'])->name('api.products.recipe'); Route::get('api/products/{product}/recipe', [InventoryProductionController::class, 'recipeJson'])->name('api.products.recipe');
Route::get('api/disposals/ingredient-charges/{ingredient}', [InventoryStockDisposalController::class, 'ingredientCharges'])->name('api.disposals.ingredient-charges');
Route::get('stock-disposals', [InventoryStockDisposalController::class, 'index'])->name('stock-disposals.index');
Route::get('stock-disposals/create', [InventoryStockDisposalController::class, 'create'])->name('stock-disposals.create');
Route::post('stock-disposals', [InventoryStockDisposalController::class, 'store'])->name('stock-disposals.store');
Route::put('stock-entries/{stock_entry}/receive', [InventoryStockEntryController::class, 'receive'])->name('stock-entries.receive'); Route::put('stock-entries/{stock_entry}/receive', [InventoryStockEntryController::class, 'receive'])->name('stock-entries.receive');
Route::get('stock-entries/{stock_entry}/copy', [InventoryStockEntryController::class, 'copy'])->name('stock-entries.copy'); Route::get('stock-entries/{stock_entry}/copy', [InventoryStockEntryController::class, 'copy'])->name('stock-entries.copy');
Route::resource('stock-entries', InventoryStockEntryController::class); Route::resource('stock-entries', InventoryStockEntryController::class);
Route::resource('productions', InventoryProductionController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update']); Route::resource('productions', InventoryProductionController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update']);
Route::get('productions/{production}/copy', [InventoryProductionController::class, 'copy'])->name('productions.copy'); Route::get('productions/{production}/copy', [InventoryProductionController::class, 'copy'])->name('productions.copy');
Route::get('product-development', [InventoryProductDevelopmentController::class, 'index'])->name('product-development');
}); });
}); });

View file

@ -0,0 +1,41 @@
<?php
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function noticeUser(int $adminLevel = 8): User
{
$user = User::query()->create([
'email' => uniqid('notice_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
test('hinweise-seite rendert das markdown gerendert als html', function () {
$this->actingAs(noticeUser(8), 'user');
$response = $this->get(route('admin.inventory.notices'));
$response->assertSuccessful();
$response->assertSee('Hinweise');
$response->assertSee('Entwicklungsstand', false);
$response->assertSee('<h2', false);
});
test('nicht-superadmin hat keinen zugriff auf hinweise-seite', function () {
$this->actingAs(noticeUser(7), 'user');
$this->get(route('admin.inventory.notices'))->assertRedirect('/home');
});

View file

@ -0,0 +1,135 @@
<?php
use App\Models\Product;
use App\Repositories\ProductRepository;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function oosMakeUser(int $adminLevel = 1): User
{
$user = User::query()->create([
'email' => uniqid('oos_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function oosMakeProduct(string $name): Product
{
return Product::query()->create([
'name' => $name,
'title' => $name,
'active' => true,
]);
}
test('repository berechnet out_of_stock_until aus tagesangabe', function () {
$product = oosMakeProduct('OOS-Tage');
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
'out_of_stock_active' => '1',
'out_of_stock_days' => '14',
]);
$product->refresh();
expect($product->out_of_stock_indefinite)->toBeFalse();
expect($product->out_of_stock_until)->not->toBeNull();
expect($product->out_of_stock_until->isSameDay(now()->addDays(14)))->toBeTrue();
expect($product->isOutOfStock())->toBeTrue();
});
test('unbestimmt hat vorrang und nullt das datum', function () {
$product = oosMakeProduct('OOS-Unbestimmt');
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
'out_of_stock_active' => '1',
'out_of_stock_days' => '14',
'out_of_stock_indefinite' => '1',
]);
$product->refresh();
expect($product->out_of_stock_indefinite)->toBeTrue();
expect($product->out_of_stock_until)->toBeNull();
expect($product->isOutOfStock())->toBeTrue();
expect($product->outOfStockNotice())->toBe('Zur Zeit nicht vorrätig');
});
test('ohne aktivierung werden die felder geleert', function () {
$product = oosMakeProduct('OOS-Aus');
$product->update(['out_of_stock_until' => now()->addDays(5), 'out_of_stock_indefinite' => false]);
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
]);
$product->refresh();
expect($product->out_of_stock_until)->toBeNull();
expect($product->out_of_stock_indefinite)->toBeFalse();
expect($product->isOutOfStock())->toBeFalse();
});
test('vergangenes datum gilt nicht als nicht vorrätig', function () {
$product = oosMakeProduct('OOS-Vergangen');
$product->update(['out_of_stock_until' => now()->subDays(2)]);
$product->refresh();
expect($product->isOutOfStock())->toBeFalse();
expect($product->outOfStockRemainingDays())->toBeNull();
expect($product->outOfStockNotice())->toBeNull();
});
test('hinweis zeigt verbleibende tage', function () {
$product = oosMakeProduct('OOS-Hinweis');
$product->update(['out_of_stock_until' => now()->addDays(5)]);
$product->refresh();
expect($product->isOutOfStock())->toBeTrue();
expect($product->outOfStockRemainingDays())->toBe(5);
expect($product->outOfStockNotice())->toBe('In ca. 5 Tagen wieder da!');
});
test('http-store speichert nicht-vorrätig mit tagen', function () {
$this->actingAs(oosMakeUser(1), 'user');
$response = $this->post(route('admin_product_store'), [
'id' => 'new',
'name' => 'OOS-HTTP',
'out_of_stock_active' => '1',
'out_of_stock_days' => '7',
]);
$response->assertRedirect();
$product = Product::query()->where('name', 'OOS-HTTP')->firstOrFail();
expect($product->out_of_stock_until)->not->toBeNull();
expect($product->out_of_stock_until->isSameDay(now()->addDays(7)))->toBeTrue();
expect($product->isOutOfStock())->toBeTrue();
});

View file

@ -212,7 +212,7 @@ test('produktion kann bearbeitet und kopiert werden (views rendern)', function (
'pos' => 0, 'pos' => 0,
'gram' => 10, 'gram' => 10,
'factor' => 1.0, 'factor' => 1.0,
'recipe_type' => 'product', 'recipe_type' => 'manufacturer',
]); ]);
$stock = StockEntry::query()->create([ $stock = StockEntry::query()->create([

View file

@ -0,0 +1,272 @@
<?php
use App\Models\Country;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\PackagingItem;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Repositories\ProductRepository;
use App\Services\ProductionService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function setMakeUser(int $adminLevel = 1): User
{
$user = User::query()->create([
'email' => uniqid('set_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function setMakeProduct(string $name, bool $active = true): Product
{
return Product::query()->create([
'name' => $name,
'title' => $name,
'active' => $active,
]);
}
test('repository speichert ein set mit bestandteilen und mengen', function () {
$set = setMakeProduct('SET-Bundle');
$a = setMakeProduct('SET-Einzel-A');
$b = setMakeProduct('SET-Einzel-B');
$repo = new ProductRepository($set);
$repo->update([
'id' => (string) $set->id,
'name' => $set->name,
'title' => $set->title,
'active' => '1',
'is_set' => '1',
'set_component_id' => [(string) $a->id, (string) $b->id],
'set_quantity' => ['2', '3'],
]);
$set->refresh();
expect($set->is_set)->toBeTrue();
expect($set->setItems)->toHaveCount(2);
expect((int) $set->setItems->firstWhere('id', $a->id)->pivot->quantity)->toBe(2);
expect((int) $set->setItems->firstWhere('id', $b->id)->pivot->quantity)->toBe(3);
});
test('set leert rezeptur und verpackung', function () {
$ing = Ingredient::query()->create([
'name' => 'SET-Roh', 'trans_name' => '', 'inci' => 'SR', 'trans_inci' => '',
'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0,
]);
$packaging = PackagingItem::factory()->create();
$component = setMakeProduct('SET-Komp');
$product = setMakeProduct('SET-Wandelbar');
ProductIngredient::query()->create([
'product_id' => $product->id, 'ingredient_id' => $ing->id,
'pos' => 0, 'gram' => 50, 'factor' => 1.1, 'recipe_type' => 'manufacturer',
]);
$product->packagings()->attach($packaging->id, ['quantity' => 1, 'pos' => 0]);
$repo = new ProductRepository($product);
$repo->update([
'id' => (string) $product->id,
'name' => $product->name,
'title' => $product->title,
'active' => '1',
'is_set' => '1',
'set_component_id' => [(string) $component->id],
'set_quantity' => ['1'],
]);
$product->refresh();
expect($product->is_set)->toBeTrue();
expect($product->manufacturer_ingredients)->toHaveCount(0);
expect($product->packagings)->toHaveCount(0);
expect($product->setItems)->toHaveCount(1);
});
test('hauptprodukt-zuordnung wird gespeichert und bei set genullt', function () {
$main = setMakeProduct('SET-Haupt');
$variant = setMakeProduct('SET-Variante');
$repo = new ProductRepository($variant);
$repo->update([
'id' => (string) $variant->id,
'name' => $variant->name,
'title' => $variant->title,
'active' => '1',
'main_product_id' => (string) $main->id,
'main_product_quantity' => '50',
]);
$variant->refresh();
expect($variant->main_product_id)->toBe($main->id);
expect($variant->main_product_quantity)->toBe(50);
// Wird das Produkt zum Set, ist es keine Variante mehr.
$repo->update([
'id' => (string) $variant->id,
'name' => $variant->name,
'title' => $variant->title,
'active' => '1',
'is_set' => '1',
'main_product_id' => (string) $main->id,
'set_component_id' => [(string) $main->id],
'set_quantity' => ['1'],
]);
$variant->refresh();
expect($variant->main_product_id)->toBeNull();
});
test('scopes trennen einzelprodukte, sets und hauptprodukte', function () {
$single = setMakeProduct('SCOPE-Einzel');
$set = setMakeProduct('SCOPE-Set');
$set->update(['is_set' => true]);
$variant = setMakeProduct('SCOPE-Variante');
$variant->update(['main_product_id' => $single->id]);
expect(Product::query()->singleProducts()->pluck('id')->all())
->toContain($single->id, $variant->id)
->not->toContain($set->id);
expect(Product::query()->sets()->pluck('id')->all())
->toContain($set->id)
->not->toContain($single->id);
expect(Product::query()->mainProducts()->pluck('id')->all())
->toContain($single->id, $set->id)
->not->toContain($variant->id);
});
test('set kann nicht produziert werden', function () {
$location = Location::factory()->create();
$set = setMakeProduct('SET-NichtProduzierbar');
$set->update(['is_set' => true]);
$service = app(ProductionService::class);
expect(fn () => $service->store(
[
'product_id' => $set->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 1,
'notes' => null,
],
[],
setMakeUser()->id
))->toThrow(ValidationException::class);
});
test('produktions-formular zeigt keine sets', function () {
p51EnsureCountryForSet();
Location::factory()->create(['name' => 'Köln']);
$user = setMakeUser(1);
setMakeProduct('SET-FORM-EINZEL');
$set = setMakeProduct('SET-FORM-SET');
$set->update(['is_set' => true]);
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.productions.create'));
$response->assertSuccessful();
$response->assertSee('SET-FORM-EINZEL');
$response->assertDontSee('SET-FORM-SET');
});
test('set ohne bestandteile wird abgelehnt', function () {
$user = setMakeUser(1);
$this->actingAs($user, 'user');
$response = $this->post(route('admin_product_store'), [
'id' => 'new',
'name' => 'SET-LEER',
'is_set' => '1',
]);
$response->assertSuccessful();
$response->assertSee('mindestens ein Einzelprodukt', false);
expect(Product::query()->where('name', 'SET-LEER')->exists())->toBeFalse();
});
test('set mit set-bestandteil wird abgelehnt', function () {
$user = setMakeUser(1);
$this->actingAs($user, 'user');
$innerSet = setMakeProduct('SET-INNEN');
$innerSet->update(['is_set' => true]);
$response = $this->post(route('admin_product_store'), [
'id' => 'new',
'name' => 'SET-AUSSEN',
'is_set' => '1',
'set_component_id' => [(string) $innerSet->id],
'set_quantity' => ['1'],
]);
$response->assertSuccessful();
$response->assertSee('dürfen selbst keine Sets sein', false);
});
test('gueltiges set wird per http gespeichert', function () {
$user = setMakeUser(1);
$this->actingAs($user, 'user');
$component = setMakeProduct('SET-HTTP-KOMP');
$response = $this->post(route('admin_product_store'), [
'id' => 'new',
'name' => 'SET-HTTP',
'is_set' => '1',
'set_component_id' => [(string) $component->id],
'set_quantity' => ['4'],
]);
$response->assertRedirect();
$set = Product::query()->where('name', 'SET-HTTP')->firstOrFail();
expect($set->is_set)->toBeTrue();
expect($set->setItems)->toHaveCount(1);
expect((int) $set->setItems->first()->pivot->quantity)->toBe(4);
});
test('produkt-kopie uebernimmt set-bestandteile', function () {
$component = setMakeProduct('COPY-KOMP');
$set = setMakeProduct('COPY-SET');
$set->update(['is_set' => true]);
$set->setItems()->attach($component->id, ['quantity' => 5, 'pos' => 0]);
$repo = new ProductRepository(new Product);
$copy = $repo->copy($set->fresh());
expect($copy->is_set)->toBeTrue();
expect($copy->setItems()->count())->toBe(1);
expect((int) $copy->setItems()->first()->pivot->quantity)->toBe(5);
});
function p51EnsureCountryForSet(): Country
{
return Country::query()->firstOrCreate(
['code' => 'DE'],
[
'phone' => '00', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Germany',
'fr' => 'Germany', 'it' => 'Germany', 'ru' => 'Germany', 'active' => true,
]
);
}

View file

@ -0,0 +1,191 @@
<?php
use App\Models\Location;
use App\Models\Product;
use App\Models\ProductStockMovement;
use App\Services\ProductionService;
use App\Services\ProductStockService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap11MakeUser(int $adminLevel = 7): User
{
$user = User::query()->create([
'email' => uniqid('ap11_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
/**
* @param array<string, mixed> $overrides
*/
function ap11MakeProduct(string $name, array $overrides = []): Product
{
return Product::query()->create(array_merge([
'name' => $name,
'title' => $name,
'active' => true,
'is_set' => false,
], $overrides));
}
test('bestand ist summe eingaenge minus ausgaenge', function () {
$product = ap11MakeProduct('AP11-Bestand');
$service = app(ProductStockService::class);
$service->recordMovement($product, 'in', 100, 'Initialbestand');
$service->recordMovement($product, 'out', 30, 'Verkauf');
$service->recordMovement($product, 'in', 5, 'Retoure');
expect($service->currentStock($product->id))->toBe(75);
});
test('status liefert kritisch, warnung und ok', function () {
$service = app(ProductStockService::class);
expect($service->productStatus(5, 20, 10))->toBe('critical')
->and($service->productStatus(15, 20, 10))->toBe('warning')
->and($service->productStatus(30, 20, 10))->toBe('ok')
->and($service->productStatus(0, null, null))->toBe('ok');
});
test('kritisch-zaehler beruecksichtigt nur hauptprodukte mit schwellwert', function () {
$service = app(ProductStockService::class);
$critical = ap11MakeProduct('AP11-Krit', ['critical_product_stock' => 10]);
$service->recordMovement($critical, 'in', 5, 'Initialbestand');
$ok = ap11MakeProduct('AP11-OK', ['critical_product_stock' => 10]);
$ok->stockMovements()->create(['direction' => 'in', 'quantity' => 50, 'reason' => 'Initialbestand', 'source' => 'manual']);
// ohne Schwellwert => nie kritisch
ap11MakeProduct('AP11-NoThreshold');
// Set wird ignoriert
ap11MakeProduct('AP11-Set', ['is_set' => true, 'critical_product_stock' => 10]);
expect($service->criticalProductCount())->toBe(1);
});
test('produktion bucht produktbestand als eingang', function () {
$user = ap11MakeUser();
$location = Location::factory()->create();
$product = ap11MakeProduct('AP11-Produktion', ['no_recipe_required' => true]);
app(ProductionService::class)->store([
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 40,
'notes' => null,
], [], $user->id);
expect(app(ProductStockService::class)->currentStock($product->id))->toBe(40);
$movement = ProductStockMovement::query()->where('product_id', $product->id)->where('source', 'production')->first();
expect($movement)->not->toBeNull()
->and($movement->direction)->toBe('in')
->and((int) $movement->quantity)->toBe(40);
});
test('produktions-update korrigiert den produktbestand per differenz', function () {
$user = ap11MakeUser();
$location = Location::factory()->create();
$product = ap11MakeProduct('AP11-ProdUpdate', ['no_recipe_required' => true]);
$production = app(ProductionService::class)->store([
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 40,
'notes' => null,
], [], $user->id);
app(ProductionService::class)->updateProduction($production, [
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 25,
'notes' => null,
], [], $user->id);
expect(app(ProductStockService::class)->currentStock($product->id))->toBe(25);
// append-only: zwei Bewegungen (Produktion + Korrektur)
expect(ProductStockMovement::query()->where('product_id', $product->id)->count())->toBe(2);
});
test('uebersicht zeigt produkt und bestand', function () {
$user = ap11MakeUser(1);
$product = ap11MakeProduct('AP11-Liste', ['critical_product_stock' => 10]);
app(ProductStockService::class)->recordMovement($product, 'in', 13, 'Initialbestand');
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.product-stock.index'));
$response->assertSuccessful();
$response->assertSee('Produktbestand');
$response->assertSee('AP11-Liste');
$response->assertSee('nur kritische anzeigen');
});
test('manuelle bewegung wird per http gebucht', function () {
$user = ap11MakeUser(7);
$product = ap11MakeProduct('AP11-HTTP');
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.product-stock.movement', $product), [
'direction' => 'in',
'quantity' => 12,
'reason' => 'Initialbestand',
'note' => 'Erstbefüllung',
]);
$response->assertRedirect();
$this->assertDatabaseHas('product_stock_movements', [
'product_id' => $product->id,
'direction' => 'in',
'quantity' => 12,
'reason' => 'Initialbestand',
]);
});
test('nicht-admin darf keine bewegung buchen', function () {
$user = ap11MakeUser(1);
$product = ap11MakeProduct('AP11-NoAdmin');
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.product-stock.movement', $product), [
'direction' => 'in',
'quantity' => 5,
'reason' => 'Initialbestand',
]);
$response->assertForbidden();
});
test('historie rendert und filtert nach richtung', function () {
$user = ap11MakeUser(1);
$product = ap11MakeProduct('AP11-Hist');
$service = app(ProductStockService::class);
$service->recordMovement($product, 'in', 50, 'Produktion', 'production');
$service->recordMovement($product, 'out', 2, 'Verkauf', 'sale');
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.product-stock.history', ['direction' => 'out']));
$response->assertSuccessful();
$response->assertSee('Produktbestand Historie');
$response->assertSee('Verkauf');
});

View file

@ -0,0 +1,375 @@
<?php
use App\Models\Country;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\ProductionService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap09MakeUser(int $adminLevel = 7): User
{
$user = User::query()->create([
'email' => uniqid('ap09_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function ap09EnsureCountry(): Country
{
return Country::query()->firstOrCreate(
['code' => 'DE'],
[
'phone' => '00',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Germany',
'fr' => 'Germany',
'it' => 'Germany',
'ru' => 'Germany',
'active' => true,
]
);
}
function ap09MakeIngredient(string $name): Ingredient
{
return Ingredient::query()->create([
'name' => $name,
'trans_name' => '',
'inci' => $name,
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
}
/**
* @param array<string, mixed> $overrides
*/
function ap09MakeStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
{
return StockEntry::query()->create(array_merge([
'entry_type' => 'ingredient',
'ingredient_id' => $ing->id,
'packaging_item_id' => null,
'supplier_id' => $supplier->id,
'location_id' => $location->id,
'unit' => 'gram',
'ordered_by' => $user->id,
'ordered_at' => '2026-01-01',
'ordered_quantity' => 10000,
'price_per_kg' => 1,
'status' => 'received',
'received_by' => $user->id,
'received_at' => '2026-01-15',
'received_quantity' => 10000,
'batch_number' => 'AP09-B',
'best_before' => '2030-12-31',
'quality_id' => null,
], $overrides));
}
test('production rechnet soll-verbrauch aus hersteller-rezeptur', function () {
ap09EnsureCountry();
$user = ap09MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap09MakeIngredient('AP09-Soll');
$product = Product::query()->create([
'name' => 'AP09-Soll-Produkt',
'title' => 'AP09-Soll-Produkt',
'active' => true,
]);
// Produkt-Rezeptur (darf NICHT verwendet werden)
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 999,
'factor' => 1.0,
'recipe_type' => 'product',
]);
// Hersteller-Rezeptur (maßgeblich): 10 g * 1.2 * 5 Stück = 60 g
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1.2,
'recipe_type' => 'manufacturer',
]);
$stock = ap09MakeStock($ing, $location, $supplier, $user);
$production = app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 5,
'notes' => null,
],
[
['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '60'],
],
$user->id
);
expect((float) $production->productionIngredients->first()->quantity_used)->toBe(60.0);
});
test('production blockiert ohne hersteller-rezeptur trotz vorhandener produkt-rezeptur', function () {
ap09EnsureCountry();
$user = ap09MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap09MakeIngredient('AP09-NoMfg');
$product = Product::query()->create([
'name' => 'AP09-NoMfg-Produkt',
'title' => 'AP09-NoMfg-Produkt',
'active' => true,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1.0,
'recipe_type' => 'product',
]);
$stock = ap09MakeStock($ing, $location, $supplier, $user);
expect(fn () => app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 1,
'notes' => null,
],
[
['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '10'],
],
$user->id
))->toThrow(ValidationException::class);
});
test('recipe json meldet fehlende hersteller-rezeptur', function () {
ap09EnsureCountry();
$user = ap09MakeUser(1);
$location = Location::factory()->create();
$product = Product::query()->create([
'name' => 'AP09-Recipe-Empty',
'title' => 'AP09-Recipe-Empty',
'active' => true,
]);
$this->actingAs($user, 'user');
$response = $this->getJson(route('admin.inventory.api.products.recipe', ['product' => $product->id]).'?location_id='.$location->id.'&quantity=1');
$response->assertSuccessful();
$response->assertJsonPath('has_recipe', false);
expect($response->json('ingredients'))->toBe([]);
});
test('chargen ohne restbestand erscheinen nicht im rezept', function () {
ap09EnsureCountry();
$user = ap09MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap09MakeIngredient('AP09-Rest');
$product = Product::query()->create([
'name' => 'AP09-Rest-Produkt',
'title' => 'AP09-Rest-Produkt',
'active' => true,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 100,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
]);
// Charge komplett aufgebraucht
$emptyStock = ap09MakeStock($ing, $location, $supplier, $user, [
'batch_number' => 'AP09-LEER',
'received_quantity' => 100,
'best_before' => '2030-01-01',
]);
// Charge mit Restbestand
$fullStock = ap09MakeStock($ing, $location, $supplier, $user, [
'batch_number' => 'AP09-VOLL',
'received_quantity' => 100,
'best_before' => '2030-06-01',
]);
// 1 Produktion verbraucht die erste Charge vollständig (100 g * 1.0 * 1 Stück)
app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 1,
'notes' => null,
],
[
['ingredient_id' => $ing->id, 'stock_entry_id' => $emptyStock->id, 'quantity_used' => '100'],
],
$user->id
);
$payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1);
$entryIds = collect($payload['ingredients'][0]['stock_entries'])->pluck('id')->all();
expect($entryIds)->toContain($fullStock->id);
expect($entryIds)->not->toContain($emptyStock->id);
});
test('chargen-label zeigt lieferant, charge und datum ohne mhd-text', function () {
ap09EnsureCountry();
$user = ap09MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create(['name' => 'AP09-Lieferant']);
$ing = ap09MakeIngredient('AP09-Label');
$product = Product::query()->create([
'name' => 'AP09-Label-Produkt',
'title' => 'AP09-Label-Produkt',
'active' => true,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
]);
ap09MakeStock($ing, $location, $supplier, $user, [
'batch_number' => 'CHARGE-123',
'best_before' => '2030-12-31',
]);
$payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1);
$label = $payload['ingredients'][0]['stock_entries'][0]['label'];
expect($label)->toBe('AP09-Lieferant - CHARGE-123 - 31.12.2030');
expect($label)->not->toContain('MHD');
});
test('produkt ohne rezeptur kann ohne chargen produziert werden', function () {
ap09EnsureCountry();
$user = ap09MakeUser();
$location = Location::factory()->create();
$product = Product::query()->create([
'name' => 'AP09-Broschuere',
'title' => 'AP09-Broschuere',
'active' => true,
'no_recipe_required' => true,
]);
$production = app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-03-01',
'quantity' => 25,
'notes' => null,
],
[],
$user->id
);
expect($production->productionIngredients)->toHaveCount(0);
expect($production->quantity)->toBe(25);
});
test('recipe json meldet recipe_required false bei eigenprodukt', function () {
ap09EnsureCountry();
$user = ap09MakeUser(1);
$location = Location::factory()->create();
$product = Product::query()->create([
'name' => 'AP09-Etikett',
'title' => 'AP09-Etikett',
'active' => true,
'no_recipe_required' => true,
]);
$this->actingAs($user, 'user');
$response = $this->getJson(route('admin.inventory.api.products.recipe', ['product' => $product->id]).'?location_id='.$location->id.'&quantity=10');
$response->assertSuccessful();
$response->assertJsonPath('recipe_required', false);
});
test('produktion per http speichert eigenprodukt ohne chargen', function () {
ap09EnsureCountry();
$user = ap09MakeUser();
$location = Location::factory()->create();
$product = Product::query()->create([
'name' => 'AP09-HTTP-Eigen',
'title' => 'AP09-HTTP-Eigen',
'active' => true,
'no_recipe_required' => true,
]);
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.productions.store'), [
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '01.03.2026',
'quantity' => 12,
]);
$response->assertRedirect();
$this->assertDatabaseHas('productions', [
'product_id' => $product->id,
'quantity' => 12,
]);
});
test('produktentwicklung platzhalter rendert briefing-hinweis', function () {
ap09EnsureCountry();
$user = ap09MakeUser(1);
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.product-development'));
$response->assertSuccessful();
$response->assertSee('Briefing ausstehend');
});

View file

@ -81,6 +81,7 @@ test('production service stores ingredients and packaging snapshot', function ()
'pos' => 0, 'pos' => 0,
'gram' => 10, 'gram' => 10,
'factor' => 1.0, 'factor' => 1.0,
'recipe_type' => 'manufacturer',
]); ]);
$material = PackagingMaterial::factory()->create(); $material = PackagingMaterial::factory()->create();
@ -168,6 +169,7 @@ test('production service rejects wrong gram sum', function () {
'pos' => 0, 'pos' => 0,
'gram' => 10, 'gram' => 10,
'factor' => 1.0, 'factor' => 1.0,
'recipe_type' => 'manufacturer',
]); ]);
$stock = StockEntry::query()->create([ $stock = StockEntry::query()->create([
@ -240,6 +242,7 @@ test('production api recipe json returns ingredients and packagings', function (
'pos' => 0, 'pos' => 0,
'gram' => 2, 'gram' => 2,
'factor' => 1.5, 'factor' => 1.5,
'recipe_type' => 'manufacturer',
]); ]);
StockEntry::query()->create([ StockEntry::query()->create([
@ -300,6 +303,7 @@ test('production mhd warning when stock expires before product end', function ()
'pos' => 0, 'pos' => 0,
'gram' => 1, 'gram' => 1,
'factor' => 1.0, 'factor' => 1.0,
'recipe_type' => 'manufacturer',
]); ]);
$stock = StockEntry::query()->create([ $stock = StockEntry::query()->create([

View file

@ -0,0 +1,269 @@
<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\Product;
use App\Models\Production;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\InventoryService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap10MakeUser(int $adminLevel = 7): User
{
$user = User::query()->create([
'email' => uniqid('ap10_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function ap10MakeIngredient(string $name, ?float $minStockAlert = null): Ingredient
{
return Ingredient::query()->create([
'name' => $name,
'trans_name' => '',
'inci' => $name,
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
'min_stock_alert' => $minStockAlert,
]);
}
/**
* @param array<string, mixed> $overrides
*/
function ap10MakeStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
{
return StockEntry::query()->create(array_merge([
'entry_type' => 'ingredient',
'ingredient_id' => $ing->id,
'supplier_id' => $supplier->id,
'location_id' => $location->id,
'unit' => 'gram',
'ordered_by' => $user->id,
'ordered_at' => '2026-01-01',
'ordered_quantity' => 10000,
'price_per_kg' => 15,
'status' => 'received',
'received_by' => $user->id,
'received_at' => '2026-01-15',
'received_quantity' => 10000,
'batch_number' => 'AP10-B',
'best_before' => '2030-12-31',
], $overrides));
}
function ap10ConsumeViaProduction(Ingredient $ing, StockEntry $stock, Location $location, User $user, float $grams, string $producedAt): void
{
$product = Product::query()->create([
'name' => 'AP10-Produkt-'.uniqid(),
'title' => 'AP10-Produkt',
'active' => true,
]);
$production = Production::query()->create([
'product_id' => $product->id,
'location_id' => $location->id,
'produced_by' => $user->id,
'produced_at' => $producedAt,
'quantity' => 1,
'mhd_warning' => false,
]);
$production->productionIngredients()->create([
'ingredient_id' => $ing->id,
'stock_entry_id' => $stock->id,
'quantity_used' => $grams,
]);
}
test('restbestand ist wareneingang abzueglich produktionsverbrauch', function () {
$user = ap10MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap10MakeIngredient('AP10-Rest');
$stock = ap10MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 10000]);
ap10ConsumeViaProduction($ing, $stock, $location, $user, 2500, now()->subDays(10)->toDateString());
$remaining = app(InventoryService::class)->remainingByIngredient([$ing->id]);
expect($remaining[$ing->id])->toBe(7500.0);
});
test('verbrauch pro tag wird aus produktionshistorie gemittelt', function () {
$user = ap10MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap10MakeIngredient('AP10-Verbrauch');
$stock = ap10MakeStock($ing, $location, $supplier, $user);
// 9000 g innerhalb des 90-Tage-Fensters => 100 g/Tag
ap10ConsumeViaProduction($ing, $stock, $location, $user, 9000, now()->subDays(5)->toDateString());
$daily = app(InventoryService::class)->dailyConsumptionByIngredient([$ing->id], 90);
expect($daily[$ing->id])->toBe(100.0);
});
test('verbrauch ausserhalb des fensters zaehlt nicht', function () {
$user = ap10MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap10MakeIngredient('AP10-Alt');
$stock = ap10MakeStock($ing, $location, $supplier, $user);
ap10ConsumeViaProduction($ing, $stock, $location, $user, 9000, now()->subDays(200)->toDateString());
$daily = app(InventoryService::class)->dailyConsumptionByIngredient([$ing->id], 90);
expect($daily[$ing->id] ?? 0.0)->toBe(0.0);
});
test('status meldet kritisch bei unterschrittenem meldebestand', function () {
$service = app(InventoryService::class);
expect($service->stockStatus(1000.0, 500.0, null, null))->toBe('critical')
->and($service->stockStatus(1000.0, 1500.0, 5.0, 30))->toBe('ok');
});
test('status meldet warnung wenn bestand vor lieferung leer ist', function () {
$service = app(InventoryService::class);
// 200 g Rest, 20 g/Tag => 10 Tage Reichweite, Lieferzeit 14 Tage => Warnung
expect($service->stockStatus(null, 200.0, 20.0, 14))->toBe('warning');
});
test('kritisch-zaehler zaehlt nur unterschrittene meldebestaende', function () {
$user = ap10MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$critical = ap10MakeIngredient('AP10-Kritisch', 5000);
ap10MakeStock($critical, $location, $supplier, $user, ['received_quantity' => 1000]);
$ok = ap10MakeIngredient('AP10-OK', 500);
ap10MakeStock($ok, $location, $supplier, $user, ['received_quantity' => 9000]);
// ohne Meldebestand => nie kritisch
$noAlert = ap10MakeIngredient('AP10-OhneMelde');
ap10MakeStock($noAlert, $location, $supplier, $user, ['received_quantity' => 0]);
expect(app(InventoryService::class)->criticalIngredientCount())->toBe(1);
});
test('offene bestellungen werden je rohstoff summiert', function () {
$user = ap10MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap10MakeIngredient('AP10-Offen');
ap10MakeStock($ing, $location, $supplier, $user, ['status' => 'pending', 'ordered_quantity' => 200, 'received_quantity' => null]);
ap10MakeStock($ing, $location, $supplier, $user, ['status' => 'pending', 'ordered_quantity' => 300, 'received_quantity' => null]);
$open = app(InventoryService::class)->openOrderQuantityByIngredient([$ing->id]);
expect($open[$ing->id])->toBe(500.0);
});
test('kritischer rohstoff mit offener bestellung wird entschaerft', function () {
$service = app(InventoryService::class);
expect($service->stockStatus(1000.0, 500.0, null, null, true))->toBe('critical_ordered')
->and($service->stockStatus(1000.0, 500.0, null, null, false))->toBe('critical');
});
test('kritisch-zaehler ignoriert rohstoffe mit offener bestellung', function () {
$user = ap10MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$critical = ap10MakeIngredient('AP10-KritischOffen', 5000);
ap10MakeStock($critical, $location, $supplier, $user, ['received_quantity' => 1000]);
// offene Nachbestellung => entschärft
ap10MakeStock($critical, $location, $supplier, $user, ['status' => 'pending', 'ordered_quantity' => 8000, 'received_quantity' => null]);
expect(app(InventoryService::class)->criticalIngredientCount())->toBe(0);
});
test('detailansicht zeigt offene bestellung', function () {
$user = ap10MakeUser(1);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create(['name' => 'AP10-OffenLieferant']);
$ing = ap10MakeIngredient('AP10-OffenDetail');
ap10MakeStock($ing, $location, $supplier, $user, ['status' => 'pending', 'ordered_quantity' => 750, 'received_quantity' => null]);
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.raw-material-stock.show', $ing));
$response->assertSuccessful();
$response->assertSee('Offene Bestellungen');
$response->assertSee('AP10-OffenLieferant');
});
test('uebersicht rendert rohstoff mit bestand', function () {
$user = ap10MakeUser(1);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap10MakeIngredient('AP10-Sheabutter', 5000);
ap10MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 2578]);
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.raw-material-stock.index'));
$response->assertSuccessful();
$response->assertSee('Rohstoffbestand');
$response->assertSee('AP10-Sheabutter');
$response->assertSee('nur kritische anzeigen');
});
test('einkauf-formular ist mit rohstoff und inhaltsstoff vorbelegt', function () {
$user = ap10MakeUser(7);
$ing = ap10MakeIngredient('AP10-Vorbelegt');
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.stock-entries.create', ['ingredient_id' => $ing->id]));
$response->assertSuccessful();
$response->assertSee('AP10-Vorbelegt');
// Art "Rohstoff" ist als Standard ausgewählt
$response->assertSee('value="ingredient" selected', false);
});
test('detailansicht zeigt charge und lieferant', function () {
$user = ap10MakeUser(1);
$location = Location::factory()->create(['name' => 'AP10-Lager']);
$supplier = Supplier::factory()->create(['name' => 'AP10-Lieferant']);
$ing = ap10MakeIngredient('AP10-Detail');
$ing->suppliers()->attach($supplier->id, ['preferred' => true]);
ap10MakeStock($ing, $location, $supplier, $user, [
'batch_number' => 'AP10-CHARGE-7',
'received_quantity' => 4000,
]);
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.raw-material-stock.show', $ing));
$response->assertSuccessful();
$response->assertSee('AP10-Detail');
$response->assertSee('AP10-CHARGE-7');
$response->assertSee('AP10-Lieferant');
});

View file

@ -0,0 +1,222 @@
<?php
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\PackagingItem;
use App\Models\StockDisposal;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\InventoryService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap12MakeUser(int $adminLevel = 7): User
{
$user = User::query()->create([
'email' => uniqid('ap12_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function ap12MakeIngredient(string $name, ?float $minStockAlert = null): Ingredient
{
return Ingredient::query()->create([
'name' => $name,
'trans_name' => '',
'inci' => $name,
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
'min_stock_alert' => $minStockAlert,
]);
}
/**
* @param array<string, mixed> $overrides
*/
function ap12MakeStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
{
return StockEntry::query()->create(array_merge([
'entry_type' => 'ingredient',
'ingredient_id' => $ing->id,
'supplier_id' => $supplier->id,
'location_id' => $location->id,
'unit' => 'gram',
'ordered_by' => $user->id,
'ordered_at' => '2026-01-01',
'ordered_quantity' => 10000,
'price_per_kg' => 15,
'status' => 'received',
'received_by' => $user->id,
'received_at' => '2026-01-15',
'received_quantity' => 10000,
'batch_number' => 'AP12-B',
'best_before' => '2030-12-31',
], $overrides));
}
test('ausschuss wird je rohstoff summiert', function () {
$location = Location::factory()->create();
$ing = ap12MakeIngredient('AP12-Sum');
StockDisposal::query()->create([
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
'quantity' => 300, 'unit' => 'gram', 'reason' => 'Bruch', 'disposed_at' => '2026-03-01',
]);
StockDisposal::query()->create([
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
'quantity' => 200, 'unit' => 'gram', 'reason' => 'Verfall', 'disposed_at' => '2026-03-02',
]);
expect(app(InventoryService::class)->disposedByIngredient([$ing->id])[$ing->id])->toBe(500.0);
});
test('restbestand zieht ausschuss ab', function () {
$user = ap12MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap12MakeIngredient('AP12-Rest');
ap12MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 10000]);
$service = app(InventoryService::class);
expect($service->remainingByIngredient([$ing->id])[$ing->id])->toBe(10000.0);
StockDisposal::query()->create([
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
'quantity' => 1500, 'unit' => 'gram', 'reason' => 'Bruch', 'disposed_at' => '2026-03-01',
]);
expect($service->remainingByIngredient([$ing->id])[$ing->id])->toBe(8500.0)
->and($service->remainingByLocationForIngredient($ing->id)[$location->id])->toBe(8500.0);
});
test('restbestand verpackung zieht ausschuss ab', function () {
$user = ap12MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$item = PackagingItem::factory()->create();
StockEntry::query()->create([
'entry_type' => 'packaging', 'packaging_item_id' => $item->id, 'supplier_id' => $supplier->id,
'location_id' => $location->id, 'unit' => 'piece', 'ordered_by' => $user->id, 'ordered_at' => '2026-01-01',
'ordered_quantity' => 500, 'price_total' => 100, 'status' => 'received', 'received_by' => $user->id,
'received_at' => '2026-01-15', 'received_quantity' => 500,
]);
$service = app(InventoryService::class);
expect($service->remainingByPackagingItem([$item->id])[$item->id])->toBe(500.0);
StockDisposal::query()->create([
'disposal_type' => 'packaging', 'packaging_item_id' => $item->id, 'location_id' => $location->id,
'quantity' => 40, 'unit' => 'piece', 'reason' => 'Bruch', 'disposed_at' => '2026-03-01',
]);
expect($service->remainingByPackagingItem([$item->id])[$item->id])->toBe(460.0);
});
test('admin bucht ausschuss per http und bestand sinkt', function () {
$user = ap12MakeUser(7);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap12MakeIngredient('AP12-HTTP');
ap12MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]);
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
'disposal_type' => 'ingredient',
'ingredient_id' => $ing->id,
'location_id' => $location->id,
'quantity' => '750',
'reason' => 'Verfall / MHD überschritten',
'note' => 'Charge verdorben',
'disposed_at' => '2026-03-10',
]);
$response->assertRedirect();
$this->assertDatabaseHas('stock_disposals', [
'ingredient_id' => $ing->id,
'quantity' => 750.00,
'reason' => 'Verfall / MHD überschritten',
'unit' => 'gram',
]);
expect(app(InventoryService::class)->remainingByIngredient([$ing->id])[$ing->id])->toBe(4250.0);
});
test('grund ist pflicht', function () {
$user = ap12MakeUser(7);
$location = Location::factory()->create();
$ing = ap12MakeIngredient('AP12-NoReason');
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
'disposal_type' => 'ingredient',
'ingredient_id' => $ing->id,
'location_id' => $location->id,
'quantity' => '100',
'disposed_at' => '2026-03-10',
]);
$response->assertSessionHasErrors('reason');
});
test('nicht-admin darf keinen ausschuss buchen', function () {
$user = ap12MakeUser(1);
$location = Location::factory()->create();
$ing = ap12MakeIngredient('AP12-NoAdmin');
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.stock-disposals.store'), [
'disposal_type' => 'ingredient',
'ingredient_id' => $ing->id,
'location_id' => $location->id,
'quantity' => '100',
'reason' => 'Bruch / Beschädigung',
'disposed_at' => '2026-03-10',
]);
$response->assertForbidden();
});
test('liste und formular rendern', function () {
$user = ap12MakeUser(7);
$location = Location::factory()->create();
$ing = ap12MakeIngredient('AP12-Render');
StockDisposal::query()->create([
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $location->id,
'quantity' => 50, 'unit' => 'gram', 'reason' => 'Bruch / Beschädigung', 'disposed_at' => '2026-03-01',
]);
$this->actingAs($user, 'user');
$this->get(route('admin.inventory.stock-disposals.index'))->assertSuccessful()->assertSee('Ausgang / Ausschuss');
$this->get(route('admin.inventory.stock-disposals.create', ['ingredient_id' => $ing->id]))
->assertSuccessful()
->assertSee('AP12-Render');
});
test('chargen-endpoint liefert eingegangene chargen', function () {
$user = ap12MakeUser(7);
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap12MakeIngredient('AP12-Charges');
ap12MakeStock($ing, $location, $supplier, $user, ['batch_number' => 'CHG-1']);
$this->actingAs($user, 'user');
$response = $this->getJson(route('admin.inventory.api.disposals.ingredient-charges', $ing));
$response->assertSuccessful();
$response->assertJsonFragment(['location_id' => $location->id]);
});