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:
parent
78679e0c55
commit
3ee2d756e9
63 changed files with 5968 additions and 901 deletions
|
|
@ -6,13 +6,6 @@
|
||||||
"artisan",
|
"artisan",
|
||||||
"boost:mcp"
|
"boost:mcp"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"sequential-thinking": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@modelcontextprotocol/server-sequential-thinking"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
21
app/Http/Controllers/Admin/Inventory/NoticeController.php
Normal file
21
app/Http/Controllers/Admin/Inventory/NoticeController.php
Normal 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/Http/Controllers/Admin/Inventory/ProductStockController.php
Normal file
142
app/Http/Controllers/Admin/Inventory/ProductStockController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/Http/Controllers/Admin/Inventory/StockDisposalController.php
Normal file
125
app/Http/Controllers/Admin/Inventory/StockDisposalController.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$model = new StockEntry([
|
||||||
|
'ordered_at' => now()->toDateString(),
|
||||||
|
'entry_type' => 'ingredient',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ingredientId = (int) $request->query('ingredient_id');
|
||||||
|
if ($ingredientId > 0) {
|
||||||
|
$ingredient = Ingredient::query()->find($ingredientId);
|
||||||
|
if ($ingredient) {
|
||||||
|
$model->entry_type = 'ingredient';
|
||||||
|
$model->ingredient_id = $ingredient->id;
|
||||||
|
$model->setRelation('ingredient', $ingredient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return view('admin.inventory.stock-entries.create', array_merge($this->formSharedData(), [
|
return view('admin.inventory.stock-entries.create', array_merge($this->formSharedData(), [
|
||||||
'model' => new StockEntry([
|
'model' => $model,
|
||||||
'ordered_at' => now()->toDateString(),
|
|
||||||
'entry_type' => 'ingredient',
|
|
||||||
]),
|
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,54 @@
|
||||||
<?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');
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
$shopping_user = Shop::checkShoppingUser($id, $user);
|
$shopping_user = Shop::checkShoppingUser($id, $user);
|
||||||
$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]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(Request::get('action') === 'next'){
|
if (Request::get('action') === 'next') {
|
||||||
Yard::instance('shopping')->destroy();
|
Yard::instance('shopping')->destroy();
|
||||||
if(strpos(Request::get('switchers-radio-is-for'), 'ot') !== false){
|
if (strpos(Request::get('switchers-radio-is-for'), 'ot') !== false) {
|
||||||
$delivery_id = $id;
|
$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,39 +58,41 @@ 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;
|
||||||
|
|
||||||
if(strpos($for, 'ot') !== false){
|
if (strpos($for, 'ot') !== false) {
|
||||||
$shopping_user = Shop::checkShoppingUser($id, $user);
|
$shopping_user = Shop::checkShoppingUser($id, $user);
|
||||||
$delivery_id = $shopping_user->id;
|
$delivery_id = $shopping_user->id;
|
||||||
}
|
}
|
||||||
if($for === 'ot-customer'){ //noch nicht implementiert
|
if ($for === 'ot-customer') { // noch nicht implementiert
|
||||||
//Liederung an ot-customer (Kunden) Zahlung und Rechnung geht an Kunden
|
// Liederung an ot-customer (Kunden) Zahlung und Rechnung geht an Kunden
|
||||||
UserService::initCustomerYard($shopping_user, $for);
|
UserService::initCustomerYard($shopping_user, $for);
|
||||||
}else{
|
} else {
|
||||||
//Lieferung an user oder ot-member (Kunden) rechnung geht an User
|
// Lieferung an user oder ot-member (Kunden) rechnung geht an User
|
||||||
//lieferland und rechnungsland prüfen
|
// lieferland und rechnungsland prüfen
|
||||||
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
if($for === 'cr'){
|
if ($for === 'cr') {
|
||||||
Yard::instance('shopping')->setGlobalTaxRate(0);
|
Yard::instance('shopping')->setGlobalTaxRate(0);
|
||||||
Yard::instance('shopping')->setShoppingUser($user, false);
|
Yard::instance('shopping')->setShoppingUser($user, false);
|
||||||
|
|
||||||
}else{
|
} else {
|
||||||
Yard::instance('shopping')->setShoppingUser($user, true);
|
Yard::instance('shopping')->setShoppingUser($user, true);
|
||||||
}
|
}
|
||||||
$data = [
|
$data = [
|
||||||
|
|
@ -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,25 +122,25 @@ 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);
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
return back()->withErrors($validator)->withInput(Request::all());
|
return back()->withErrors($validator)->withInput(Request::all());
|
||||||
}
|
}
|
||||||
//hier prüfen, ob versand etc richtig berechnet wurde
|
// hier prüfen, ob versand etc richtig berechnet wurde
|
||||||
$this->checkSendYardForPayment($data, $id);
|
$this->checkSendYardForPayment($data, $id);
|
||||||
|
|
||||||
if(Yard::instance('shopping')->getNumComp() > 0){
|
if (Yard::instance('shopping')->getNumComp() > 0) {
|
||||||
if(!isset($data['switchers-comp-product'])){
|
if (! isset($data['switchers-comp-product'])) {
|
||||||
$validator->errors()->add('switchers-comp-product', __('Bitte wähle ein Kompensationsprodukt aus'));
|
$validator->errors()->add('switchers-comp-product', __('Bitte wähle ein Kompensationsprodukt aus'));
|
||||||
}else{
|
} else {
|
||||||
if(!is_array($data['switchers-comp-product'])){
|
if (! is_array($data['switchers-comp-product'])) {
|
||||||
$validator->errors()->add('switchers-comp-product', __('Bitte wähle ein Kompensationsprodukt aus'));
|
$validator->errors()->add('switchers-comp-product', __('Bitte wähle ein Kompensationsprodukt aus'));
|
||||||
}else{
|
} else {
|
||||||
if(count($data['switchers-comp-product']) !== Yard::instance('shopping')->getNumComp()){
|
if (count($data['switchers-comp-product']) !== Yard::instance('shopping')->getNumComp()) {
|
||||||
$validator->errors()->add('switchers-comp-product', __('Bitte wähle :count Kompensationsprodukte aus', ['count'=>Yard::instance('shopping')->getNumComp()]));
|
$validator->errors()->add('switchers-comp-product', __('Bitte wähle :count Kompensationsprodukte aus', ['count' => Yard::instance('shopping')->getNumComp()]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +148,7 @@ class OrderController extends Controller
|
||||||
return back()->withErrors($validator)->withInput(Request::all());
|
return back()->withErrors($validator)->withInput(Request::all());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
do {
|
do {
|
||||||
$identifier = Util::getToken();
|
$identifier = Util::getToken();
|
||||||
|
|
@ -173,118 +177,119 @@ class OrderController extends Controller
|
||||||
]);
|
]);
|
||||||
Yard::instance('shopping')->store($identifier);
|
Yard::instance('shopping')->store($identifier);
|
||||||
*/
|
*/
|
||||||
//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);
|
|
||||||
//return redirect()->secure($path);
|
// $path = str_replace('http', 'https', $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);
|
||||||
}
|
}
|
||||||
|
|
||||||
$shipping_country_id = Shop::checkShoppingCountry($data['shipping_is_for'], $id);
|
$shipping_country_id = Shop::checkShoppingCountry($data['shipping_is_for'], $id);
|
||||||
if(!$shipping_country_id){
|
if (! $shipping_country_id) {
|
||||||
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
||||||
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
|
||||||
if($shipping_country_id != Yard::instance('shopping')->getShippingCountryId()){
|
if ($shipping_country_id != Yard::instance('shopping')->getShippingCountryId()) {
|
||||||
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
||||||
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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if($data['shipping_is_for'] !== 'ot-customer'){
|
if ($data['shipping_is_for'] !== 'ot-customer') {
|
||||||
if(Yard::instance('shopping')->shipping_free){
|
if (Yard::instance('shopping')->shipping_free) {
|
||||||
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
||||||
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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if($data['shipping_is_for'] === 'ot-customer'){
|
if ($data['shipping_is_for'] === 'ot-customer') {
|
||||||
if(!$user->shop){
|
if (! $user->shop) {
|
||||||
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
||||||
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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$shipping_price = Shop::getShippingPriceByShippingCountryId($shipping_country_id, Yard::instance('shopping')->weight());
|
$shipping_price = Shop::getShippingPriceByShippingCountryId($shipping_country_id, Yard::instance('shopping')->weight());
|
||||||
//for other and has weight - check
|
// for other and has weight - check
|
||||||
if(strpos($data['shipping_is_for'], 'ot') !== false && $data['shipping_is_for'] !== 'ot-customer' && Yard::instance('shopping')->weight() > 0){
|
if (strpos($data['shipping_is_for'], 'ot') !== false && $data['shipping_is_for'] !== 'ot-customer' && Yard::instance('shopping')->weight() > 0) {
|
||||||
if(!Yard::instance('shopping')->getShippingPrice() || Yard::instance('shopping')->getShippingPrice() == 0){
|
if (! Yard::instance('shopping')->getShippingPrice() || Yard::instance('shopping')->getShippingPrice() == 0) {
|
||||||
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
|
||||||
Yard::instance('shopping')->store($identifier);
|
|
||||||
$data['user_id'] = Auth::user()->id;
|
|
||||||
$data['shopping_user_id'] = $id;
|
|
||||||
\App\Services\MyLog::writeLog('payment', 'error', 'Yard OT shipping_price is 0 or | Yard identifier: '.$identifier, $data);
|
|
||||||
abort(403, __('msg.shipping_cost_cannot_be_0'));
|
|
||||||
}
|
|
||||||
if(Yard::instance('shopping')->getShippingPrice() != $shipping_price->price){
|
|
||||||
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
||||||
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 0 or | Yard identifier: '.$identifier, $data);
|
||||||
|
abort(403, __('msg.shipping_cost_cannot_be_0'));
|
||||||
|
}
|
||||||
|
if (Yard::instance('shopping')->getShippingPrice() != $shipping_price->price) {
|
||||||
|
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
||||||
|
Yard::instance('shopping')->store($identifier);
|
||||||
|
$data['user_id'] = Auth::user()->id;
|
||||||
|
$data['shopping_user_id'] = $id;
|
||||||
|
MyLog::writeLog('payment', 'error', 'Yard OT shipping_price is not the same from shipping_price | Yard identifier: '.$identifier, $data);
|
||||||
abort(403, __('msg.shipping_costs_were_not_calculated_correctly'));
|
abort(403, __('msg.shipping_costs_were_not_calculated_correctly'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if($data['shipping_is_for'] == 'me' && Yard::instance('shopping')->weight() > 0){
|
if ($data['shipping_is_for'] == 'me' && Yard::instance('shopping')->weight() > 0) {
|
||||||
if(!Yard::instance('shopping')->getShippingPrice() || Yard::instance('shopping')->getShippingPrice() == 0){
|
if (! Yard::instance('shopping')->getShippingPrice() || Yard::instance('shopping')->getShippingPrice() == 0) {
|
||||||
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
||||||
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) {
|
||||||
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
||||||
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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if(Yard::instance('shopping')->getNumComp() != $shipping_price->num_comp){
|
if (Yard::instance('shopping')->getNumComp() != $shipping_price->num_comp) {
|
||||||
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
$identifier = 'error-'.time().mt_rand(1000000, 9999999);
|
||||||
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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function datatable()
|
||||||
public function datatable(){
|
{
|
||||||
|
|
||||||
$not_show_pids = ProductBuy::getNotShowProductIDs(Auth::user()->id);
|
$not_show_pids = ProductBuy::getNotShowProductIDs(Auth::user()->id);
|
||||||
|
|
||||||
|
|
@ -294,7 +299,7 @@ class OrderController extends Controller
|
||||||
break;
|
break;
|
||||||
case 'mp':
|
case 'mp':
|
||||||
$query = Product::select('products.*')->where('active', true)->whereJsonContains('show_on', '2');
|
$query = Product::select('products.*')->where('active', true)->whereJsonContains('show_on', '2');
|
||||||
break;
|
break;
|
||||||
case 'cr':
|
case 'cr':
|
||||||
$query = Product::select('products.*')->where('active', true)->whereJsonContains('show_on', '6');
|
$query = Product::select('products.*')->where('active', true)->whereJsonContains('show_on', '6');
|
||||||
break;
|
break;
|
||||||
|
|
@ -302,9 +307,8 @@ class OrderController extends Controller
|
||||||
$query = Product::select('products.*')->where('active', true)->whereJsonContains('show_on', '1');
|
$query = Product::select('products.*')->where('active', true)->whereJsonContains('show_on', '1');
|
||||||
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) {
|
||||||
|
|
@ -349,32 +360,33 @@ class OrderController extends Controller
|
||||||
</div>';
|
</div>';
|
||||||
|
|
||||||
})*/
|
})*/
|
||||||
|
|
||||||
->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>';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $ret;
|
return $ret;
|
||||||
})
|
})
|
||||||
->addColumn('picture', function (Product $product) {
|
->addColumn('picture', function (Product $product) {
|
||||||
if(count($product->images)){
|
if (count($product->images)) {
|
||||||
return '<a href="" class="" data-modal="modal-lg" data-toggle="modal" data-target="#modals-load-content" data-id="'.$product->id.'" data-route="'.route('modal_load').'"
|
return '<a href="" class="" data-modal="modal-lg" data-toggle="modal" data-target="#modals-load-content" data-id="'.$product->id.'" data-route="'.route('modal_load').'"
|
||||||
data-action="user-order-show-product" data-view="customer">
|
data-action="user-order-show-product" data-view="customer">
|
||||||
<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"
|
||||||
|
|
@ -386,8 +398,8 @@ class OrderController extends Controller
|
||||||
data-toggle="modal" data-target="#modals-load-content" data-id="'.$product->id.'" data-route="'.route('modal_load').'"
|
data-toggle="modal" data-target="#modals-load-content" data-id="'.$product->id.'" data-route="'.route('modal_load').'"
|
||||||
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,32 +426,32 @@ 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()) {
|
||||||
|
|
||||||
$data = Request::all();
|
$data = Request::all();
|
||||||
$is_for = isset($data['shipping_is_for']) ? $data['shipping_is_for'] : 'ot';
|
$is_for = isset($data['shipping_is_for']) ? $data['shipping_is_for'] : 'ot';
|
||||||
$data['comp_products'] = $this->getCompProducts($is_for);
|
$data['comp_products'] = $this->getCompProducts($is_for);
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
//get the card item
|
// get the card item
|
||||||
|
|
||||||
|
// Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, $product->price, $product->tax, ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight]);
|
||||||
|
|
||||||
//Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, $product->price, $product->tax, ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight]);
|
|
||||||
|
|
||||||
$cartItem = Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, $product->price, $product->tax,
|
$cartItem = Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, $product->price, $product->tax,
|
||||||
//$product->getPriceWith(Yard::instance('shopping')->getUserTaxFree(), true, Yard::instance('shopping')->getUserCountry()), $product->getTaxWith(Yard::instance('shopping')->getUserCountry()), //$product->tax, true?
|
// $product->getPriceWith(Yard::instance('shopping')->getUserTaxFree(), true, Yard::instance('shopping')->getUserCountry()), $product->getTaxWith(Yard::instance('shopping')->getUserCountry()), //$product->tax, true?
|
||||||
[
|
[
|
||||||
'image' => $image,
|
'image' => $image,
|
||||||
'slug' => $product->slug,
|
'slug' => $product->slug,
|
||||||
|
|
@ -450,140 +462,144 @@ class OrderController extends Controller
|
||||||
'partner_commission' => $product->partner_commission,
|
'partner_commission' => $product->partner_commission,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if(Yard::instance('shopping')->getUserTaxFree()){
|
if (Yard::instance('shopping')->getUserTaxFree()) {
|
||||||
//Yard::setTax($cartItem->rowId, 0);
|
// Yard::setTax($cartItem->rowId, 0);
|
||||||
Yard::instance('shopping')->setGlobalTaxRate(0);
|
Yard::instance('shopping')->setGlobalTaxRate(0);
|
||||||
}else{
|
} else {
|
||||||
//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 {
|
||||||
//if 0 get the item by qty:1 and remove it
|
// if 0 get the item by qty:1 and remove it
|
||||||
Yard::instance('shopping')->remove($cartItem->rowId);
|
Yard::instance('shopping')->remove($cartItem->rowId);
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if($data['action'] === 'reCalculateCart') {
|
if ($data['action'] === 'reCalculateCart') {
|
||||||
//set use_payment_credit
|
// set use_payment_credit
|
||||||
$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' => '']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if($data['action'] === 'updateShippingCountry') {
|
if ($data['action'] === 'updateShippingCountry') {
|
||||||
if(isset($data['shipping_country_id'])){
|
if (isset($data['shipping_country_id'])) {
|
||||||
if($shipping_country = ShippingCountry::find($data['shipping_country_id'])){
|
if ($shipping_country = ShippingCountry::find($data['shipping_country_id'])) {
|
||||||
Yard::instance('shopping')->setShippingCountryWithPrice($shipping_country->id, $is_for); //$is_for == 'ot' or 'me'
|
Yard::instance('shopping')->setShippingCountryWithPrice($shipping_country->id, $is_for); // $is_for == 'ot' or 'me'
|
||||||
$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') {
|
||||||
// $data['comp_product_id']
|
// $data['comp_product_id']
|
||||||
// $data['comp_num']
|
// $data['comp_num']
|
||||||
//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) {
|
||||||
Yard::instance('shopping')->remove($row->rowId);
|
Yard::instance('shopping')->remove($row->rowId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private function updateCompProduct($data){
|
|
||||||
//clear old
|
|
||||||
foreach (Yard::instance('shopping')->content() as $row) {
|
|
||||||
//wenn kleiner wurde ein produkt entfernt aufgrund der Anzahl
|
|
||||||
//wenn gleich löschen, da neue Versandkosten
|
|
||||||
|
|
||||||
if($row->options->comp === $data['comp_num'] || $row->options->comp > $data['count_comp_products']) {
|
private function updateCompProduct($data)
|
||||||
|
{
|
||||||
|
// clear old
|
||||||
|
foreach (Yard::instance('shopping')->content() as $row) {
|
||||||
|
// wenn kleiner wurde ein produkt entfernt aufgrund der Anzahl
|
||||||
|
// wenn gleich löschen, da neue Versandkosten
|
||||||
|
|
||||||
|
if ($row->options->comp === $data['comp_num'] || $row->options->comp > $data['count_comp_products']) {
|
||||||
Yard::instance('shopping')->remove($row->rowId);
|
Yard::instance('shopping')->remove($row->rowId);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(isset($data['comp_product_id'])) {
|
if (isset($data['comp_product_id'])) {
|
||||||
if ($product = Product::find($data['comp_product_id'])) {
|
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;
|
||||||
}
|
}
|
||||||
$cartItem = Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, 0, 0,
|
$cartItem = Yard::instance('shopping')->add($product->id, $product->getLang('name'), 1, 0, 0,
|
||||||
[
|
[
|
||||||
'image' => $image,
|
'image' => $image,
|
||||||
'slug' => $product->slug,
|
'slug' => $product->slug,
|
||||||
'weight' => 0,
|
'weight' => 0,
|
||||||
'single_commission' => 0,
|
'single_commission' => 0,
|
||||||
'amount_commission' => 0,
|
'amount_commission' => 0,
|
||||||
'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\"')")
|
||||||
->orWhereRaw("JSON_CONTAINS(show_on, '\"11\"')");
|
->orWhereRaw("JSON_CONTAINS(show_on, '\"11\"')");
|
||||||
})
|
})
|
||||||
->where('shipping_addon', true)
|
->where('shipping_addon', true)
|
||||||
->orderBy('pos', 'DESC')
|
->orderBy('pos', 'DESC')
|
||||||
->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\"')")
|
||||||
->orWhereRaw("JSON_CONTAINS(show_on, '\"11\"')");
|
->orWhereRaw("JSON_CONTAINS(show_on, '\"11\"')");
|
||||||
})
|
})
|
||||||
->where('shipping_addon', true)
|
->where('shipping_addon', true)
|
||||||
->orderBy('pos', 'DESC')
|
->orderBy('pos', 'DESC')
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'] ?? []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
95
app/Http/Requests/Inventory/StoreStockDisposalRequest.php
Normal file
95
app/Http/Requests/Inventory/StoreStockDisposalRequest.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']) : '';
|
||||||
|
|
|
||||||
70
app/Models/ProductStockMovement.php
Normal file
70
app/Models/ProductStockMovement.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Models/StockDisposal.php
Normal file
87
app/Models/StockDisposal.php
Normal 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 ?? '—');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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'] : []);
|
||||||
|
|
||||||
$this->updateIngredients($data);
|
if ($this->model->is_set) {
|
||||||
$this->updateManufacturerIngredients($data);
|
// Sets haben keine eigene Rezeptur/Verpackung und werden nicht produziert.
|
||||||
$this->updatePackagings($data);
|
ProductIngredient::where('product_id', $this->model->id)->delete();
|
||||||
|
$this->model->packagings()->detach();
|
||||||
|
$this->updateSetItems($data);
|
||||||
|
} else {
|
||||||
|
$this->model->setItems()->detach();
|
||||||
|
$this->updateIngredients($data);
|
||||||
|
$this->updateManufacturerIngredients($data);
|
||||||
|
$this->updatePackagings($data);
|
||||||
|
}
|
||||||
|
|
||||||
$this->updateCountryPrices($data);
|
$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,
|
||||||
|
|
|
||||||
309
app/Services/InventoryService.php
Normal file
309
app/Services/InventoryService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
146
app/Services/ProductStockService.php
Normal file
146
app/Services/ProductStockService.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,44 +34,22 @@ 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).')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$requiredGrams = $this->requiredGramsByIngredient($product, $producedQty);
|
if ($product->no_recipe_required) {
|
||||||
|
$ingredientLines = [];
|
||||||
|
} else {
|
||||||
|
$this->assertManufacturerRecipe($product);
|
||||||
|
|
||||||
$sums = [];
|
$requiredGrams = $this->requiredGramsByIngredient($product, $producedQty);
|
||||||
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) {
|
$this->assertLinesMatchRecipe($requiredGrams, $ingredientLines);
|
||||||
$sum = $sums[$iid] ?? 0;
|
|
||||||
if (abs($sum - $req) > 0.02) {
|
foreach ($ingredientLines as $line) {
|
||||||
throw ValidationException::withMessages([
|
$this->assertStockEntryMatchesLine($line, $locationId);
|
||||||
'ingredient_lines' => __('Summe der Chargen-Mengen pro INCI muss dem Soll-Verbrauch entsprechen (INCI :id: Soll :req g, Ist :sum g).', [
|
|
||||||
'id' => $iid,
|
|
||||||
'req' => number_format($req, 2, ',', '.'),
|
|
||||||
'sum' => number_format($sum, 2, ',', '.'),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($sums as $iid => $_sum) {
|
|
||||||
if (! isset($requiredGrams[$iid])) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'ingredient_lines' => __('Unerwarteter Inhaltsstoff in den Chargen-Zeilen.'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($ingredientLines as $line) {
|
|
||||||
$this->assertStockEntryMatchesLine($line, $locationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines);
|
$mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines);
|
||||||
|
|
||||||
$production = Production::query()->create([
|
$production = Production::query()->create([
|
||||||
|
|
@ -87,17 +70,10 @@ class ProductionService
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($product->packagings as $bom) {
|
$this->syncPackagingSnapshot($production, $product, $producedQty);
|
||||||
$perUnit = (float) ($bom->pivot->quantity ?? 1);
|
|
||||||
$pieces = (int) round($perUnit * $producedQty);
|
$production->setRelation('product', $product);
|
||||||
if ($pieces < 1) {
|
$this->productStockService->recordProductionStock($production, $userId);
|
||||||
$pieces = 1;
|
|
||||||
}
|
|
||||||
$production->productionPackagings()->create([
|
|
||||||
'packaging_item_id' => $bom->id,
|
|
||||||
'quantity_used' => $pieces,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']);
|
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']);
|
||||||
});
|
});
|
||||||
|
|
@ -109,9 +85,9 @@ class ProductionService
|
||||||
*/
|
*/
|
||||||
public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId): Production
|
public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId): Production
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($production, $data, $ingredientLines) {
|
return DB::transaction(function () use ($production, $data, $ingredientLines, $userId) {
|
||||||
$product = Product::query()
|
$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'];
|
||||||
|
|
@ -120,36 +96,22 @@ 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).')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$requiredGrams = $this->requiredGramsByIngredient($product, $producedQty);
|
if ($product->no_recipe_required) {
|
||||||
|
$ingredientLines = [];
|
||||||
|
} else {
|
||||||
|
$this->assertManufacturerRecipe($product);
|
||||||
|
|
||||||
$sums = [];
|
$requiredGrams = $this->requiredGramsByIngredient($product, $producedQty);
|
||||||
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) {
|
$this->assertLinesMatchRecipe($requiredGrams, $ingredientLines);
|
||||||
$sum = $sums[$iid] ?? 0;
|
|
||||||
if (abs($sum - $req) > 0.02) {
|
foreach ($ingredientLines as $line) {
|
||||||
throw ValidationException::withMessages([
|
$this->assertStockEntryMatchesLine($line, $locationId);
|
||||||
'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);
|
$mhdWarning = $this->computeMhdWarning($product, $data['produced_at'], $ingredientLines);
|
||||||
|
|
||||||
$production->update([
|
$production->update([
|
||||||
|
|
@ -171,33 +133,102 @@ class ProductionService
|
||||||
}
|
}
|
||||||
|
|
||||||
$production->productionPackagings()->delete();
|
$production->productionPackagings()->delete();
|
||||||
foreach ($product->packagings as $bom) {
|
$this->syncPackagingSnapshot($production, $product, $producedQty);
|
||||||
$perUnit = (float) ($bom->pivot->quantity ?? 1);
|
|
||||||
$pieces = (int) round($perUnit * $producedQty);
|
$production->setRelation('product', $product);
|
||||||
if ($pieces < 1) {
|
$this->productStockService->recordProductionStock($production, $userId);
|
||||||
$pieces = 1;
|
|
||||||
}
|
|
||||||
$production->productionPackagings()->create([
|
|
||||||
'packaging_item_id' => $bom->id,
|
|
||||||
'quantity_used' => $pieces,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']);
|
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 = [];
|
||||||
|
foreach ($ingredientLines as $line) {
|
||||||
|
$iid = (int) $line['ingredient_id'];
|
||||||
|
$used = $this->parseQuantity($line['quantity_used'] ?? null);
|
||||||
|
$sums[$iid] = ($sums[$iid] ?? 0) + $used;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($requiredGrams as $iid => $req) {
|
||||||
|
$sum = $sums[$iid] ?? 0;
|
||||||
|
if (abs($sum - $req) > 0.02) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'ingredient_lines' => __('Summe der Chargen-Mengen pro INCI muss dem Soll-Verbrauch entsprechen (INCI :id: Soll :req g, Ist :sum g).', [
|
||||||
|
'id' => $iid,
|
||||||
|
'req' => number_format($req, 2, ',', '.'),
|
||||||
|
'sum' => number_format($sum, 2, ',', '.'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($sums as $iid => $_sum) {
|
||||||
|
if (! isset($requiredGrams[$iid])) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'ingredient_lines' => __('Unerwarteter Inhaltsstoff in den Chargen-Zeilen.'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncPackagingSnapshot(Production $production, Product $product, int $producedQty): void
|
||||||
|
{
|
||||||
|
foreach ($product->packagings as $bom) {
|
||||||
|
$perUnit = (float) ($bom->pivot->quantity ?? 1);
|
||||||
|
$pieces = (int) round($perUnit * $producedQty);
|
||||||
|
if ($pieces < 1) {
|
||||||
|
$pieces = 1;
|
||||||
|
}
|
||||||
|
$production->productionPackagings()->create([
|
||||||
|
'packaging_item_id' => $bom->id,
|
||||||
|
'quantity_used' => $pieces,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, float>
|
* @return array<int, float>
|
||||||
*/
|
*/
|
||||||
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,52 +282,107 @@ class ProductionService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Letzte empfangene Wareneingänge pro Inhaltsstoff am Standort (max. 3).
|
* Verbrauchte Menge je Wareneingang (über alle Produktionen), optional ohne eine bestimmte Produktion.
|
||||||
|
*
|
||||||
|
* @param array<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)
|
||||||
return [
|
->map(function (StockEntry $se) {
|
||||||
'id' => $se->id,
|
return [
|
||||||
'batch_number' => $se->batch_number,
|
'id' => $se->id,
|
||||||
'best_before' => $se->best_before?->format('Y-m-d'),
|
'label' => $this->stockEntryLabel($se),
|
||||||
'received_at' => $se->received_at?->format('Y-m-d'),
|
'batch_number' => $se->batch_number,
|
||||||
'received_quantity' => $se->received_quantity !== null ? (float) $se->received_quantity : null,
|
'best_before' => $se->best_before?->format('Y-m-d'),
|
||||||
];
|
'remaining' => (float) $se->getAttribute('remaining_quantity'),
|
||||||
})->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,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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=Eingang−Ausgang, Status, Kritisch-Zähler, Produktionsbuchung + Update-Korrektur, Index/Historie-Render, HTTP-Buchung + Admin-Schutz. Regression Produktion + `RawMaterialStockTest` (26) grün |
|
||||||
|
| 03.06.2026 | **AP-10 (Rohstoffbestand)** | Neue Übersicht **Warenwirtschaft → Rohstoffbestand** (CopyReader, erster Menüpunkt). Neuer `app/Services/InventoryService.php` als zentrale Bestandslogik: `remainingByIngredient()` (= `SUM(received_quantity)` der eingegangenen Rohstoff-Chargen − `SUM(production_ingredients.quantity_used)`), `remainingByLocationForIngredient()`, `dailyConsumptionByIngredient()` (Ø Gramm/Tag aus Produktionshistorie der letzten 90 Tage), `daysUntilEmpty()`/`expectedEmptyDate()`, `stockStatus()` (critical = Meldebestand unterschritten, warning = Reichweite ≤ Lieferzeit) und `criticalIngredientCount()`. `RawMaterialStockController@index` (Tabelle: Name, Qualität, Bestand, Verbrauch/Tag, Voraussichtlich auf Null + Resttage, Hochrechnungs-Spalte mit Horizont-Dropdown 1/3/6/12 Monate; Suche + „nur kritische anzeigen"-Filter; kritisch=`table-danger`, warning=`table-warning`, Zeile klickbar) und `@show` (Bestell-Detailseite: Kennzahlen, „Enthalten in" = aktive Rezepturen mit g/Stück, Lieferanten mit Lieferzeit/letztem Netto-kg-Preis + Bestellaktion `Zum Shop`/`Per Mail` je `order_method`, verfügbare Chargen mit Restbestand + Bestand je Lagerort). Routen `raw-material-stock.index`/`.show` in der `copyreader`-Gruppe. Sidenav-Eintrag „Rohstoffbestand" inkl. rotem Kritisch-Badge über `View::composer` (AppServiceProvider, nur für CopyReader). **Hinweis:** Produktbestand/Verkauf-pro-Tag je Produkt im „Enthalten in"-Block folgt mit AP-11 (Produktbestand existiert noch nicht). | `tests/Feature/RawMaterialStockTest.php` (8 grün): Restbestand=Eingang−Verbrauch, Verbrauch/Tag-Mittelung, Fenstergrenze, Status kritisch/warnung, Kritisch-Zähler, Index- und Detail-Render. Regression `ProductionManufacturerRecipeTest`/`ProductSetTest` (19) grün |
|
||||||
| 02.06.2026 | **AP-01** | URL-Bugfixes B1/B2 umgesetzt: `suppliers/form.blade.php` und `packaging-items/form.blade.php` von `type="url"` auf `type="text"` (placeholder `https://`); `Store/UpdatePackagingItemRequest` URL-Regel `url\|max:500` → `string\|max:2048`; Migration `2026_06_02_145358_widen_url_columns_in_inventory_tables` (suppliers.url + packaging_items.url → varchar(2048)). | `tests/Feature/InventoryUrlFieldsTest.php` (3 grün); Regression Phase 2+3 grün (17) |
|
| 02.06.2026 | **AP-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=Eingang−Ausgang, Schwellwert-Status, manuelle `+`/`−`-Bewegungen, automatische Produktionsbuchung, Übersicht + revisionssichere Historie, Kritisch-Badge); **AP-12 erledigt** (Ausgang/Ausschuss: `stock_disposals`, Erfassung mit Pflicht-Grund + optionaler Charge, reduziert Rohstoff-/Verpackungsbestand inkl. Kritisch-Badge).
|
||||||
|
|
||||||
> **Alle Klärungspunkte aus §5 sind beantwortet** (Kunde, 02.06.2026) und in die jeweiligen APs eingearbeitet — keine Blocker mehr offen.
|
> **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. 421–454). Dort wird `shipped` gesetzt und – bereits **idempotent** über `if (! $shopping_order->shipped_at)` – einmalig `shipped_at` beim Übergang auf „sent"/„close" gesetzt. **Idealer Aufhängepunkt.**
|
||||||
|
- **WooCommerce-Anbindung (Ist):** WP ruft per Passport-API (`/api/wp/*`, `ShoppingUserController`) `store`/`order`/`update`/`status`/`cancel`/`open`. Status „versendet" wird derzeit **nicht** von WP gesetzt, sondern intern im Backend; WP **liest** den Status (`ShoppingOrder::getAPIShippedType()`).
|
||||||
|
- **Produktbestand-Infrastruktur steht (AP-11):** `product_stock_movements` mit `direction`, `source`, `reason`, `nullableMorphs('reference')` und `ProductStockService::recordMovement()`. → Die „out"-Buchung setzt **ohne neue Tabellen** darauf auf.
|
||||||
|
|
||||||
|
#### 1. Ablauf-Skizze
|
||||||
|
```
|
||||||
|
WooCommerce ──(push /api/wp/order)──▶ shopping_orders / shopping_order_items (Mapping via products.wp_number)
|
||||||
|
│
|
||||||
|
Backend: Versand buchen (SalesController@store / store_shipped) ──▶ shipped = 2 + shipped_at
|
||||||
|
│ (Trigger)
|
||||||
|
▼
|
||||||
|
SaleStockService::bookShipment(ShoppingOrder)
|
||||||
|
├─ je Position: Set? ──ja──▶ Komponenten (× Set-Menge × qty) je „out"
|
||||||
|
│ └─nein─▶ Produkt selbst (× qty) „out"
|
||||||
|
├─ source = 'sale', reference = ShoppingOrder, reason = "Versand #<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 ~2–3 Tage; Szenario B (Webhook/Pull) ~3–5 Tage.
|
||||||
|
|
||||||
|
**Akzeptanz:** Versand reduziert den Produktbestand; Set-Versand reduziert die enthaltenen Einzelprodukte; Storno bucht zurück; jede Buchung steht revisionssicher in der Historie.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -383,6 +481,8 @@ Diese Punkte sind klein, konkret und blockieren teils die tägliche Nutzung. Sie
|
||||||
- **Akzeptanz:** Kunde sieht unter Einstellungen → Hinweise eine lesbare, gepflegte Statusseite.
|
- **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-14–AP-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-12–AP-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
124
resources/docs/hinweise.md
Normal 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.
|
||||||
44
resources/views/admin/inventory/notices/index.blade.php
Normal file
44
resources/views/admin/inventory/notices/index.blade.php
Normal 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
|
||||||
|
|
@ -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
|
||||||
110
resources/views/admin/inventory/product-stock/history.blade.php
Normal file
110
resources/views/admin/inventory/product-stock/history.blade.php
Normal 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
|
||||||
144
resources/views/admin/inventory/product-stock/index.blade.php
Normal file
144
resources/views/admin/inventory/product-stock/index.blade.php
Normal 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">×</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
|
||||||
|
|
@ -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>
|
||||||
213
resources/views/admin/inventory/productions/_scripts.blade.php
Normal file
213
resources/views/admin/inventory/productions/_scripts.blade.php
Normal 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>
|
||||||
|
|
@ -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();
|
@endphp
|
||||||
var lid = $('#location_id').val();
|
@include('admin.inventory.productions._scripts', ['existingLines' => $existingLines, 'excludeProductionId' => null])
|
||||||
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();
|
|
||||||
$('#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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
});
|
||||||
|
@endphp
|
||||||
function recipeUrl() {
|
@include('admin.inventory.productions._scripts', ['existingLines' => $existingLines, 'excludeProductionId' => $model->id])
|
||||||
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>');
|
|
||||||
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">').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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
194
resources/views/admin/inventory/stock-disposals/create.blade.php
Normal file
194
resources/views/admin/inventory/stock-disposals/create.blade.php
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">☰</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</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') }}">☰</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">×</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>
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -4,87 +4,117 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
||||||
<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>
|
||||||
<div class="card-datatable table-responsive">
|
<div class="card-datatable table-responsive">
|
||||||
<table class="datatables-product table table-striped table-bordered">
|
<table class="datatables-product table table-striped table-bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="max-width: 60px;"> </th>
|
<th style="max-width: 60px;"> </th>
|
||||||
<th>{{__('Pos')}}</th>
|
<th>{{ __('Pos') }}</th>
|
||||||
<th>{{__('Bild')}}</th>
|
<th>{{ __('Bild') }}</th>
|
||||||
<th>{{__('Name')}}</th>
|
<th>{{ __('Name') }}</th>
|
||||||
<th>{{__('Artikelnummer')}}</th>
|
<th>{{ __('Artikelnummer') }}</th>
|
||||||
<th>{{__('Kategorie')}}</th>
|
<th>{{ __('Kategorie') }}</th>
|
||||||
<th>{{__('Preis')}}</th>
|
<th>{{ __('Preis') }}</th>
|
||||||
<th>{{__('Inhalt')}}</th>
|
<th>{{ __('Inhalt') }}</th>
|
||||||
<th>{{__('Einheit')}}</th>
|
<th>{{ __('Einheit') }}</th>
|
||||||
<th>{{__('Grundpreis')}}</th>
|
<th>{{ __('Grundpreis') }}</th>
|
||||||
<th>{{__('Gewicht')}}</th>
|
<th>{{ __('Gewicht') }}</th>
|
||||||
<th>{{__('sichbar')}}</th>
|
<th>{{ __('Verfügbarkeit') }}</th>
|
||||||
<th><div data-toggle="tooltip" title data-original-title="White Label">{{__('WL')}}</div></th>
|
<th>{{ __('sichbar') }}</th>
|
||||||
<th><div data-toggle="tooltip" title data-original-title="Kompensationsprodukt">{{__('KP')}}</div></th>
|
<th>
|
||||||
<th><div data-toggle="tooltip" title data-original-title="Maximaler Kauf pro Berater">{{__('MK')}}</div></th>
|
<div data-toggle="tooltip" title data-original-title="White Label">{{ __('WL') }}</div>
|
||||||
<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>
|
<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>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@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]) }}"
|
||||||
<span class="far fa-edit"></span>
|
class="btn icon-btn btn-sm btn-primary">
|
||||||
</a>
|
<span class="far fa-edit"></span>
|
||||||
</td>
|
</a>
|
||||||
<td>{{ $value->pos }}</td>
|
</td>
|
||||||
<td>
|
<td>{{ $value->pos }}</td>
|
||||||
@if(count($value->images))
|
<td>
|
||||||
<img class="img-fluid" alt="" style="max-height: 80px" src="{{ route('product_image', [$value->images->first()->slug]) }}">
|
@if (count($value->images))
|
||||||
@endif
|
<img class="img-fluid" alt="" style="max-height: 80px"
|
||||||
</td>
|
src="{{ route('product_image', [$value->images->first()->slug]) }}">
|
||||||
<td>{{ $value->name }}</td>
|
@endif
|
||||||
<td>{{ $value->number }}</td>
|
</td>
|
||||||
<td>
|
<td>{{ $value->name }}</td>
|
||||||
@foreach($value->categories as $category)
|
<td>{{ $value->number }}</td>
|
||||||
<div style="white-space: nowrap">{{ $category->category->name }}</div>
|
<td>
|
||||||
@endforeach
|
@foreach ($value->categories as $category)
|
||||||
</td>
|
<div style="white-space: nowrap">{{ $category->category->name }}</div>
|
||||||
<td>{{ $value->getFormattedPrice() }}</td>
|
@endforeach
|
||||||
<td>{{ $value->contents_total }}</td>
|
</td>
|
||||||
<td>{{ $value->getUnitType() }}</td>
|
<td>{{ $value->getFormattedPrice() }}</td>
|
||||||
<td>{{ $value->getBasePriceFormatted() }}</td>
|
<td>{{ $value->contents_total }}</td>
|
||||||
<td>{{ $value->weight }}</td>
|
<td>{{ $value->getUnitType() }}</td>
|
||||||
<td>{!! $value->getShowOnTypes('<br>') !!}</td>
|
<td>{{ $value->getBasePriceFormatted() }}</td>
|
||||||
<td data-sort="{{ $value->whitelabel }}">{!! get_active_badge($value->whitelabel, $value->whitelabel_name) !!}</td>
|
<td>{{ $value->weight }}</td>
|
||||||
<td data-sort="{{ $value->shipping_addon }}">{!! get_active_badge($value->shipping_addon) !!}</td>
|
<td data-sort="{{ $value->isOutOfStock() ? 1 : 0 }}">
|
||||||
<td data-sort="{{ $value->max_buy }}">{!! get_active_badge($value->max_buy) !!}</td>
|
@if ($value->isOutOfStock())
|
||||||
<td data-sort="{{ $value->single_commission }}">{!! get_active_badge($value->single_commission) !!}</td>
|
<span class="badge badge-danger"
|
||||||
<td data-sort="{{ $value->exclude_stats_sales }}">{!! get_active_badge($value->exclude_stats_sales) !!}</td>
|
style="white-space: normal;">{{ $value->outOfStockNotice() }}</span>
|
||||||
<td data-sort="{{ $value->active }}">{!! get_active_badge($value->active) !!}</td>
|
@else
|
||||||
<td><a class="text-info" href="{{ route('admin_product_copy', [$value->id]) }}" onclick="return confirm('{{__('Eintrag kopieren?')}}');"><i class="far fa-copy"></i></a>
|
<span class="text-muted small">{{ __('vorrätig') }}</span>
|
||||||
<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>
|
@endif
|
||||||
</tr>
|
</td>
|
||||||
@endforeach
|
<td>{!! $value->getShowOnTypes('<br>') !!}</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->max_buy }}">{!! get_active_badge($value->max_buy) !!}</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->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>
|
||||||
|
<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>
|
||||||
|
@endforeach
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="mt-4 ml-4">
|
<div class="mt-4 ml-4">
|
||||||
<a href="{{route('admin_product_edit', ['new'])}}" class="btn btn-sm btn-primary">
|
<a href="{{ route('admin_product_edit', ['new']) }}" class="btn btn-sm btn-primary">
|
||||||
{{__('Neues Produkt erstellen')}}
|
{{ __('Neues Produkt erstellen') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
$( document ).ready(function() {
|
$(document).ready(function() {
|
||||||
$('.datatables-product').dataTable({
|
$('.datatables-product').dataTable({
|
||||||
"bLengthChange": false,
|
"bLengthChange": false,
|
||||||
"iDisplayLength": 50,
|
"iDisplayLength": 50,
|
||||||
|
|
@ -92,15 +122,13 @@
|
||||||
"url": "/js/German.json"
|
"url": "/js/German.json"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$('.show-active-products').on('change', function () {
|
$('.show-active-products').on('change', function() {
|
||||||
console.log($(this));
|
console.log($(this));
|
||||||
console.log($(this).prop('checked'));
|
console.log($(this).prop('checked'));
|
||||||
document.location.search = $(this).attr('name')+"="+$(this).prop('checked');
|
document.location.search = $(this).attr('name') + "=" + $(this).prop('checked');
|
||||||
// window.location.reload(true);
|
// window.location.reload(true);
|
||||||
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,90 @@
|
||||||
@extends('layouts.layout-2')
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<style>
|
<style>
|
||||||
.btn-md-extra {
|
.btn-md-extra {
|
||||||
padding: 0.3rem 0.6rem;
|
padding: 0.3rem 0.6rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
|
||||||
.md-btn-extra {
|
|
||||||
width: calc(1.7rem + 2px) !important;
|
|
||||||
line-height: 1.5rem;
|
|
||||||
}
|
|
||||||
.form-control.input-extra {
|
|
||||||
padding: 0.28rem 0.6rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
min-height: calc(1.8rem + 2px);
|
|
||||||
height: calc(1.8rem + 2px);
|
|
||||||
width: 44px;
|
|
||||||
}
|
|
||||||
.input-group-min-w {
|
|
||||||
min-width: 102px;
|
|
||||||
}
|
|
||||||
.img-extra {
|
|
||||||
min-width:55px;
|
|
||||||
max-height: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 tbody td:first-child,
|
|
||||||
.default-style:not([dir=rtl]) div.card-datatable table.dataTable tfoot th:first-child {
|
|
||||||
padding-left: 0.6rem !important;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-btn-extra {
|
||||||
|
width: calc(1.7rem + 2px) !important;
|
||||||
|
line-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control.input-extra {
|
||||||
|
padding: 0.28rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
min-height: calc(1.8rem + 2px);
|
||||||
|
height: calc(1.8rem + 2px);
|
||||||
|
width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group-min-w {
|
||||||
|
min-width: 102px;
|
||||||
|
}
|
||||||
|
|
||||||
.img-extra {
|
.img-extra {
|
||||||
min-width:35px;
|
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) {
|
||||||
|
|
||||||
|
.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 tfoot th:first-child {
|
||||||
|
padding-left: 0.6rem !important;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-extra {
|
||||||
|
min-width: 35px;
|
||||||
|
max-height: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
@if ($for === 'cr')
|
||||||
|
<h4 class="font-weight-bold py-2 mb-2">
|
||||||
@if($for === 'cr')
|
{{ __('navigation.my_orders') }} / Mein Guthaben aufladen
|
||||||
<h4 class="font-weight-bold py-2 mb-2">
|
<a href="{{ route('user_order_my_delivery', [$for, $delivery_id]) }}"
|
||||||
{{ __('navigation.my_orders') }} / Mein Guthaben aufladen
|
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]) }}"
|
||||||
<div class="clearfix"></div>
|
class="btn btn-sm btn-default float-right">zurück</a>
|
||||||
</h4>
|
<div class="clearfix"></div>
|
||||||
@if($user->user_level)
|
</h4>
|
||||||
<p>Die Produktpreise werden entsprechend Deiner Rolle: <strong>{{$user->user_level->name}}</strong> angezeigt.<br>
|
@if ($user->user_level)
|
||||||
|
<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>
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if($errors->has('switchers-comp-product'))
|
@if ($errors->has('switchers-comp-product'))
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<div class="alert alert-danger" id="gotocomp">
|
<div class="alert alert-danger" id="gotocomp">
|
||||||
|
|
@ -86,46 +104,50 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<div class="text-muted small">{{ __('payment.ordering_country') }}</div>
|
<div class="text-muted small">{{ __('payment.ordering_country') }}</div>
|
||||||
{{ App\Services\UserService::getOrderInfo('billing_state') }}
|
{{ App\Services\UserService::getOrderInfo('billing_state') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<div class="text-muted small">{{ __('payment.country_of_delivery') }}</div>
|
<div class="text-muted small">{{ __('payment.country_of_delivery') }}</div>
|
||||||
{{ App\Services\UserService::getOrderInfo('shipping_state') }}
|
{{ App\Services\UserService::getOrderInfo('shipping_state') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<div class="text-muted small">{{ __('payment.VAT') }}</div>
|
<div class="text-muted small">{{ __('payment.VAT') }}</div>
|
||||||
{{ App\Services\UserService::getOrderInfo('tax_free') }}
|
{{ App\Services\UserService::getOrderInfo('tax_free') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<div class="text-muted small">{{ __('payment.reverse_charge_procedure') }}</div>
|
<div class="text-muted small">{{ __('payment.reverse_charge_procedure') }}</div>
|
||||||
{{ App\Services\UserService::getOrderInfo('user_reverse_charge') }}
|
{{ App\Services\UserService::getOrderInfo('user_reverse_charge') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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', [
|
||||||
@else
|
'user_level_name' => $user->user_level->getLang('name'),
|
||||||
<p>{{ __('order.no_career_level_info') }}</p>
|
'user_level_margin' => $user->user_level->getFormattedMargin(),
|
||||||
@endif
|
]) !!}</p>
|
||||||
|
@else
|
||||||
|
<p>{{ __('order.no_career_level_info') }}</p>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
<th>{{__('Produkt')}}</th>
|
<th>{{ __('Produkt') }}</th>
|
||||||
<th>{{__('Kategorie')}}</th>
|
<th>{{ __('Kategorie') }}</th>
|
||||||
<th>{{__('Preis netto')}}</th>
|
<th>{{ __('Preis netto') }}</th>
|
||||||
<th>{{__('Preis brutto')}}</th>
|
<th>{{ __('Preis brutto') }}</th>
|
||||||
<th>{{__('Provision')}}</th>
|
<th>{{ __('Provision') }}</th>
|
||||||
<th>{{__('Gewicht')}}</th>
|
<th>{{ __('Gewicht') }}</th>
|
||||||
<th><span class="no-line-break">{{__('Inhalt (ml)')}}</span></th>
|
<th><span class="no-line-break">{{ __('Inhalt (ml)') }}</span></th>
|
||||||
<th>{{__('Artikelnummer')}}</th>
|
<th>{{ __('Artikelnummer') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -134,30 +156,29 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!! Form::open(['action' => route('user_order_my_payment', [$for, $delivery_id]), 'class' => 'form-horizontal']) !!}
|
{!! Form::open(['action' => route('user_order_my_payment', [$for, $delivery_id]), 'class' => 'form-horizontal']) !!}
|
||||||
<input type="hidden" name="shipping_is_for" value="{{$for}}">
|
<input type="hidden" name="shipping_is_for" value="{{ $for }}">
|
||||||
|
|
||||||
@if($for === 'cr')
|
@if ($for === 'cr')
|
||||||
@include('user.order.shipping_credit')
|
@include('user.order.shipping_credit')
|
||||||
@else
|
@else
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if($for === 'ot')
|
@if ($for === 'ot')
|
||||||
<h4>Lieferland des Kunden</h4>
|
<h4>Lieferland des Kunden</h4>
|
||||||
@include('user.order.shipping_ot')
|
@include('user.order.shipping_ot')
|
||||||
@endif
|
@endif
|
||||||
@if($for === 'me' || $for === 'mp')
|
@if ($for === 'me' || $for === 'mp')
|
||||||
<h4>Mein Lieferland</h4>
|
<h4>Mein Lieferland</h4>
|
||||||
@include('user.order.shipping_me')
|
@include('user.order.shipping_me')
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if($comp_products)
|
@if ($comp_products)
|
||||||
<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,13 +190,14 @@
|
||||||
</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() !!}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
$( document ).ready(function() {
|
$(document).ready(function() {
|
||||||
|
|
||||||
var iqShoppingCart = IqShoppingCart.init();
|
var iqShoppingCart = IqShoppingCart.init();
|
||||||
|
|
||||||
|
|
@ -183,23 +205,61 @@
|
||||||
"processing": true,
|
"processing": true,
|
||||||
"serverSide": true,
|
"serverSide": true,
|
||||||
ajax: {
|
ajax: {
|
||||||
url: '{!! route( 'user_order_my_datatable') !!}',
|
url: '{!! route('user_order_my_datatable') !!}',
|
||||||
data: function(d) {
|
data: function(d) {
|
||||||
d.shipping_is_for = $('input[name=shipping_is_for]').val();
|
d.shipping_is_for = $('input[name=shipping_is_for]').val();
|
||||||
// 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,
|
||||||
|
|
@ -207,7 +267,7 @@
|
||||||
"language": {
|
"language": {
|
||||||
"url": "/js/German.json"
|
"url": "/js/German.json"
|
||||||
},
|
},
|
||||||
drawCallback: function (settings) {
|
drawCallback: function(settings) {
|
||||||
iqShoppingCart.reInit();
|
iqShoppingCart.reInit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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() }} €*
|
{{ $product->getFormattedPrice() }} €*
|
||||||
<br><span class="small text-muted">@if($product->unit) {{ $product->getBasePriceFormattedFull() }} € @else @endif</span>
|
<br><span class="small text-muted">@if($product->unit) {{ $product->getBasePriceFormattedFull() }} € @else @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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
41
tests/Feature/InventoryNoticesTest.php
Normal file
41
tests/Feature/InventoryNoticesTest.php
Normal 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');
|
||||||
|
});
|
||||||
135
tests/Feature/ProductOutOfStockTest.php
Normal file
135
tests/Feature/ProductOutOfStockTest.php
Normal 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();
|
||||||
|
});
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
272
tests/Feature/ProductSetTest.php
Normal file
272
tests/Feature/ProductSetTest.php
Normal 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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
191
tests/Feature/ProductStockTest.php
Normal file
191
tests/Feature/ProductStockTest.php
Normal 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');
|
||||||
|
});
|
||||||
375
tests/Feature/ProductionManufacturerRecipeTest.php
Normal file
375
tests/Feature/ProductionManufacturerRecipeTest.php
Normal 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');
|
||||||
|
});
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
269
tests/Feature/RawMaterialStockTest.php
Normal file
269
tests/Feature/RawMaterialStockTest.php
Normal 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');
|
||||||
|
});
|
||||||
222
tests/Feature/StockDisposalTest.php
Normal file
222
tests/Feature/StockDisposalTest.php
Normal 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]);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue