13-05-2026 Waren Wirtschaft
This commit is contained in:
parent
9ce711d6b2
commit
ca3eb663fe
40 changed files with 1000 additions and 189 deletions
|
|
@ -7,15 +7,6 @@
|
||||||
"boost:mcp"
|
"boost:mcp"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"context7": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@upstash/context7-mcp",
|
|
||||||
"--api-key",
|
|
||||||
"ctx7sk-119cd4ab-8983-4229-8702-e84c59c34fc9"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sequential-thinking": {
|
"sequential-thinking": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": [
|
"args": [
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@
|
||||||
"LARAVEL_SAIL": "1"
|
"LARAVEL_SAIL": "1"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
],
|
],
|
||||||
// WICHTIG: Nur noch den Vite-Port weiterleiten
|
// WICHTIG: Nur noch den Vite-Port weiterleiten
|
||||||
"forwardPorts": [
|
"forwardPorts": [
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ class PackagingItemController extends Controller
|
||||||
->orderBy('name');
|
->orderBy('name');
|
||||||
|
|
||||||
if ($isShipping) {
|
if ($isShipping) {
|
||||||
$query->whereIn('category', ['shipping', 'label', 'shipping_office']);
|
$query->where('category', 'shipping');
|
||||||
} else {
|
} else {
|
||||||
$query->where('category', 'packaging');
|
$query->where('category', 'packaging');
|
||||||
}
|
}
|
||||||
|
|
@ -41,7 +41,10 @@ class PackagingItemController extends Controller
|
||||||
|
|
||||||
public function create(Request $request)
|
public function create(Request $request)
|
||||||
{
|
{
|
||||||
$category = $request->get('category', 'packaging');
|
$category = $request->query('category', 'packaging');
|
||||||
|
if (! in_array($category, ['packaging', 'shipping'], true)) {
|
||||||
|
$category = 'packaging';
|
||||||
|
}
|
||||||
|
|
||||||
return view('admin.inventory.packaging-items.form', [
|
return view('admin.inventory.packaging-items.form', [
|
||||||
'model' => new PackagingItem(['active' => true, 'category' => $category === 'shipping' ? 'shipping' : 'packaging', 'weight_grams' => 0]),
|
'model' => new PackagingItem(['active' => true, 'category' => $category === 'shipping' ? 'shipping' : 'packaging', 'weight_grams' => 0]),
|
||||||
|
|
@ -53,18 +56,27 @@ class PackagingItemController extends Controller
|
||||||
|
|
||||||
public function store(StorePackagingItemRequest $request)
|
public function store(StorePackagingItemRequest $request)
|
||||||
{
|
{
|
||||||
$item = $this->packagingItemRepository->create($request->validated());
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
\Log::debug('PackagingItem STORE – raw input', [
|
||||||
|
'category_raw' => $request->input('category'),
|
||||||
|
'category_hex' => bin2hex((string) $request->input('category')),
|
||||||
|
'all_input' => $request->all(),
|
||||||
|
]);
|
||||||
|
\Log::debug('PackagingItem STORE – validated', $validated);
|
||||||
|
|
||||||
|
$item = $this->packagingItemRepository->create($validated);
|
||||||
|
|
||||||
\Session::flash('alert-save', '1');
|
\Session::flash('alert-save', '1');
|
||||||
|
|
||||||
$category = in_array($item->category, ['shipping', 'label', 'shipping_office']) ? 'shipping' : 'packaging';
|
$category = $item->category === 'shipping' ? 'shipping' : 'packaging';
|
||||||
|
|
||||||
return redirect()->route('admin.inventory.packaging-items.index', ['category' => $category]);
|
return redirect()->route('admin.inventory.packaging-items.index', ['category' => $category]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(PackagingItem $packagingItem)
|
public function edit(PackagingItem $packagingItem)
|
||||||
{
|
{
|
||||||
$category = in_array($packagingItem->category, ['shipping', 'label', 'shipping_office']) ? 'shipping' : 'packaging';
|
$category = $packagingItem->category === 'shipping' ? 'shipping' : 'packaging';
|
||||||
|
|
||||||
return view('admin.inventory.packaging-items.form', [
|
return view('admin.inventory.packaging-items.form', [
|
||||||
'model' => $packagingItem,
|
'model' => $packagingItem,
|
||||||
|
|
@ -76,18 +88,27 @@ class PackagingItemController extends Controller
|
||||||
|
|
||||||
public function update(UpdatePackagingItemRequest $request, PackagingItem $packagingItem)
|
public function update(UpdatePackagingItemRequest $request, PackagingItem $packagingItem)
|
||||||
{
|
{
|
||||||
$this->packagingItemRepository->update($packagingItem, $request->validated());
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
\Log::debug('PackagingItem UPDATE – raw input', [
|
||||||
|
'category_raw' => $request->input('category'),
|
||||||
|
'category_hex' => bin2hex((string) $request->input('category')),
|
||||||
|
'all_input' => $request->all(),
|
||||||
|
]);
|
||||||
|
\Log::debug('PackagingItem UPDATE – validated', $validated);
|
||||||
|
|
||||||
|
$this->packagingItemRepository->update($packagingItem, $validated);
|
||||||
|
|
||||||
\Session::flash('alert-save', '1');
|
\Session::flash('alert-save', '1');
|
||||||
|
|
||||||
$category = in_array($packagingItem->category, ['shipping', 'label', 'shipping_office']) ? 'shipping' : 'packaging';
|
$category = $packagingItem->category === 'shipping' ? 'shipping' : 'packaging';
|
||||||
|
|
||||||
return redirect()->route('admin.inventory.packaging-items.index', ['category' => $category]);
|
return redirect()->route('admin.inventory.packaging-items.index', ['category' => $category]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(PackagingItem $packagingItem)
|
public function destroy(PackagingItem $packagingItem)
|
||||||
{
|
{
|
||||||
$category = in_array($packagingItem->category, ['shipping', 'label', 'shipping_office']) ? 'shipping' : 'packaging';
|
$category = $packagingItem->category === 'shipping' ? 'shipping' : 'packaging';
|
||||||
|
|
||||||
$packagingItem->delete();
|
$packagingItem->delete();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,8 +190,7 @@ class StockEntryController extends Controller
|
||||||
|
|
||||||
$categoryMap = [
|
$categoryMap = [
|
||||||
'packaging' => 'packaging',
|
'packaging' => 'packaging',
|
||||||
'label' => 'label',
|
'shipping' => 'shipping',
|
||||||
'shipping_office' => 'shipping_office',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$query = PackagingItem::query()
|
$query = PackagingItem::query()
|
||||||
|
|
@ -223,11 +222,11 @@ class StockEntryController extends Controller
|
||||||
return [
|
return [
|
||||||
'suppliers' => Supplier::query()->where('active', true)->orderBy('name')->get(),
|
'suppliers' => Supplier::query()->where('active', true)->orderBy('name')->get(),
|
||||||
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
|
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
|
||||||
|
'materialQualities' => MaterialQuality::query()->orderBy('pos')->orderBy('name')->get(),
|
||||||
'entryTypeLabels' => [
|
'entryTypeLabels' => [
|
||||||
'ingredient' => __('Rohstoff'),
|
'ingredient' => __('Rohstoff'),
|
||||||
'packaging' => __('Verpackung'),
|
'packaging' => __('Produktverpackung'),
|
||||||
'label' => __('Etikett'),
|
'shipping' => __('Versandverpackung'),
|
||||||
'shipping_office' => __('Versand & Büro'),
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,6 @@ class ProductController extends Controller
|
||||||
|
|
||||||
return redirect(route('admin_product_edit', [$product->id]));
|
return redirect(route('admin_product_edit', [$product->id]));
|
||||||
}
|
}
|
||||||
|
|
||||||
\Session()->flash('alert-save', '1');
|
|
||||||
|
|
||||||
return redirect(route('admin_product_show'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function copy($id)
|
public function copy($id)
|
||||||
|
|
@ -135,16 +131,16 @@ class ProductController extends Controller
|
||||||
|
|
||||||
if ($do === 'ingredient') {
|
if ($do === 'ingredient') {
|
||||||
$model = Product::findOrFail($id);
|
$model = Product::findOrFail($id);
|
||||||
$ProductIngredient = ProductIngredient::where('ingredient_id', $did)->where('product_id', $model->id)->first();
|
$productIngredient = ProductIngredient::where('ingredient_id', $did)->where('product_id', $model->id)->first();
|
||||||
if ($ProductIngredient) {
|
if ($productIngredient) {
|
||||||
$ProductIngredient->delete();
|
$productIngredient->delete();
|
||||||
\Session()->flash('alert-success', 'Eintrag gelöscht');
|
\Session()->flash('alert-success', 'Eintrag gelöscht');
|
||||||
|
}
|
||||||
|
|
||||||
return redirect(route('admin_product_edit', [$model->id]));
|
return redirect(route('admin_product_edit', [$model->id]));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
abort(404);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload FILE -----------------------------------------------------------------------------------------------------------------------
|
// Upload FILE -----------------------------------------------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Http\Requests\Inventory;
|
namespace App\Http\Requests\Inventory;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
class ReceiveStockEntryRequest extends FormRequest
|
class ReceiveStockEntryRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
|
@ -31,4 +32,19 @@ class ReceiveStockEntryRequest extends FormRequest
|
||||||
'received_quantity' => reFormatNumber($this->input('received_quantity')),
|
'received_quantity' => reFormatNumber($this->input('received_quantity')),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function withValidator(Validator $validator): void
|
||||||
|
{
|
||||||
|
$validator->after(function (Validator $validator): void {
|
||||||
|
$stockEntry = $this->route('stockEntry') ?? $this->route('stock_entry');
|
||||||
|
if ($stockEntry && $stockEntry->entry_type === 'ingredient') {
|
||||||
|
if (empty($this->input('batch_number'))) {
|
||||||
|
$validator->errors()->add('batch_number', __('Bitte eine Chargennummer angeben.'));
|
||||||
|
}
|
||||||
|
if (empty($this->input('best_before'))) {
|
||||||
|
$validator->errors()->add('best_before', __('Bitte die Mindesthaltbarkeit angeben.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class StorePackagingItemRequest extends FormRequest
|
||||||
'packaging_material_id' => ['required', 'integer', 'exists:packaging_materials,id'],
|
'packaging_material_id' => ['required', 'integer', 'exists:packaging_materials,id'],
|
||||||
'supplier_id' => ['nullable', 'integer', 'exists:suppliers,id'],
|
'supplier_id' => ['nullable', 'integer', 'exists:suppliers,id'],
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'category' => ['required', Rule::in(['packaging', 'shipping', 'label', 'shipping_office'])],
|
'category' => ['required', Rule::in(['packaging', 'shipping'])],
|
||||||
'weight_grams' => ['nullable', 'numeric', 'min:0'],
|
'weight_grams' => ['nullable', 'numeric', 'min:0'],
|
||||||
'min_stock_alert' => ['nullable', 'integer', 'min:0'],
|
'min_stock_alert' => ['nullable', 'integer', 'min:0'],
|
||||||
'url' => ['nullable', 'url', 'max:500'],
|
'url' => ['nullable', 'url', 'max:500'],
|
||||||
|
|
@ -34,6 +34,8 @@ class StorePackagingItemRequest extends FormRequest
|
||||||
{
|
{
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'active' => $this->boolean('active'),
|
'active' => $this->boolean('active'),
|
||||||
|
'category' => trim((string) $this->input('category')),
|
||||||
|
'weight_grams' => $this->filled('weight_grams') ? reFormatNumber($this->input('weight_grams')) : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach (['supplier_id', 'product_id', 'min_stock_alert'] as $key) {
|
foreach (['supplier_id', 'product_id', 'min_stock_alert'] as $key) {
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,31 @@ class StoreStockEntryRequest extends FormRequest
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'entry_type' => ['required', Rule::in(['ingredient', 'packaging', 'label', 'shipping_office'])],
|
'entry_type' => ['required', Rule::in(['ingredient', 'packaging', 'shipping'])],
|
||||||
'ingredient_id' => ['nullable', 'integer', 'exists:ingredients,id'],
|
'ingredient_id' => ['nullable', 'integer', 'exists:ingredients,id'],
|
||||||
'packaging_item_id' => ['nullable', 'integer', 'exists:packaging_items,id'],
|
'packaging_item_id' => ['nullable', 'integer', 'exists:packaging_items,id'],
|
||||||
'supplier_id' => ['required', 'integer', 'exists:suppliers,id'],
|
'supplier_id' => ['required', 'integer', 'exists:suppliers,id'],
|
||||||
'location_id' => ['required', 'integer', 'exists:locations,id'],
|
'location_id' => ['required', 'integer', 'exists:locations,id'],
|
||||||
'ordered_at' => ['required', 'date'],
|
'ordered_at' => ['required', 'date'],
|
||||||
'ordered_quantity' => ['required', 'numeric', 'min:0.000001'],
|
'ordered_quantity' => ['required', 'numeric', 'min:0.000001'],
|
||||||
|
'quality_id' => ['nullable', 'integer', 'exists:material_qualities,id'],
|
||||||
'price_per_kg' => ['nullable', 'numeric', 'min:0'],
|
'price_per_kg' => ['nullable', 'numeric', 'min:0'],
|
||||||
'price_total' => ['nullable', 'numeric', 'min:0'],
|
'price_total' => ['nullable', 'numeric', 'min:0'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'quality_id.required' => __('Bitte eine Rohstoffqualität wählen.'),
|
||||||
|
'price_per_kg.required' => __('Bitte den Netto-Preis pro kg angeben.'),
|
||||||
|
'price_total.required' => __('Bitte den Gesamtpreis netto angeben.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected function prepareForValidation(): void
|
protected function prepareForValidation(): void
|
||||||
{
|
{
|
||||||
$this->merge([
|
$this->merge([
|
||||||
|
|
@ -48,10 +61,19 @@ class StoreStockEntryRequest extends FormRequest
|
||||||
if (empty($this->input('ingredient_id'))) {
|
if (empty($this->input('ingredient_id'))) {
|
||||||
$validator->errors()->add('ingredient_id', __('Bitte einen Inhaltsstoff wählen.'));
|
$validator->errors()->add('ingredient_id', __('Bitte einen Inhaltsstoff wählen.'));
|
||||||
}
|
}
|
||||||
|
if (empty($this->input('quality_id'))) {
|
||||||
|
$validator->errors()->add('quality_id', __('Bitte eine Rohstoffqualität wählen.'));
|
||||||
|
}
|
||||||
|
if (! is_numeric($this->input('price_per_kg')) || (float) $this->input('price_per_kg') <= 0) {
|
||||||
|
$validator->errors()->add('price_per_kg', __('Bitte den Netto-Preis pro kg angeben.'));
|
||||||
|
}
|
||||||
} elseif (! empty($type)) {
|
} elseif (! empty($type)) {
|
||||||
if (empty($this->input('packaging_item_id'))) {
|
if (empty($this->input('packaging_item_id'))) {
|
||||||
$validator->errors()->add('packaging_item_id', __('Bitte einen Verpackungsartikel wählen.'));
|
$validator->errors()->add('packaging_item_id', __('Bitte einen Verpackungsartikel wählen.'));
|
||||||
}
|
}
|
||||||
|
if (! is_numeric($this->input('price_total')) || (float) $this->input('price_total') <= 0) {
|
||||||
|
$validator->errors()->add('price_total', __('Bitte den Gesamtpreis netto angeben.'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +88,7 @@ class StoreStockEntryRequest extends FormRequest
|
||||||
$data['packaging_item_id'] = null;
|
$data['packaging_item_id'] = null;
|
||||||
} else {
|
} else {
|
||||||
$data['ingredient_id'] = null;
|
$data['ingredient_id'] = null;
|
||||||
|
$data['quality_id'] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ class UpdatePackagingItemRequest extends FormRequest
|
||||||
'packaging_material_id' => ['required', 'integer', 'exists:packaging_materials,id'],
|
'packaging_material_id' => ['required', 'integer', 'exists:packaging_materials,id'],
|
||||||
'supplier_id' => ['nullable', 'integer', 'exists:suppliers,id'],
|
'supplier_id' => ['nullable', 'integer', 'exists:suppliers,id'],
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'category' => ['required', Rule::in(['packaging', 'shipping', 'label', 'shipping_office'])],
|
'category' => ['required', Rule::in(['packaging', 'shipping'])],
|
||||||
'weight_grams' => ['nullable', 'numeric', 'min:0'],
|
'weight_grams' => ['nullable', 'numeric', 'min:0'],
|
||||||
'min_stock_alert' => ['nullable', 'integer', 'min:0'],
|
'min_stock_alert' => ['nullable', 'integer', 'min:0'],
|
||||||
'url' => ['nullable', 'url', 'max:500'],
|
'url' => ['nullable', 'url', 'max:500'],
|
||||||
|
|
@ -34,6 +34,8 @@ class UpdatePackagingItemRequest extends FormRequest
|
||||||
{
|
{
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'active' => $this->boolean('active'),
|
'active' => $this->boolean('active'),
|
||||||
|
'category' => trim((string) $this->input('category')),
|
||||||
|
'weight_grams' => $this->filled('weight_grams') ? reFormatNumber($this->input('weight_grams')) : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
foreach (['supplier_id', 'product_id', 'min_stock_alert'] as $key) {
|
foreach (['supplier_id', 'product_id', 'min_stock_alert'] as $key) {
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,31 @@ class UpdateStockEntryRequest extends FormRequest
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'entry_type' => ['required', Rule::in(['ingredient', 'packaging', 'label', 'shipping_office'])],
|
'entry_type' => ['required', Rule::in(['ingredient', 'packaging', 'shipping'])],
|
||||||
'ingredient_id' => ['nullable', 'integer', 'exists:ingredients,id'],
|
'ingredient_id' => ['nullable', 'integer', 'exists:ingredients,id'],
|
||||||
'packaging_item_id' => ['nullable', 'integer', 'exists:packaging_items,id'],
|
'packaging_item_id' => ['nullable', 'integer', 'exists:packaging_items,id'],
|
||||||
'supplier_id' => ['required', 'integer', 'exists:suppliers,id'],
|
'supplier_id' => ['required', 'integer', 'exists:suppliers,id'],
|
||||||
'location_id' => ['required', 'integer', 'exists:locations,id'],
|
'location_id' => ['required', 'integer', 'exists:locations,id'],
|
||||||
'ordered_at' => ['required', 'date'],
|
'ordered_at' => ['required', 'date'],
|
||||||
'ordered_quantity' => ['required', 'numeric', 'min:0.000001'],
|
'ordered_quantity' => ['required', 'numeric', 'min:0.000001'],
|
||||||
|
'quality_id' => ['nullable', 'integer', 'exists:material_qualities,id'],
|
||||||
'price_per_kg' => ['nullable', 'numeric', 'min:0'],
|
'price_per_kg' => ['nullable', 'numeric', 'min:0'],
|
||||||
'price_total' => ['nullable', 'numeric', 'min:0'],
|
'price_total' => ['nullable', 'numeric', 'min:0'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'quality_id.required' => __('Bitte eine Rohstoffqualität wählen.'),
|
||||||
|
'price_per_kg.required' => __('Bitte den Netto-Preis pro kg angeben.'),
|
||||||
|
'price_total.required' => __('Bitte den Gesamtpreis netto angeben.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected function prepareForValidation(): void
|
protected function prepareForValidation(): void
|
||||||
{
|
{
|
||||||
$this->merge([
|
$this->merge([
|
||||||
|
|
@ -48,10 +61,19 @@ class UpdateStockEntryRequest extends FormRequest
|
||||||
if (empty($this->input('ingredient_id'))) {
|
if (empty($this->input('ingredient_id'))) {
|
||||||
$validator->errors()->add('ingredient_id', __('Bitte einen Inhaltsstoff wählen.'));
|
$validator->errors()->add('ingredient_id', __('Bitte einen Inhaltsstoff wählen.'));
|
||||||
}
|
}
|
||||||
|
if (empty($this->input('quality_id'))) {
|
||||||
|
$validator->errors()->add('quality_id', __('Bitte eine Rohstoffqualität wählen.'));
|
||||||
|
}
|
||||||
|
if (! is_numeric($this->input('price_per_kg')) || (float) $this->input('price_per_kg') <= 0) {
|
||||||
|
$validator->errors()->add('price_per_kg', __('Bitte den Netto-Preis pro kg angeben.'));
|
||||||
|
}
|
||||||
} elseif (! empty($type)) {
|
} elseif (! empty($type)) {
|
||||||
if (empty($this->input('packaging_item_id'))) {
|
if (empty($this->input('packaging_item_id'))) {
|
||||||
$validator->errors()->add('packaging_item_id', __('Bitte einen Verpackungsartikel wählen.'));
|
$validator->errors()->add('packaging_item_id', __('Bitte einen Verpackungsartikel wählen.'));
|
||||||
}
|
}
|
||||||
|
if (! is_numeric($this->input('price_total')) || (float) $this->input('price_total') <= 0) {
|
||||||
|
$validator->errors()->add('price_total', __('Bitte den Gesamtpreis netto angeben.'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +88,7 @@ class UpdateStockEntryRequest extends FormRequest
|
||||||
$data['packaging_item_id'] = null;
|
$data['packaging_item_id'] = null;
|
||||||
} else {
|
} else {
|
||||||
$data['ingredient_id'] = null;
|
$data['ingredient_id'] = null;
|
||||||
|
$data['quality_id'] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Models;
|
||||||
|
|
||||||
use Cviebrock\EloquentSluggable\Sluggable;
|
use Cviebrock\EloquentSluggable\Sluggable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App\Models\ProductImage
|
* App\Models\ProductImage
|
||||||
|
|
@ -16,9 +17,10 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
* @property string $mine
|
* @property string $mine
|
||||||
* @property int $size
|
* @property int $size
|
||||||
* @property int $active
|
* @property int $active
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property-read \App\Models\Product|null $product
|
* @property-read Product|null $product
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereActive($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereActive($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereCreatedAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereCreatedAt($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereExt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereExt($value)
|
||||||
|
|
@ -29,45 +31,51 @@ use Illuminate\Database\Eloquent\Model;
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereProductId($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereProductId($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereSize($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereSize($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereUpdatedAt($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereUpdatedAt($value)
|
||||||
|
*
|
||||||
* @property string|null $slug
|
* @property string|null $slug
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage findSimilarSlugs($attribute, $config, $slug)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage findSimilarSlugs($attribute, $config, $slug)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereSlug($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage whereSlug($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage newModelQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage newModelQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage newQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage newQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage query()
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage query()
|
||||||
|
*
|
||||||
* @property int|null $pos
|
* @property int|null $pos
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage wherePos($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ProductImage wherePos($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ProductImage withUniqueSlugConstraints(\Illuminate\Database\Eloquent\Model $model, string $attribute, array $config, string $slug)
|
* @method static \Illuminate\Database\Eloquent\Builder|ProductImage withUniqueSlugConstraints(\Illuminate\Database\Eloquent\Model $model, string $attribute, array $config, string $slug)
|
||||||
|
*
|
||||||
* @property int|null $user_wl_product_id
|
* @property int|null $user_wl_product_id
|
||||||
* @property string|null $type
|
* @property string|null $type
|
||||||
* @property object|null $attributes
|
* @property object|null $attributes
|
||||||
* @property-read \App\Models\UserWhitelabelProduct|null $user_wl_product
|
* @property-read UserWhitelabelProduct|null $user_wl_product
|
||||||
|
*
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ProductImage whereAttributes($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|ProductImage whereAttributes($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ProductImage whereType($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|ProductImage whereType($value)
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder|ProductImage whereUserWlProductId($value)
|
* @method static \Illuminate\Database\Eloquent\Builder|ProductImage whereUserWlProductId($value)
|
||||||
|
*
|
||||||
* @mixin \Eloquent
|
* @mixin \Eloquent
|
||||||
*/
|
*/
|
||||||
class ProductImage extends Model
|
class ProductImage extends Model
|
||||||
{
|
{
|
||||||
use Sluggable;
|
use Sluggable;
|
||||||
|
|
||||||
|
|
||||||
protected $table = 'product_images';
|
protected $table = 'product_images';
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'attributes' => 'object'
|
'attributes' => 'object',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'product_id', 'user_wl_product_id', 'type', 'filename', 'original_name', 'ext', 'mine', 'size', 'attributes'
|
'product_id', 'user_wl_product_id', 'type', 'filename', 'original_name', 'ext', 'mine', 'size', 'pos', 'active', 'attributes',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function sluggable(): array
|
public function sluggable(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'slug' => [
|
'slug' => [
|
||||||
'source' => 'original_name'
|
'source' => 'original_name',
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,9 +96,9 @@ class ProductImage extends Model
|
||||||
if ($size > 0) {
|
if ($size > 0) {
|
||||||
$size = (int) $size;
|
$size = (int) $size;
|
||||||
$base = log($size) / log(1024);
|
$base = log($size) / log(1024);
|
||||||
$suffixes = array(' bytes', ' KB', ' MB', ' GB', ' TB');
|
$suffixes = [' bytes', ' KB', ' MB', ' GB', ' TB'];
|
||||||
|
|
||||||
return round(pow(1024, $base - floor($base)), $precision) . $suffixes[floor($base)];
|
return round(pow(1024, $base - floor($base)), $precision).$suffixes[floor($base)];
|
||||||
} else {
|
} else {
|
||||||
return $size;
|
return $size;
|
||||||
}
|
}
|
||||||
|
|
@ -98,21 +106,21 @@ class ProductImage extends Model
|
||||||
|
|
||||||
public function getImagePath()
|
public function getImagePath()
|
||||||
{
|
{
|
||||||
if($this->type === 'uwllogo'){
|
if ($this->type === 'uwllogo') {
|
||||||
return '/images/user_product/'.$this->user_wl_product_id .'/'.$this->filename;
|
return '/images/user_product/'.$this->user_wl_product_id.'/'.$this->filename;
|
||||||
}
|
}
|
||||||
if($this->type === 'product'){
|
if ($this->type === 'product') {
|
||||||
return '/images/product/'.$this->product_id .'/'.$this->filename;
|
return '/images/product/'.$this->product_id.'/'.$this->filename;
|
||||||
}
|
}
|
||||||
if($this->type === 'wllogo'){
|
if ($this->type === 'wllogo') {
|
||||||
return '/images/product/'.$this->product_id .'/'.$this->filename;
|
return '/images/product/'.$this->product_id.'/'.$this->filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '/images/product/'.$this->product_id .'/'.$this->filename;
|
return '/images/product/'.$this->product_id.'/'.$this->filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getBaseImagePath()
|
||||||
public function getBaseImagePath(){
|
{
|
||||||
return base_path()."/storage/app/public".$this->getImagePath();
|
return base_path().'/storage/app/public'.$this->getImagePath();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class ProductIngredient extends Model
|
||||||
'product_id' => 'int',
|
'product_id' => 'int',
|
||||||
'ingredient_id' => 'int',
|
'ingredient_id' => 'int',
|
||||||
'pos' => 'int',
|
'pos' => 'int',
|
||||||
'gram' => 'decimal:3',
|
'gram' => 'decimal:6',
|
||||||
'factor' => 'decimal:2',
|
'factor' => 'decimal:2',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,28 @@ class PackagingItemRepository
|
||||||
*/
|
*/
|
||||||
public function create(array $data): PackagingItem
|
public function create(array $data): PackagingItem
|
||||||
{
|
{
|
||||||
return PackagingItem::create($this->extractAttributes($data));
|
$attrs = $this->extractAttributes($data);
|
||||||
|
\DB::enableQueryLog();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$item = PackagingItem::create($attrs);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$queries = \DB::getQueryLog();
|
||||||
|
$lastQ = end($queries);
|
||||||
|
\Log::error('PackagingItem CREATE FAILED', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'sql' => $lastQ['query'] ?? 'unknown',
|
||||||
|
'bindings' => $lastQ['bindings'] ?? [],
|
||||||
|
'binding_types' => array_map(fn($v) => gettype($v) . ':' . json_encode($v), $lastQ['bindings'] ?? []),
|
||||||
|
'attributes_passed' => $attrs,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
\DB::disableQueryLog();
|
||||||
|
|
||||||
|
return $item;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -30,15 +51,20 @@ class PackagingItemRepository
|
||||||
*/
|
*/
|
||||||
protected function extractAttributes(array $data): array
|
protected function extractAttributes(array $data): array
|
||||||
{
|
{
|
||||||
return collect($data)->only([
|
$attrs = collect($data)->only([
|
||||||
'packaging_material_id',
|
'packaging_material_id',
|
||||||
'supplier_id',
|
'supplier_id',
|
||||||
'name',
|
'name',
|
||||||
'category',
|
'category',
|
||||||
'weight_grams',
|
'weight_grams',
|
||||||
'min_stock_alert',
|
'min_stock_alert',
|
||||||
|
'url',
|
||||||
'product_id',
|
'product_id',
|
||||||
'active',
|
'active',
|
||||||
])->all();
|
])->all();
|
||||||
|
|
||||||
|
$attrs['weight_grams'] = $attrs['weight_grams'] ?? 0;
|
||||||
|
|
||||||
|
return $attrs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use App\Models\ProductCategory;
|
||||||
use App\Models\ProductImage;
|
use App\Models\ProductImage;
|
||||||
use App\Models\ProductIngredient;
|
use App\Models\ProductIngredient;
|
||||||
use App\Services\Slim;
|
use App\Services\Slim;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class ProductRepository extends BaseRepository
|
class ProductRepository extends BaseRepository
|
||||||
{
|
{
|
||||||
|
|
@ -345,7 +346,6 @@ class ProductRepository extends BaseRepository
|
||||||
$this->model->wp_number = null;
|
$this->model->wp_number = null;
|
||||||
$this->model->save();
|
$this->model->save();
|
||||||
|
|
||||||
// categories
|
|
||||||
foreach ($model->categories as $category) {
|
foreach ($model->categories as $category) {
|
||||||
ProductCategory::create([
|
ProductCategory::create([
|
||||||
'product_id' => $this->model->id,
|
'product_id' => $this->model->id,
|
||||||
|
|
@ -353,7 +353,6 @@ class ProductRepository extends BaseRepository
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// attributes
|
|
||||||
foreach ($model->attributes as $attribute) {
|
foreach ($model->attributes as $attribute) {
|
||||||
ProductAttribute::create([
|
ProductAttribute::create([
|
||||||
'product_id' => $this->model->id,
|
'product_id' => $this->model->id,
|
||||||
|
|
@ -361,6 +360,15 @@ class ProductRepository extends BaseRepository
|
||||||
'attribute_id' => $attribute->attribute_id,
|
'attribute_id' => $attribute->attribute_id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($model->attribute_variants as $variant) {
|
||||||
|
ProductAttribute::create([
|
||||||
|
'product_id' => $this->model->id,
|
||||||
|
'type_id' => $variant->type_id,
|
||||||
|
'attribute_id' => $variant->attribute_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($model->p_ingredients()->orderByPivot('pos')->get() as $ing) {
|
foreach ($model->p_ingredients()->orderByPivot('pos')->get() as $ing) {
|
||||||
ProductIngredient::create([
|
ProductIngredient::create([
|
||||||
'product_id' => $this->model->id,
|
'product_id' => $this->model->id,
|
||||||
|
|
@ -392,16 +400,38 @@ class ProductRepository extends BaseRepository
|
||||||
}
|
}
|
||||||
$this->model->packagings()->sync($packSync);
|
$this->model->packagings()->sync($packSync);
|
||||||
|
|
||||||
// images
|
foreach ($model->country_prices as $cp) {
|
||||||
foreach ($model->images as $image) {
|
CountryPrice::create([
|
||||||
|
'country_id' => $cp->country_id,
|
||||||
|
'product_id' => $this->model->id,
|
||||||
|
'c_price' => $cp->c_price,
|
||||||
|
'c_tax' => $cp->c_tax,
|
||||||
|
'c_price_old' => $cp->c_price_old,
|
||||||
|
'c_currency' => $cp->c_currency,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->copyImages($model->images, 'product');
|
||||||
|
$this->copyImages($model->whitelabel_images, 'wllogo');
|
||||||
|
|
||||||
|
return $this->model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, ProductImage> $images
|
||||||
|
*/
|
||||||
|
protected function copyImages($images, string $type): void
|
||||||
|
{
|
||||||
|
foreach ($images as $image) {
|
||||||
$name = Slim::sanitizeFileName($image->original_name);
|
$name = Slim::sanitizeFileName($image->original_name);
|
||||||
$name = uniqid().'_'.$name;
|
$name = uniqid().'_'.$name;
|
||||||
|
|
||||||
// copy
|
$sourcePath = 'images/product/'.$image->product_id.'/'.$image->filename;
|
||||||
$data = \Storage::disk('public')->copy(
|
$targetPath = 'images/product/'.$this->model->id.'/'.$name;
|
||||||
'images/product/'.$image->product_id.'/'.$image->filename,
|
|
||||||
'images/product/'.$this->model->id.'/'.$name
|
if (\Storage::disk('public')->exists($sourcePath)) {
|
||||||
);
|
\Storage::disk('public')->copy($sourcePath, $targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
ProductImage::create([
|
ProductImage::create([
|
||||||
'product_id' => $this->model->id,
|
'product_id' => $this->model->id,
|
||||||
|
|
@ -411,12 +441,11 @@ class ProductRepository extends BaseRepository
|
||||||
'ext' => $image->ext,
|
'ext' => $image->ext,
|
||||||
'mine' => $image->mine,
|
'mine' => $image->mine,
|
||||||
'size' => $image->size,
|
'size' => $image->size,
|
||||||
|
'pos' => $image->pos,
|
||||||
|
'active' => $image->active ?? true,
|
||||||
'attributes' => $image->attributes,
|
'attributes' => $image->attributes,
|
||||||
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->model;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete() {}
|
public function delete() {}
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ class ProductionService
|
||||||
$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 Gramm in der Rezeptur.', ['name' => $ing->name]),
|
'product_id' => __('Für „:name“ fehlt der Anteil (%) in der Rezeptur.', ['name' => $ing->name]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$factor = (float) ($ing->pivot->factor ?? 1.1);
|
$factor = (float) ($ing->pivot->factor ?? 1.1);
|
||||||
|
|
|
||||||
|
|
@ -81,25 +81,9 @@ class StockEntryFactory extends Factory
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function label(): static
|
public function shipping(): static
|
||||||
{
|
{
|
||||||
return $this->state(function () {
|
return $this->shippingOffice();
|
||||||
$supplier = Supplier::factory()->create();
|
|
||||||
$item = PackagingItem::factory()->create([
|
|
||||||
'supplier_id' => $supplier->id,
|
|
||||||
'category' => 'label',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'entry_type' => 'label',
|
|
||||||
'ingredient_id' => null,
|
|
||||||
'packaging_item_id' => $item->id,
|
|
||||||
'supplier_id' => $supplier->id,
|
|
||||||
'unit' => 'piece',
|
|
||||||
'price_per_kg' => null,
|
|
||||||
'price_total' => $this->faker->randomFloat(4, 5, 800),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function shippingOffice(): static
|
public function shippingOffice(): static
|
||||||
|
|
@ -108,11 +92,11 @@ class StockEntryFactory extends Factory
|
||||||
$supplier = Supplier::factory()->create();
|
$supplier = Supplier::factory()->create();
|
||||||
$item = PackagingItem::factory()->create([
|
$item = PackagingItem::factory()->create([
|
||||||
'supplier_id' => $supplier->id,
|
'supplier_id' => $supplier->id,
|
||||||
'category' => 'shipping_office',
|
'category' => 'shipping',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'entry_type' => 'shipping_office',
|
'entry_type' => 'shipping',
|
||||||
'ingredient_id' => null,
|
'ingredient_id' => null,
|
||||||
'packaging_item_id' => $item->id,
|
'packaging_item_id' => $item->id,
|
||||||
'supplier_id' => $supplier->id,
|
'supplier_id' => $supplier->id,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ return new class extends Migration
|
||||||
{
|
{
|
||||||
Schema::table('product_ingredients', function (Blueprint $table) {
|
Schema::table('product_ingredients', function (Blueprint $table) {
|
||||||
$table->unsignedSmallInteger('pos')->default(0)->after('ingredient_id');
|
$table->unsignedSmallInteger('pos')->default(0)->after('ingredient_id');
|
||||||
$table->decimal('gram', 12, 2)->nullable()->after('pos');
|
$table->decimal('gram', 12, 6)->nullable()->after('pos');
|
||||||
$table->decimal('factor', 4, 2)->default(1.10)->after('gram');
|
$table->decimal('factor', 4, 2)->default(1.10)->after('gram');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ return new class extends Migration
|
||||||
$table->foreignId('packaging_material_id')->constrained('packaging_materials');
|
$table->foreignId('packaging_material_id')->constrained('packaging_materials');
|
||||||
$table->foreignId('supplier_id')->nullable()->constrained('suppliers')->nullOnDelete();
|
$table->foreignId('supplier_id')->nullable()->constrained('suppliers')->nullOnDelete();
|
||||||
$table->string('name');
|
$table->string('name');
|
||||||
$table->enum('category', ['packaging', 'label', 'shipping_office']);
|
$table->enum('category', ['packaging', 'shipping']);
|
||||||
$table->decimal('weight_grams', 10, 2)->default(0);
|
$table->decimal('weight_grams', 10, 2)->default(0);
|
||||||
$table->unsignedInteger('min_stock_alert')->nullable();
|
$table->unsignedInteger('min_stock_alert')->nullable();
|
||||||
$table->unsignedInteger('product_id')->nullable();
|
$table->unsignedInteger('product_id')->nullable();
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ return new class extends Migration
|
||||||
{
|
{
|
||||||
Schema::create('stock_entries', function (Blueprint $table) {
|
Schema::create('stock_entries', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->enum('entry_type', ['ingredient', 'packaging', 'label', 'shipping_office']);
|
$table->enum('entry_type', ['ingredient', 'packaging', 'shipping']);
|
||||||
$table->unsignedInteger('ingredient_id')->nullable();
|
$table->unsignedInteger('ingredient_id')->nullable();
|
||||||
$table->foreign('ingredient_id')->references('id')->on('ingredients')->nullOnDelete();
|
$table->foreign('ingredient_id')->references('id')->on('ingredients')->nullOnDelete();
|
||||||
$table->foreignId('packaging_item_id')->nullable()->constrained('packaging_items')->nullOnDelete();
|
$table->foreignId('packaging_item_id')->nullable()->constrained('packaging_items')->nullOnDelete();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$driver = DB::getDriverName();
|
||||||
|
|
||||||
|
if ($driver === 'sqlite') {
|
||||||
|
DB::table('packaging_items')
|
||||||
|
->whereIn('category', ['label', 'shipping_office'])
|
||||||
|
->update(['category' => 'shipping']);
|
||||||
|
|
||||||
|
DB::table('stock_entries')
|
||||||
|
->whereIn('entry_type', ['label', 'shipping_office'])
|
||||||
|
->update(['entry_type' => 'shipping']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::statement("ALTER TABLE packaging_items MODIFY category ENUM('packaging','label','shipping_office','shipping') NOT NULL");
|
||||||
|
DB::table('packaging_items')
|
||||||
|
->whereIn('category', ['label', 'shipping_office'])
|
||||||
|
->update(['category' => 'shipping']);
|
||||||
|
DB::statement("ALTER TABLE packaging_items MODIFY category ENUM('packaging','shipping') NOT NULL");
|
||||||
|
|
||||||
|
DB::statement("ALTER TABLE stock_entries MODIFY entry_type ENUM('ingredient','packaging','label','shipping_office','shipping') NOT NULL");
|
||||||
|
DB::table('stock_entries')
|
||||||
|
->whereIn('entry_type', ['label', 'shipping_office'])
|
||||||
|
->update(['entry_type' => 'shipping']);
|
||||||
|
DB::statement("ALTER TABLE stock_entries MODIFY entry_type ENUM('ingredient','packaging','shipping') NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$driver = DB::getDriverName();
|
||||||
|
|
||||||
|
if ($driver === 'sqlite') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::statement("ALTER TABLE packaging_items MODIFY category ENUM('packaging','label','shipping_office') NOT NULL DEFAULT 'packaging'");
|
||||||
|
DB::statement("ALTER TABLE stock_entries MODIFY entry_type ENUM('ingredient','packaging','label','shipping_office') NOT NULL");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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('product_ingredients', function (Blueprint $table) {
|
||||||
|
$table->decimal('gram', 12, 6)->nullable()->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('product_ingredients', function (Blueprint $table) {
|
||||||
|
$table->decimal('gram', 12, 2)->nullable()->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -171,7 +171,7 @@ class InventoryStammdatenTestSeeder extends Seeder
|
||||||
[
|
[
|
||||||
'packaging_material_id' => $materialPappe->id,
|
'packaging_material_id' => $materialPappe->id,
|
||||||
'supplier_id' => $supplierLogistik->id,
|
'supplier_id' => $supplierLogistik->id,
|
||||||
'category' => 'shipping_office',
|
'category' => 'shipping',
|
||||||
'weight_grams' => 95,
|
'weight_grams' => 95,
|
||||||
'min_stock_alert' => 300,
|
'min_stock_alert' => 300,
|
||||||
'product_id' => null,
|
'product_id' => null,
|
||||||
|
|
@ -183,7 +183,7 @@ class InventoryStammdatenTestSeeder extends Seeder
|
||||||
[
|
[
|
||||||
'packaging_material_id' => $materialPappe->id,
|
'packaging_material_id' => $materialPappe->id,
|
||||||
'supplier_id' => $supplierOele->id,
|
'supplier_id' => $supplierOele->id,
|
||||||
'category' => 'label',
|
'category' => 'shipping',
|
||||||
'weight_grams' => 1.2,
|
'weight_grams' => 1.2,
|
||||||
'min_stock_alert' => 5000,
|
'min_stock_alert' => 5000,
|
||||||
'product_id' => null,
|
'product_id' => null,
|
||||||
|
|
|
||||||
84
dev/product management /briefing-anpassungen-27-04-2026.md
Normal file
84
dev/product management /briefing-anpassungen-27-04-2026.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
|
||||||
|
Wichtig ist, dass wir in Zukunft relativ schnell den Produktbestand einpflegen können und den Grundbestand der Inhaltsstoffe (auf Basis der Hersteller Rezept)
|
||||||
|
Wir müssen also schon mal den Lagerbestand initialisieren können
|
||||||
|
|
||||||
|
Wenn wir so etwas haben, benötigen wir ja auch an dieser Stelle einen zusätzliches Feld für einen Eingang oder Ausgang
|
||||||
|
Anzahl und Grund Muss sie angegeben werden. Hintergrund. Wenn wir ein Lagerbestand hinzufügen, weil es ja noch gar nicht in der Produktion erfasst worden ist, fügen wir XY etc. hinzu und geben das entsprechend an. Weiterhin kann es sein, dass Produkte kaputtgehen zerstört werden etc. die können wir hier auch entsprechend wieder austragen. Somit haben wir die Möglichkeit, den aktuellen Lagerstand immer auf aktuell zu halten.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Produkte Sets
|
||||||
|
|
||||||
|
Bei den Produkten ist es notwendig, dass alle Produkte in zwei Klassen unterteilt werden in die klassischen Einzelprodukte, die dann auch die entsprechende Einstellung wie Rezeptur, Verpackungsmaterial, Warenwirtschaft haben. Und es gibt Sets, Wir bauen über den Inhaltsstoffen Rezeptur eine Card ein Standardmäßig ist dort eine Checkbox nicht aktiviert und damit ist es ein Standard Produkt, wird diese Checkbox aktiviert. Handelt es sich um ein Fett, die Cards Rezeptur, Verpackungsmaterial, Warenwirtschaft Werden ausgeblendet und es Wird eine neue Card Eingeblendet die technische funktioniert, wie Rezeptur Nur werden hier Produkte hinzugefügt, gleiches System. Es geht ein Modal Fenster auf wo alle Produkte drin sind. Einzelprodukte kein Set und ich kann diese hinzufügen mit Menge. Wird später ein Set verkauft, weiß das System automatisch, welche Produkte es beinhaltet und kann entsprechend in der Warenwirtschaft die entsprechenden Mengen wieder abziehen. Setz haben auch keine Produktion in dem Sinne. Sie sind ja nur ein Päckchen, was aus unterschiedlichen Produkten zusammen zusammengepackt wird.
|
||||||
|
|
||||||
|
|
||||||
|
Neuer Einkauf
|
||||||
|
A) Felder für Preise wie folgt:
|
||||||
|
- Dropdown UST (Auswahl 19%, 7% etc.) -> Muss ich vorab in den Einstellungen definieren können
|
||||||
|
- Brutto-Preis pro kg
|
||||||
|
- Netto-Preis pro kg
|
||||||
|
-> Manche Shops zeigen mir den Netto-Preis an, manche den Brutto-Preis. Ich will da nicht immer rumrechnen. Ich trage entweder den Netto- oder Bruttobetrag ein und das jeweilige leere Feld berechnet sich dann von selbst.
|
||||||
|
|
||||||
|
|
||||||
|
Weiteres betrifft alle seiten
|
||||||
|
B) Bitte einen ausgefüllten Einkauf duplizierbar machen. Hintergrund: Manchmal kommen Rohstoffe in 2,3 Kanistern mit unterschiedlichen Chargen, dann muss ich das nicht alles nochmal ausfüllen.
|
||||||
|
|
||||||
|
c) Bitte die Icons Auge, Stift und Mülleimber in der Übersicht „Einkauf & Wareneingang“ weiter auseinander und ein Stück größer - ist schwer anzuklicken auf dem ipad - bitte bei allen Tabellen in den Menüpunkten
|
||||||
|
|
||||||
|
…………….
|
||||||
|
|
||||||
|
Lieferanten
|
||||||
|
A) Hier muss ich ein Kästchen anklicken können, ob ich per Mail oder online in einem Shop bestelle. Hintergrund: Bei manchen Lieferanten muss ich per Mail bestellen, da soll dann in der Liste für den Rohstoffbestand ein Link entweder zu einem Onlineshop oder ins Mailformular mit der richtigen Email-Adresse bereits hinterlegt erscheinen.
|
||||||
|
|
||||||
|
B) Ich möchte bei jedem Lieferanten eine Lieferzeit eintragen können, die ich vorher definiere (z. B. 3-5 in Tage). Aber bitte als Textfeld. Zuätzlich benötige ich bei den INCIs auch ein Feld, wo ich eine Lieferzeit eintragen kann (auch als Textfeld). Wenn da was drinsteht, dann überschreibt der Inhalt den vom Feld beim Lieferanten. Hintergrund: Dragonspice z.B. liefert immer prompt nach 3 Tagen. Manche (wenn auch wenige) INCI von denen sind aber nicht immer vorrätig oder müssen vorbestellt werden.
|
||||||
|
|
||||||
|
//in der Lieferzeiten
|
||||||
|
…………….
|
||||||
|
|
||||||
|
INCIs
|
||||||
|
A) Bei jedem INCI muss ich eine Mehrfachauswahl als Dropdown haben, bei welchen Lieferanten ich dieses eine INCI kaufen kann (beziehe nicht immer beim gleichen). Das brauchen wir dann später in der Übersicht Rohstoffbestand.
|
||||||
|
|
||||||
|
B) Pro INIC möchte die Höhe der UST hinterlegen über ein Dropdown (7%, 19% etc. -> anzulegen in Einstellungen). // Steuer als Enum kann zukünfig auch geänder werden!
|
||||||
|
|
||||||
|
…………
|
||||||
|
|
||||||
|
Neue Produktion
|
||||||
|
A) Bitte weniger Linien - Überschriften Charge und Menge bei den einzelnen Rohstoffen weg - steht ja im Dropdown / Feld (hier noch g hinter die Zahl)
|
||||||
|
|
||||||
|
B) Im Dropdown für die Charge steht zusätzlich noch der Lieferant, also „Manske GmbH - DE-170722 - 30.09.2028“ (Datum bitte so in dieser Schreibweise und die Buchstaben MHD raus). Die Charge darf auch nur angezeigt werden, wenn noch was von ihr da ist.
|
||||||
|
|
||||||
|
C) Die Basis Ist die Herstellerrezeptur! Daraus berechnen sich die Nötigen Felder.
|
||||||
|
Es ist ein Fehler drinne bei Charge hinzufügen. Wir werden dann automatisch zwei neue Felder hinzugefügt.
|
||||||
|
Wenn oben etwas geändert wird wie zum Beispiel die Stückzahl darf sich die Rezeptur nicht mehr ändern. Wenn hier schon Fälle ausgefüllt sind oder Chargen hinzugefügt sind, sollen die so bleiben, soll sich dann nur entsprechend die soll Angaben in Gramm anpassen.
|
||||||
|
|
||||||
|
|
||||||
|
D) Schau Dir mal bitte „Neue Produktion“ auf dem ipad an. Die Felder Produktionsdatum und Produzierte Stückzahl überlappen grafisch.
|
||||||
|
|
||||||
|
E) Wir haben die Produktentwicklung noch nicht bedacht, da gehen ja auch Rohstoffe für drauf. Unter dem Menüpunkt „Produktion“ brauchen wir noch einen Menüpunkt Produktentwicklung. Konzept dafür folgt, aber bitte schon mal einfügen, damit wir das nicht vergessen.
|
||||||
|
|
||||||
|
…………….
|
||||||
|
|
||||||
|
Übersicht Rohstoffeinkauf
|
||||||
|
Neuer Menüpunkt: Rohstoffbestand
|
||||||
|
|
||||||
|
Das ist unsere Übersicht, in der ich sehen kann, welche Rohstoffe im Lager sind. Alles weitere in den Screens anbei.
|
||||||
|
|
||||||
|
…………….
|
||||||
|
|
||||||
|
Produktbestand
|
||||||
|
A) Wir haben einen Hauptmenüpunkt: Produktbestand (sh. Screen anbei - hier trage ich Ein und Ausgänge ein - ganz simpel)
|
||||||
|
Darunter einen weiteren Menüpunkt: Historie (Hier machen wir ein umfangreiches Archiv vor allem fürs Finanzamt, bei dem man jedes einzelne Produkt nachverfolgen kann, wenn nötig. D.h., JEDER Ein- und Ausgang wird hier dokumentiert - sei es durch einen Verkauf über die Shops oder die manuelle Eingabe z. B. durch Verschenken von Testern. Hier gibt es zwei Screens für.
|
||||||
|
|
||||||
|
B) Bei dem Produktbestand dürfen nur Hauptprodukte angezeigt werden. Also die „Bio Tattoocreme 15 ml (einzeln)“ ist das Hauptprodukt, das Produkt Bio Tattoocreme 50 Stück als Set ist das „Child-Produkt“ davon. Das muss ich also in den Produkten noch vermerken. Musst Du Dir was überlegen. Aber beim Child-Produkt könnte man das unten im Bereich Verpackung & Material noch mit angeben, dass wir hier 50 x das Hauptprodukt haben.
|
||||||
|
|
||||||
|
…………….
|
||||||
|
|
||||||
|
2FA - Google Authenticator für die Admins
|
||||||
|
Wie besprochen
|
||||||
|
|
||||||
|
Rechtevergabe - Wir vergeben pro Block ein Zugriffsrecht. Am Ende sind Mitarbeiter Admins, aber ich kann als SuperAdmin anklicken, welchen Block die sehen und bearbeiten können. Also ein Mitarbeitern kann z. B. die Produktliste einsehen, aber nicht bearbeiten oder einsehen UND bearbeiten.
|
||||||
|
|
||||||
|
………………
|
||||||
|
|
||||||
|
Nicht vorrätig
|
||||||
|
Brauche die Funktion „Nicht vorrätig“ inkl. Zeitangabe. Ich hake also ein Kästchen im Produkt an „Nicht vorrätig“ und trage dahinter eine Zahl in einem Feld ein (Anzahl Tage). Von da an zählt das System runter und die Zahl aktualisiert sich dann in der Bestellans
|
||||||
|
|
@ -0,0 +1,500 @@
|
||||||
|
# Aktualisierter Entwicklungsplan: Warenwirtschaft, Produktion & Produktbestand
|
||||||
|
|
||||||
|
> **Version:** 3.0 - Stand 13.05.2026
|
||||||
|
> **Prioritaet:** Anpassungsbriefing vom 27.04.2026 zuerst einarbeiten
|
||||||
|
> **Referenzen:** `entwicklungsplan.md`, `briefing-anpassungen-27-04-2026.md`, `feedback.md`, Screens unter `screens/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Aktueller Ist-Stand
|
||||||
|
|
||||||
|
Laut bestehendem `entwicklungsplan.md` sind die Phasen 0 bis 5.1 umgesetzt:
|
||||||
|
|
||||||
|
- **Phase 0:** Bugfixes Produkt/INCI, Sync-Logik, Sortierung, Kopierfehler.
|
||||||
|
- **Phase 1:** INCI-Erweiterung, Rezepturfelder, Haltbarkeit, Produktkopie.
|
||||||
|
- **Phase 2:** Stammdaten-Module fuer Lagerorte, Lieferanten, Verpackung, Qualitaeten.
|
||||||
|
- **Phase 3:** Einkauf & Wareneingang als Zwei-Stufen-Workflow.
|
||||||
|
- **Phase 4:** Verpackung & Material am Produkt.
|
||||||
|
- **Phase 5:** Produktion MVP mit Chargen, Rezeptur-API und Packaging-Snapshot.
|
||||||
|
- **Phase 5.1:** Feedback-Korrekturen zu Menue, Rezeptur, Produktion und Verpackungsartikeln.
|
||||||
|
|
||||||
|
Noch offen aus dem alten Plan:
|
||||||
|
|
||||||
|
- **Phase 6:** Bestandsuebersichten & Alarme.
|
||||||
|
- **Phase 7:** Ausgang / Ausschuss.
|
||||||
|
- **Phase 8:** Rollen, Audit-Trail & Feinschliff.
|
||||||
|
- **Phase 9:** Einstellungen & Konfiguration.
|
||||||
|
- Offene Tests aus Phase 5.1, insbesondere Rezeptur/Hersteller-Rezeptur, Produktion kopieren/bearbeiten, aktive Produkte und Menue-Labels.
|
||||||
|
|
||||||
|
Das Anpassungsbriefing vom 27.04.2026 erweitert den Scope wesentlich. Deshalb wird vor den alten Phasen 6 bis 9 eine neue priorisierte Phase eingeschoben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Neue Priorisierung
|
||||||
|
|
||||||
|
### Sofortige Reihenfolge
|
||||||
|
|
||||||
|
| Prioritaet | Phase | Titel | Status | Abhaengigkeit |
|
||||||
|
|------------|-------|-------|--------|---------------|
|
||||||
|
| P0 | 5.2 | Anpassungsbriefing 27.04.2026 | neu, sofort | Phasen 0-5.1 |
|
||||||
|
| P1 | 6 | Rohstoffbestand & Produktbestand | offen, angepasst | Phase 5.2 |
|
||||||
|
| P2 | 7 | Ausgang, manuelle Bestandsbewegungen & Historie | offen, erweitert | Phase 6 |
|
||||||
|
| P3 | 8 | Rechte, 2FA, Audit-Trail | offen, erweitert | Phase 5.2 + 7 |
|
||||||
|
| P4 | 9 | Einstellungen & Konfiguration | offen, erweitert | Phase 5.2 |
|
||||||
|
|
||||||
|
**Wichtig:** Funktionen aus Phase 6-9 duerfen erst final umgesetzt werden, wenn die neuen Stammdatenfelder, Produkt-Set-Logik und Produktbestandslogik aus Phase 5.2 geklaert und umgesetzt sind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Phase 5.2: Anpassungsbriefing 27.04.2026
|
||||||
|
|
||||||
|
> **Ziel:** Alle neuen Anforderungen aus `briefing-anpassungen-27-04-2026.md` in das bestehende Warenwirtschaftsmodell integrieren, bevor Bestandsuebersichten und Historien final gebaut werden.
|
||||||
|
|
||||||
|
### 5.2.1 Einstellungen: UST-Saetze und Lieferzeiten
|
||||||
|
|
||||||
|
**Anforderungen**
|
||||||
|
|
||||||
|
- Neue konfigurierbare UST-Saetze, z. B. 19 %, 7 %.
|
||||||
|
- UST nicht hart im Code, sondern als Stammdatum/Setting verwalten.
|
||||||
|
- Lieferzeiten als frei definierbare Textwerte, z. B. `3-5 Tage`.
|
||||||
|
|
||||||
|
**Umsetzung**
|
||||||
|
|
||||||
|
- Neues Einstellungsmodul oder Erweiterung der bestehenden Warenwirtschafts-Einstellungen:
|
||||||
|
- `tax_rates`: Name, Prozentwert, aktiv.
|
||||||
|
- `delivery_times`: Textwert, aktiv, Sortierung.
|
||||||
|
- UST-Dropdowns in Einkauf und INCI nutzen diese Werte.
|
||||||
|
- Lieferzeit-Dropdowns/Textfelder bei Lieferant und INCI anbinden.
|
||||||
|
|
||||||
|
**Akzeptanzkriterien**
|
||||||
|
|
||||||
|
- SuperAdmin kann UST-Saetze und Lieferzeiten pflegen.
|
||||||
|
- Einkauf und INCI koennen nur aktive UST-Saetze auswaehlen.
|
||||||
|
- Lieferzeiten bleiben bewusst als Text flexibel.
|
||||||
|
|
||||||
|
### 5.2.2 Produkte: Einzelprodukt, Set und Hauptprodukt-Zuordnung
|
||||||
|
|
||||||
|
**Anforderungen**
|
||||||
|
|
||||||
|
- Produkte werden in zwei Klassen unterschieden:
|
||||||
|
- **Einzelprodukt:** hat Rezeptur, Hersteller-Rezeptur, Verpackung & Material, Warenwirtschaft.
|
||||||
|
- **Set:** hat keine eigene Rezeptur/Produktion, sondern besteht aus mehreren Einzelprodukten mit Menge.
|
||||||
|
- Im Produktformular wird eine Checkbox/Option eingebaut: `Ist Set`.
|
||||||
|
- Wenn `Ist Set` aktiv ist:
|
||||||
|
- Karten Rezeptur, Verpackung & Material und Warenwirtschaft ausblenden.
|
||||||
|
- Neue Karte `Set-Bestandteile` anzeigen.
|
||||||
|
- Modal mit auswaehlbaren Einzelprodukten, keine Sets.
|
||||||
|
- Produktbestand darf nur Hauptprodukte anzeigen.
|
||||||
|
- Child-/Set-Produkte muessen dem Hauptprodukt zugeordnet werden koennen, z. B. `50 x Bio Tattoocreme 15 ml`.
|
||||||
|
|
||||||
|
**Umsetzung**
|
||||||
|
|
||||||
|
- `products` erweitern:
|
||||||
|
- `is_set` boolean.
|
||||||
|
- `main_product_id` nullable FK auf `products`.
|
||||||
|
- optional `main_product_quantity` fuer Child-Produkte.
|
||||||
|
- Neue Pivot-Tabelle fuer Sets:
|
||||||
|
- `product_set_items`: `set_product_id`, `component_product_id`, `quantity`.
|
||||||
|
- Produktformular:
|
||||||
|
- Umschaltlogik fuer Einzelprodukt/Set.
|
||||||
|
- Set-Bestandteile wie bestehende Rezeptur-/Packaging-Modals aufbauen.
|
||||||
|
- Validierung:
|
||||||
|
- Set darf keine Sets enthalten.
|
||||||
|
- Set braucht mindestens einen Bestandteil.
|
||||||
|
- Einzelprodukt darf Rezeptur/Packaging/Warenwirtschaft pflegen.
|
||||||
|
|
||||||
|
**Akzeptanzkriterien**
|
||||||
|
|
||||||
|
- Ein Set kann aus mehreren Einzelprodukten bestehen.
|
||||||
|
- Beim Verkauf eines Sets werden die enthaltenen Einzelprodukte im Produktbestand reduziert.
|
||||||
|
- Set-Produkte erscheinen nicht als selbst produzierbare Produkte.
|
||||||
|
- Produktbestand zeigt nur Haupt-/Einzelprodukte, nicht die Child-/Set-Varianten.
|
||||||
|
|
||||||
|
### 5.2.3 Einkauf & Wareneingang erweitern
|
||||||
|
|
||||||
|
**Anforderungen**
|
||||||
|
|
||||||
|
- Einkauf bekommt UST-Dropdown.
|
||||||
|
- Brutto-Preis pro kg und Netto-Preis pro kg: eines der beiden Felder kann eingegeben werden, das andere berechnet sich automatisch.
|
||||||
|
- Ausgefuellten Einkauf duplizierbar machen.
|
||||||
|
- Aktionsicons Auge/Stift/Muelleimer in Tabellen groesser und mit mehr Abstand, iPad-tauglich.
|
||||||
|
|
||||||
|
**Umsetzung**
|
||||||
|
|
||||||
|
- `stock_entries` erweitern:
|
||||||
|
- `tax_rate_id` oder gespeicherter Snapshot `tax_rate_percent`.
|
||||||
|
- `price_per_kg_net`.
|
||||||
|
- `price_per_kg_gross`.
|
||||||
|
- Frontend-JS:
|
||||||
|
- Netto/Brutto anhand UST gegenseitig berechnen.
|
||||||
|
- Rundung einheitlich definieren.
|
||||||
|
- Copy-Route fuer Einkauf:
|
||||||
|
- Dupliziert Stufe-1-Felder.
|
||||||
|
- Laesst Chargennummer, MHD, Eingangsdaten und Status leer bzw. setzt wieder auf `pending`.
|
||||||
|
- Gemeinsame Tabellen-Aktionsklasse einfuehren:
|
||||||
|
- groessere Buttons.
|
||||||
|
- mehr Abstand.
|
||||||
|
- konsequent fuer Einkaufs-, Lieferanten-, Produktions-, Bestands- und Stammdatentabellen.
|
||||||
|
|
||||||
|
**Akzeptanzkriterien**
|
||||||
|
|
||||||
|
- Einkauf kann mit Netto oder Brutto angelegt werden.
|
||||||
|
- Beim Wechsel des UST-Satzes wird das Gegenfeld aktualisiert.
|
||||||
|
- Ein Einkauf kann fuer mehrere Kanister/Chargen komfortabel dupliziert werden.
|
||||||
|
- Tabellenaktionen sind auf dem iPad gut klickbar.
|
||||||
|
|
||||||
|
### 5.2.4 Lieferanten erweitern
|
||||||
|
|
||||||
|
**Anforderungen**
|
||||||
|
|
||||||
|
- Lieferant braucht Auswahl, ob Bestellung per Mail oder Online-Shop erfolgt.
|
||||||
|
- Je nach Auswahl soll spaeter in Rohstoffbestand/Bestellhilfe ein Link zum Shop oder ein Mail-Link erscheinen.
|
||||||
|
- Lieferant bekommt Lieferzeit als frei pflegbares Textfeld.
|
||||||
|
|
||||||
|
**Umsetzung**
|
||||||
|
|
||||||
|
- `suppliers` erweitern:
|
||||||
|
- `order_method` enum/string: `email`, `online_shop`.
|
||||||
|
- `order_email` nullable, falls abweichend von Kontakt-Mail.
|
||||||
|
- `order_url` nullable, falls abweichend von allgemeiner URL.
|
||||||
|
- `delivery_time` nullable string.
|
||||||
|
- Lieferantenformular:
|
||||||
|
- Radio/Select fuer Bestellart.
|
||||||
|
- passende Felder anzeigen.
|
||||||
|
- Spaetere Nutzung in Rohstoffbestand:
|
||||||
|
- Button `Bestellen` erzeugt `mailto:` oder oeffnet Shop-URL.
|
||||||
|
|
||||||
|
**Akzeptanzkriterien**
|
||||||
|
|
||||||
|
- Pro Lieferant ist erkennbar, wie bestellt wird.
|
||||||
|
- Rohstoffbestand kann daraus einen konkreten Bestell-Link ableiten.
|
||||||
|
- Lieferzeit des Lieferanten ist sichtbar und editierbar.
|
||||||
|
|
||||||
|
### 5.2.5 INCI erweitern
|
||||||
|
|
||||||
|
**Anforderungen**
|
||||||
|
|
||||||
|
- Pro INCI Mehrfachauswahl der Lieferanten, bei denen es gekauft werden kann.
|
||||||
|
- Pro INCI UST-Satz hinterlegen.
|
||||||
|
- Pro INCI optional eigene Lieferzeit; diese ueberschreibt die Lieferzeit des Lieferanten.
|
||||||
|
|
||||||
|
**Umsetzung**
|
||||||
|
|
||||||
|
- Neue Pivot-Tabelle:
|
||||||
|
- `ingredient_supplier`: `ingredient_id`, `supplier_id`, optional `preferred`, `supplier_sku`, `url`.
|
||||||
|
- `ingredients` erweitern:
|
||||||
|
- `tax_rate_id` oder Snapshot.
|
||||||
|
- `delivery_time` nullable string.
|
||||||
|
- INCI-Formular:
|
||||||
|
- Lieferanten-Mehrfachauswahl per Select2.
|
||||||
|
- UST-Dropdown.
|
||||||
|
- Lieferzeit-Textfeld.
|
||||||
|
|
||||||
|
**Akzeptanzkriterien**
|
||||||
|
|
||||||
|
- Ein INCI kann mehrere Lieferanten haben.
|
||||||
|
- Rohstoffbestand kann alle moeglichen Lieferanten mit Lieferzeit anzeigen.
|
||||||
|
- INCI-Lieferzeit ueberschreibt Lieferanten-Lieferzeit.
|
||||||
|
|
||||||
|
### 5.2.6 Produktion korrigieren
|
||||||
|
|
||||||
|
**Anforderungen**
|
||||||
|
|
||||||
|
- Neue Produktion optisch vereinfachen:
|
||||||
|
- weniger Linien.
|
||||||
|
- Ueberschriften `Charge` und `Menge` bei einzelnen Rohstoffen entfernen.
|
||||||
|
- bei Mengenangaben `g` hinter die Zahl.
|
||||||
|
- Chargen-Dropdown zeigt Lieferant, Charge und MHD:
|
||||||
|
- Beispiel: `Manske GmbH - DE-170722 - 30.09.2028`.
|
||||||
|
- `MHD`-Text nicht anzeigen.
|
||||||
|
- Nur Chargen anzeigen, von denen noch Bestand vorhanden ist.
|
||||||
|
- Basis fuer Produktion ist die Hersteller-Rezeptur.
|
||||||
|
- Fehler beim Hinzufuegen einer Charge beheben: Es duerfen nicht automatisch zwei neue Felder erscheinen.
|
||||||
|
- Wenn oben z. B. Stueckzahl geaendert wird:
|
||||||
|
- bestehende ausgefuellte Chargen/Mengen bleiben erhalten.
|
||||||
|
- nur Soll-Angaben in Gramm werden neu berechnet.
|
||||||
|
- iPad-Layout: Produktionsdatum und produzierte Stueckzahl duerfen nicht ueberlappen.
|
||||||
|
- Neuer Untermenuepunkt `Produktentwicklung` unter Produktion als Platzhalter.
|
||||||
|
|
||||||
|
**Umsetzung**
|
||||||
|
|
||||||
|
- `ProductionController::recipeJson()` auf Hersteller-Rezeptur als Basis umstellen.
|
||||||
|
- Chargenquery im `InventoryService` oder `ProductionService`:
|
||||||
|
- nur `received` Chargen.
|
||||||
|
- Restbestand pro Charge > 0.
|
||||||
|
- Label mit Lieferant, Chargennummer, Datum `d.m.Y`.
|
||||||
|
- JS fuer Chargen-Splitting korrigieren:
|
||||||
|
- genau eine neue Zeile pro Klick.
|
||||||
|
- vorhandene Eingaben beim Neuberechnen nicht ueberschreiben.
|
||||||
|
- Responsive Bootstrap-Grid fuer Kopfdaten der Produktion ueberarbeiten.
|
||||||
|
- Route/View fuer `Produktentwicklung` als Platzhalterseite:
|
||||||
|
- Hinweis: Konzept folgt.
|
||||||
|
- noch keine Bestandsbuchung.
|
||||||
|
|
||||||
|
**Akzeptanzkriterien**
|
||||||
|
|
||||||
|
- Produktion basiert auf Hersteller-Rezeptur.
|
||||||
|
- Chargenliste zeigt nur verfuegbare Chargen mit Lieferant und Datum.
|
||||||
|
- Aenderung der Stueckzahl zerstoert keine manuell eingetragenen Chargen.
|
||||||
|
- iPad-Darstellung ist nutzbar.
|
||||||
|
- Menuepunkt Produktentwicklung existiert sichtbar.
|
||||||
|
|
||||||
|
### 5.2.7 Rohstoffbestand als neuer Schwerpunkt
|
||||||
|
|
||||||
|
**Anforderungen**
|
||||||
|
|
||||||
|
- Neuer Menuepunkt `Rohstoffbestand`.
|
||||||
|
- Uebersicht zeigt, welche Rohstoffe im Lager sind.
|
||||||
|
- Muss Lieferanten, Lieferzeiten und Bestellmoeglichkeiten beruecksichtigen.
|
||||||
|
- Grundlage fuer spaetere Nachbestellung/Knappheitsanzeige.
|
||||||
|
|
||||||
|
**Umsetzung**
|
||||||
|
|
||||||
|
- Bestehende alte Phase 6 `Bestand Rohstoffe` wird inhaltlich auf `Rohstoffbestand` priorisiert.
|
||||||
|
- Spaltenvorschlag:
|
||||||
|
- INCI/Rohstoff.
|
||||||
|
- Qualitaet.
|
||||||
|
- Gesamtbestand.
|
||||||
|
- Bestand je Lagerort.
|
||||||
|
- reserviert/verbraucht durch Produktionen.
|
||||||
|
- Meldebestand oder berechneter Bedarf.
|
||||||
|
- Status.
|
||||||
|
- Lieferanten.
|
||||||
|
- Lieferzeit.
|
||||||
|
- Bestellaktion.
|
||||||
|
- Lieferzeitlogik:
|
||||||
|
- INCI-Lieferzeit vor Lieferanten-Lieferzeit.
|
||||||
|
- Bestellaktion:
|
||||||
|
- Online-Shop-Link oder Mail-Link je Lieferant.
|
||||||
|
|
||||||
|
**Akzeptanzkriterien**
|
||||||
|
|
||||||
|
- Rohstoffbestand zeigt reale Restbestaende aus Wareneingang minus Produktion/Ausgang.
|
||||||
|
- Nur Chargen mit Restbestand fliessen ein.
|
||||||
|
- Kritische Rohstoffe sind visuell markiert.
|
||||||
|
- Bestellweg ist direkt aus der Uebersicht erreichbar.
|
||||||
|
|
||||||
|
### 5.2.8 Produktbestand & Historie
|
||||||
|
|
||||||
|
**Anforderungen**
|
||||||
|
|
||||||
|
- Neuer Hauptmenuepunkt `Produktbestand`.
|
||||||
|
- Unterpunkt `Historie`.
|
||||||
|
- Produktbestand erlaubt einfache Ein- und Ausgaenge.
|
||||||
|
- Historie dokumentiert jeden Ein- und Ausgang revisionssicher, auch Verkauf ueber Shops und manuelle Eingaben.
|
||||||
|
- Screens zeigen:
|
||||||
|
- Suche.
|
||||||
|
- Checkbox `nur kritische anzeigen`.
|
||||||
|
- Bestand je Produkt.
|
||||||
|
- rote/gelbe Markierung bei kritischem Bestand.
|
||||||
|
- Aktionsbuttons `+`, `-`, `Produzieren`.
|
||||||
|
- Produktbestand zeigt nur Hauptprodukte.
|
||||||
|
|
||||||
|
**Umsetzung**
|
||||||
|
|
||||||
|
- Neue Tabellen:
|
||||||
|
- `product_stock_movements`: Produkt, Richtung `in/out`, Menge, Grund, Quelle, User, Datum, Referenz.
|
||||||
|
- optional `product_stock_thresholds` oder Felder an `products`: `min_product_stock`, `critical_product_stock`.
|
||||||
|
- Produktbestand wird berechnet:
|
||||||
|
- Summe Produktion/manuelle Eingaenge minus Verkauf/manuelle Ausgaenge/Set-Verbrauch.
|
||||||
|
- Manueller Eingang/Ausgang:
|
||||||
|
- Menge Pflicht.
|
||||||
|
- Grund Pflicht.
|
||||||
|
- Richtung Pflicht.
|
||||||
|
- Historie:
|
||||||
|
- filterbar nach Produkt, Quelle, Zeitraum, User.
|
||||||
|
- unveraenderbar bzw. Korrektur nur ueber Gegenbuchung.
|
||||||
|
- Shop-Verkaeufe:
|
||||||
|
- Produktbestand nach Bestellung reduzieren.
|
||||||
|
- Bei Sets Bestand der enthaltenen Einzelprodukte reduzieren.
|
||||||
|
|
||||||
|
**Akzeptanzkriterien**
|
||||||
|
|
||||||
|
- Produktbestand ist schnell pflegbar.
|
||||||
|
- Jede Bewegung steht in der Historie.
|
||||||
|
- Set-Verkaeufe reduzieren die enthaltenen Einzelprodukte.
|
||||||
|
- Kritische Produkte koennen gefiltert werden.
|
||||||
|
|
||||||
|
### 5.2.9 Nicht vorrätig mit Zeitangabe
|
||||||
|
|
||||||
|
**Anforderungen**
|
||||||
|
|
||||||
|
- Produkt bekommt Checkbox `Nicht vorrätig`.
|
||||||
|
- Zahl in Tagen wird hinterlegt.
|
||||||
|
- Im Shop erscheint z. B. `In ca. 14 Tagen wieder da!`.
|
||||||
|
- Die Zahl zaehlt automatisch runter und aktualisiert sich in der Bestellansicht.
|
||||||
|
|
||||||
|
**Umsetzung**
|
||||||
|
|
||||||
|
- `products` erweitern:
|
||||||
|
- `out_of_stock_until` date nullable oder `out_of_stock_days` plus Startdatum.
|
||||||
|
- Empfehlung: `out_of_stock_until`, weil daraus Resttage sauber berechnet werden.
|
||||||
|
- Produktformular:
|
||||||
|
- Checkbox.
|
||||||
|
- Tagefeld.
|
||||||
|
- beim Speichern `now()->addDays($days)`.
|
||||||
|
- Shopanzeige:
|
||||||
|
- Wenn Datum in Zukunft: Kaufbutton ersetzen/ergaenzen mit Hinweis.
|
||||||
|
- Resttage dynamisch berechnen.
|
||||||
|
- Scheduler optional:
|
||||||
|
- Status nach Ablauf automatisch deaktivieren oder Anzeige laeuft durch Datumslogik aus.
|
||||||
|
|
||||||
|
**Akzeptanzkriterien**
|
||||||
|
|
||||||
|
- Produkt kann zeitweise als nicht vorrätig markiert werden.
|
||||||
|
- Hinweis im Shop zeigt aktuelle Resttage.
|
||||||
|
- Nach Ablauf verschwindet der Hinweis automatisch.
|
||||||
|
|
||||||
|
### 5.2.10 Admin-Sicherheit: 2FA und blockbasierte Rechte
|
||||||
|
|
||||||
|
**Anforderungen**
|
||||||
|
|
||||||
|
- 2FA per Google Authenticator fuer Admins.
|
||||||
|
- Rechtevergabe pro Block:
|
||||||
|
- ansehen.
|
||||||
|
- bearbeiten.
|
||||||
|
- optional loeschen/freigeben.
|
||||||
|
- Mitarbeiter koennen Admins sein, aber nur ausgewaehlte Bereiche sehen/bearbeiten.
|
||||||
|
|
||||||
|
**Umsetzung**
|
||||||
|
|
||||||
|
- 2FA:
|
||||||
|
- Pruefen, ob bestehende Auth-Struktur mit `App\User` und Guard `user` erweitert wird.
|
||||||
|
- TOTP-Secret am User speichern.
|
||||||
|
- Setup-Flow fuer Admins.
|
||||||
|
- Login-Zwischenschritt fuer 2FA-Code.
|
||||||
|
- Rechte:
|
||||||
|
- Neues Berechtigungsmodell statt nur numerischem `admin`-Level fuer Warenwirtschaftsbloecke.
|
||||||
|
- Tabellen z. B. `admin_permission_blocks`, `admin_permission_user`.
|
||||||
|
- Bloecke: Produkte, Einkauf, Rohstoffbestand, Produktbestand, Produktion, Lieferanten, Einstellungen, Historie.
|
||||||
|
- Middleware/Gates fuer `view` und `edit`.
|
||||||
|
|
||||||
|
**Akzeptanzkriterien**
|
||||||
|
|
||||||
|
- Admin kann ohne 2FA nicht in geschuetzte Bereiche, sobald 2FA aktiviert ist.
|
||||||
|
- SuperAdmin kann pro Mitarbeiter Rechte je Block vergeben.
|
||||||
|
- Navigation zeigt nur erlaubte Bloecke.
|
||||||
|
- Bearbeiten ist getrennt von Einsehen steuerbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Angepasste offene Phasen nach 5.2
|
||||||
|
|
||||||
|
## Phase 6: Rohstoffbestand & Produktbestand
|
||||||
|
|
||||||
|
> **Status:** offen, erweitert durch Screens und Anpassungsbriefing.
|
||||||
|
|
||||||
|
### Inhalt
|
||||||
|
|
||||||
|
- `InventoryService` finalisieren.
|
||||||
|
- Rohstoffbestand mit Lieferanten-, Lieferzeit-, UST- und Bestellinformationen.
|
||||||
|
- Produktbestand mit Hauptproduktfilter, kritischen Markierungen, manuellen Bewegungen und `Produzieren`-Shortcut.
|
||||||
|
- Bestand pro Lagerort und Gesamtbestand.
|
||||||
|
- Kritisch-Filter analog Screen.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Bestandsberechnung Rohstoffe pro Charge und Lagerort.
|
||||||
|
- Nur Chargen mit Restbestand werden angezeigt.
|
||||||
|
- Produktbestand zeigt nur Hauptprodukte.
|
||||||
|
- Set-Verkauf reduziert Einzelproduktbestand.
|
||||||
|
- Kritisch-Filter zeigt nur Produkte/Rohstoffe unter Schwellwert.
|
||||||
|
|
||||||
|
## Phase 7: Ausgang, Bewegungen & Historie
|
||||||
|
|
||||||
|
> **Status:** offen, inhaltlich erweitert.
|
||||||
|
|
||||||
|
### Inhalt
|
||||||
|
|
||||||
|
- Alter `Ausgang / Ausschuss` bleibt fuer Rohstoffe/Verpackung relevant.
|
||||||
|
- Produktbestand bekommt eigene Bewegungslogik.
|
||||||
|
- Produktbestand-Historie als Finanzamt-/Audit-Archiv.
|
||||||
|
- Jeder manuelle Ein- und Ausgang braucht Grund, User und Datum.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Rohstoff-Ausgang reduziert Rohstoffbestand.
|
||||||
|
- Produkt-Ausgang reduziert Produktbestand.
|
||||||
|
- Historie dokumentiert alle Quellen: Produktion, Verkauf, manuell, Set.
|
||||||
|
- Manuelle Korrektur ohne Grund wird abgelehnt.
|
||||||
|
|
||||||
|
## Phase 8: Rollen, Audit-Trail, 2FA
|
||||||
|
|
||||||
|
> **Status:** offen, erweitert.
|
||||||
|
|
||||||
|
### Inhalt
|
||||||
|
|
||||||
|
- Bisheriger Audit-Trail bleibt Pflicht.
|
||||||
|
- Blockbasierte Rechte ersetzen fuer neue Module reine Level-Annahmen.
|
||||||
|
- 2FA fuer Admins.
|
||||||
|
- Bestehende Rollenlevel bleiben als Grundschutz erhalten, werden aber fuer Modulrechte verfeinert.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- User mit Leserecht kann sehen, aber nicht speichern.
|
||||||
|
- User ohne Blockrecht sieht den Menuepunkt nicht.
|
||||||
|
- 2FA-Zwischenschritt greift fuer Admins.
|
||||||
|
- Audit-Log wird bei jeder Bestandsbewegung geschrieben.
|
||||||
|
|
||||||
|
## Phase 9: Einstellungen & Konfiguration
|
||||||
|
|
||||||
|
> **Status:** offen, erweitert.
|
||||||
|
|
||||||
|
### Inhalt
|
||||||
|
|
||||||
|
- UST-Saetze.
|
||||||
|
- Lieferzeiten.
|
||||||
|
- Bestandsalarm-E-Mail.
|
||||||
|
- Standard-Lagerort.
|
||||||
|
- Produktbestand-Schwellwerte, falls global sinnvoll.
|
||||||
|
- Optional: Standardtexte fuer `Nicht vorrätig`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Nur SuperAdmin kann Einstellungen pflegen.
|
||||||
|
- UST-Saetze koennen deaktiviert, aber historische Werte weiterhin angezeigt werden.
|
||||||
|
- Lieferzeiten stehen in Lieferanten und INCIs zur Auswahl.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Offene Klaerungspunkte
|
||||||
|
|
||||||
|
- Soll `Nicht vorrätig` den Kauf komplett sperren oder nur einen Hinweis anzeigen?
|
||||||
|
- Wird Produktbestand beim Status `bezahlt`, `versendet` oder direkt bei Bestellung reduziert?
|
||||||
|
- Sollen Produktbestandsbewegungen auch Stornos/Retouren automatisch abbilden?
|
||||||
|
- Soll die blockbasierte Rechtevergabe nur fuer Warenwirtschaft/Produktmanagement gelten oder fuer alle Admin-Bereiche?
|
||||||
|
- Soll Produktentwicklung bereits Bestandsabzug erzeugen oder zunaechst nur ein Platzhalter-Menuepunkt sein?
|
||||||
|
- Soll ein Child-Produkt zwingend genau ein Hauptprodukt haben oder koennen mehrere Einzelprodukte in einem Verkaufsprodukt gebuendelt werden? Fuer echte Sets ist die Pivot-Loesung vorgesehen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Empfohlene naechste Umsetzungsschritte
|
||||||
|
|
||||||
|
1. Datenmodell fuer `is_set`, Set-Bestandteile, Hauptprodukt-Zuordnung und Produktbestand finalisieren.
|
||||||
|
2. Einstellungen fuer UST-Saetze und Lieferzeiten umsetzen, weil Einkauf/INCI/Lieferanten davon abhaengen.
|
||||||
|
3. Lieferanten und INCIs um Bestellweg, Lieferzeit, UST und Lieferanten-Zuordnung erweitern.
|
||||||
|
4. Einkauf um Netto/Brutto/UST, Duplizieren und iPad-taugliche Aktionsbuttons erweitern.
|
||||||
|
5. Produktion auf Hersteller-Rezeptur, bessere Chargenlabels und stabile JS-Neuberechnung korrigieren.
|
||||||
|
6. Produktbestand mit Historie und manuellen Bewegungen bauen.
|
||||||
|
7. Rohstoffbestand mit Lieferanten-/Bestellinformationen bauen.
|
||||||
|
8. Danach alte Phasen 7-9 finalisieren: Ausgang, Audit, Rechte, 2FA, Einstellungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Grobe Aufwandsschaetzung
|
||||||
|
|
||||||
|
| Block | Aufwand |
|
||||||
|
|-------|---------|
|
||||||
|
| Einstellungen UST/Lieferzeiten | 1-2 Tage |
|
||||||
|
| Produkt Set-/Hauptproduktmodell | 3-5 Tage |
|
||||||
|
| Einkaufserweiterungen | 2-3 Tage |
|
||||||
|
| Lieferanten/INCI-Erweiterungen | 2-4 Tage |
|
||||||
|
| Produktionskorrekturen | 2-4 Tage |
|
||||||
|
| Rohstoffbestand | 4-6 Tage |
|
||||||
|
| Produktbestand + Historie | 5-8 Tage |
|
||||||
|
| Nicht vorrätig | 1-2 Tage |
|
||||||
|
| 2FA Admins | 3-5 Tage |
|
||||||
|
| Blockbasierte Rechte | 5-8 Tage |
|
||||||
|
| Tests & Feinschliff | 4-6 Tage |
|
||||||
|
|
||||||
|
**Neue Gesamtannahme fuer offene Arbeit:** ca. 6-9 Wochen bei einem Entwickler, abhaengig davon, wie tief Shop-Verkaeufe, Stornos und Rechtevergabe integriert werden sollen.
|
||||||
BIN
dev/product management /screens/1.-Produktbestand.jpeg
Normal file
BIN
dev/product management /screens/1.-Produktbestand.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
dev/product management /screens/1.-Rohstoffbestand.jpeg
Normal file
BIN
dev/product management /screens/1.-Rohstoffbestand.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
dev/product management /screens/2.-Produktbestand_Historie.jpeg
Normal file
BIN
dev/product management /screens/2.-Produktbestand_Historie.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
BIN
dev/product management /screens/PastedGraphic-2.png
Normal file
BIN
dev/product management /screens/PastedGraphic-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 456 KiB |
|
|
@ -15,7 +15,7 @@
|
||||||
@method('PUT')
|
@method('PUT')
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<input type="hidden" name="category" value="{{ old('category', $model->category) }}">
|
<input type="hidden" name="category" value="{{ old('category', $category ?? 'packaging') }}">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">{{ __('Name') }}</label>
|
<label for="name">{{ __('Name') }}</label>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="entry_type">{{ __('Art') }}</label>
|
<label for="entry_type">{{ __('Art') }} <span class="text-danger">*</span></label>
|
||||||
<select name="entry_type" id="entry_type" class="form-control @error('entry_type') is-invalid @enderror" required>
|
<select name="entry_type" id="entry_type" class="form-control @error('entry_type') is-invalid @enderror" required>
|
||||||
@foreach($entryTypeLabels as $value => $label)
|
@foreach ($entryTypeLabels as $value => $label)
|
||||||
<option value="{{ $value }}" @selected(old('entry_type', $model->entry_type ?? 'ingredient') === $value)>{{ $label }}</option>
|
<option value="{{ $value }}" @selected(old('entry_type', $model->entry_type ?? 'ingredient') === $value)>{{ $label }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -16,11 +16,15 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="stock-entry-ingredient-block" class="form-group" style="display:none;">
|
<div id="stock-entry-ingredient-block" class="form-group" style="display:none;">
|
||||||
<label for="ingredient_id">{{ __('Inhaltsstoff') }}</label>
|
<label for="ingredient_id">{{ __('Inhaltsstoff') }} <span class="text-danger">*</span></label>
|
||||||
<div class="light-style">
|
<div class="light-style">
|
||||||
<select name="ingredient_id" id="ingredient_id" class="w-100" data-search-url="{{ route('admin.inventory.api.ingredients.search') }}">
|
<select name="ingredient_id" id="ingredient_id" class="w-100"
|
||||||
@if($model->ingredient_id && $model->ingredient)
|
data-search-url="{{ route('admin.inventory.api.ingredients.search') }}">
|
||||||
<option value="{{ $model->ingredient_id }}" selected>{{ $model->ingredient->name }}@if($model->ingredient->inci) ({{ $model->ingredient->inci }})@endif</option>
|
@if ($model->ingredient_id && $model->ingredient)
|
||||||
|
<option value="{{ $model->ingredient_id }}" selected>{{ $model->ingredient->name }}@if ($model->ingredient->inci)
|
||||||
|
({{ $model->ingredient->inci }})
|
||||||
|
@endif
|
||||||
|
</option>
|
||||||
@elseif(old('ingredient_id'))
|
@elseif(old('ingredient_id'))
|
||||||
<option value="{{ old('ingredient_id') }}" selected>{{ old('ingredient_id') }}</option>
|
<option value="{{ old('ingredient_id') }}" selected>{{ old('ingredient_id') }}</option>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -31,11 +35,25 @@
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="stock-entry-quality-block" class="form-group" style="display:none;">
|
||||||
|
<label for="quality_id">{{ __('Rohstoffqualität') }} <span class="text-danger">*</span></label>
|
||||||
|
<select name="quality_id" id="quality_id" class="form-control @error('quality_id') is-invalid @enderror">
|
||||||
|
<option value="">{{ __('Bitte wählen') }}</option>
|
||||||
|
@foreach ($materialQualities as $mq)
|
||||||
|
<option value="{{ $mq->id }}" @selected((string) old('quality_id', $model->quality_id) === (string) $mq->id)>{{ $mq->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('quality_id')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="stock-entry-packaging-block" class="form-group" style="display:none;">
|
<div id="stock-entry-packaging-block" class="form-group" style="display:none;">
|
||||||
<label for="packaging_item_id">{{ __('Verpackungsartikel') }}</label>
|
<label for="packaging_item_id">{{ __('Verpackungsartikel') }} <span class="text-danger">*</span></label>
|
||||||
<div class="light-style">
|
<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') }}">
|
<select name="packaging_item_id" id="packaging_item_id" class="w-100"
|
||||||
@if($model->packaging_item_id && $model->packagingItem)
|
data-search-url="{{ route('admin.inventory.api.packaging-items.search') }}">
|
||||||
|
@if ($model->packaging_item_id && $model->packagingItem)
|
||||||
<option value="{{ $model->packaging_item_id }}" selected>{{ $model->packagingItem->name }}</option>
|
<option value="{{ $model->packaging_item_id }}" selected>{{ $model->packagingItem->name }}</option>
|
||||||
@elseif(old('packaging_item_id'))
|
@elseif(old('packaging_item_id'))
|
||||||
<option value="{{ old('packaging_item_id') }}" selected>{{ old('packaging_item_id') }}</option>
|
<option value="{{ old('packaging_item_id') }}" selected>{{ old('packaging_item_id') }}</option>
|
||||||
|
|
@ -48,11 +66,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="supplier_id">{{ __('Lieferant') }}</label>
|
<label for="supplier_id">{{ __('Lieferant') }} <span class="text-danger">*</span></label>
|
||||||
<select name="supplier_id" id="supplier_id" class="form-control @error('supplier_id') is-invalid @enderror" required>
|
<select name="supplier_id" id="supplier_id" class="form-control @error('supplier_id') is-invalid @enderror"
|
||||||
|
required>
|
||||||
<option value="">{{ __('Bitte wählen') }}</option>
|
<option value="">{{ __('Bitte wählen') }}</option>
|
||||||
@foreach($suppliers as $sup)
|
@foreach ($suppliers as $sup)
|
||||||
<option value="{{ $sup->id }}" @selected((string)old('supplier_id', $model->supplier_id) === (string)$sup->id)>{{ $sup->name }}</option>
|
<option value="{{ $sup->id }}" @selected((string) old('supplier_id', $model->supplier_id) === (string) $sup->id)>{{ $sup->name }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
@error('supplier_id')
|
@error('supplier_id')
|
||||||
|
|
@ -61,11 +80,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="location_id">{{ __('Lagerort') }}</label>
|
<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>
|
<select name="location_id" id="location_id" class="form-control @error('location_id') is-invalid @enderror"
|
||||||
|
required>
|
||||||
<option value="">{{ __('Bitte wählen') }}</option>
|
<option value="">{{ __('Bitte wählen') }}</option>
|
||||||
@foreach($locations as $loc)
|
@foreach ($locations as $loc)
|
||||||
<option value="{{ $loc->id }}" @selected((string)old('location_id', $model->location_id) === (string)$loc->id)>{{ $loc->name }}</option>
|
<option value="{{ $loc->id }}" @selected((string) old('location_id', $model->location_id) === (string) $loc->id)>{{ $loc->name }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
@error('location_id')
|
@error('location_id')
|
||||||
|
|
@ -74,8 +94,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ordered_at">{{ __('Bestelldatum') }}</label>
|
<label for="ordered_at">{{ __('Bestelldatum') }} <span class="text-danger">*</span></label>
|
||||||
<input type="date" name="ordered_at" id="ordered_at" class="form-control @error('ordered_at') is-invalid @enderror"
|
<input type="date" name="ordered_at" id="ordered_at"
|
||||||
|
class="form-control @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('Y-m-d') : '') }}" required>
|
||||||
@error('ordered_at')
|
@error('ordered_at')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
|
@ -83,9 +104,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ordered_quantity">{{ __('Bestellte Menge') }}</label>
|
<label for="ordered_quantity">{{ __('Bestellte Menge') }} <span class="text-danger">*</span></label>
|
||||||
<input type="text" name="ordered_quantity" id="ordered_quantity" class="form-control @error('ordered_quantity') is-invalid @enderror"
|
<input type="text" name="ordered_quantity" id="ordered_quantity"
|
||||||
value="{{ old('ordered_quantity', $model->ordered_quantity !== null ? \App\Services\Util::formatNumber($model->ordered_quantity) : '') }}" required>
|
class="form-control @error('ordered_quantity') is-invalid @enderror"
|
||||||
|
value="{{ old('ordered_quantity', $model->ordered_quantity !== null ? \App\Services\Util::formatNumber($model->ordered_quantity) : '') }}"
|
||||||
|
required>
|
||||||
<small class="text-muted">{{ __('Bei Rohstoff in Gramm, bei Verpackung in Stück.') }}</small>
|
<small class="text-muted">{{ __('Bei Rohstoff in Gramm, bei Verpackung in Stück.') }}</small>
|
||||||
@error('ordered_quantity')
|
@error('ordered_quantity')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
|
@ -93,8 +116,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="price-per-kg-block" class="form-group" style="display:none;">
|
<div id="price-per-kg-block" class="form-group" style="display:none;">
|
||||||
<label for="price_per_kg">{{ __('Netto-Preis pro kg') }}</label>
|
<label for="price_per_kg">{{ __('Netto-Preis pro kg') }} <span class="text-danger">*</span></label>
|
||||||
<input type="text" name="price_per_kg" id="price_per_kg" class="form-control @error('price_per_kg') is-invalid @enderror"
|
<input type="text" name="price_per_kg" id="price_per_kg"
|
||||||
|
class="form-control @error('price_per_kg') is-invalid @enderror"
|
||||||
value="{{ old('price_per_kg', $model->price_per_kg !== null ? \App\Services\Util::formatNumber($model->price_per_kg) : '') }}">
|
value="{{ old('price_per_kg', $model->price_per_kg !== null ? \App\Services\Util::formatNumber($model->price_per_kg) : '') }}">
|
||||||
@error('price_per_kg')
|
@error('price_per_kg')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
|
@ -102,8 +126,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="price-total-block" class="form-group" style="display:none;">
|
<div id="price-total-block" class="form-group" style="display:none;">
|
||||||
<label for="price_total">{{ __('Gesamtpreis netto') }}</label>
|
<label for="price_total">{{ __('Gesamtpreis netto') }} <span class="text-danger">*</span></label>
|
||||||
<input type="text" name="price_total" id="price_total" class="form-control @error('price_total') is-invalid @enderror"
|
<input type="text" name="price_total" id="price_total"
|
||||||
|
class="form-control @error('price_total') is-invalid @enderror"
|
||||||
value="{{ old('price_total', $model->price_total !== null ? \App\Services\Util::formatNumber($model->price_total) : '') }}">
|
value="{{ old('price_total', $model->price_total !== null ? \App\Services\Util::formatNumber($model->price_total) : '') }}">
|
||||||
@error('price_total')
|
@error('price_total')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
var t = $('#entry_type').val();
|
var t = $('#entry_type').val();
|
||||||
var isIng = t === 'ingredient';
|
var isIng = t === 'ingredient';
|
||||||
$('#stock-entry-ingredient-block').toggle(isIng);
|
$('#stock-entry-ingredient-block').toggle(isIng);
|
||||||
|
$('#stock-entry-quality-block').toggle(isIng);
|
||||||
$('#stock-entry-packaging-block').toggle(!isIng);
|
$('#stock-entry-packaging-block').toggle(!isIng);
|
||||||
$('#price-per-kg-block').toggle(isIng);
|
$('#price-per-kg-block').toggle(isIng);
|
||||||
$('#price-total-block').toggle(!isIng);
|
$('#price-total-block').toggle(!isIng);
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@
|
||||||
@endif
|
@endif
|
||||||
</dd>
|
</dd>
|
||||||
|
|
||||||
|
@if($model->entry_type === 'ingredient' && $model->quality)
|
||||||
|
<dt class="col-sm-3">{{ __('Rohstoffqualität') }}</dt>
|
||||||
|
<dd class="col-sm-9">{{ $model->quality->name }}</dd>
|
||||||
|
@endif
|
||||||
|
|
||||||
<dt class="col-sm-3">{{ __('Lieferant') }}</dt>
|
<dt class="col-sm-3">{{ __('Lieferant') }}</dt>
|
||||||
<dd class="col-sm-9">{{ $model->supplier?->name ?? '—' }}</dd>
|
<dd class="col-sm-9">{{ $model->supplier?->name ?? '—' }}</dd>
|
||||||
|
|
||||||
|
|
@ -122,7 +127,7 @@
|
||||||
@method('PUT')
|
@method('PUT')
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="received_at">{{ __('Eingangsdatum') }}</label>
|
<label for="received_at">{{ __('Eingangsdatum') }} <span class="text-danger">*</span></label>
|
||||||
<input type="date" name="received_at" id="received_at" required
|
<input type="date" name="received_at" id="received_at" required
|
||||||
class="form-control @error('received_at') is-invalid @enderror"
|
class="form-control @error('received_at') is-invalid @enderror"
|
||||||
value="{{ old('received_at', now()->toDateString()) }}">
|
value="{{ old('received_at', now()->toDateString()) }}">
|
||||||
|
|
@ -132,7 +137,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="received_quantity">{{ __('Eingegangene Menge') }}</label>
|
<label for="received_quantity">{{ __('Eingegangene Menge') }} <span class="text-danger">*</span></label>
|
||||||
<input type="text" name="received_quantity" id="received_quantity" required
|
<input type="text" name="received_quantity" id="received_quantity" required
|
||||||
class="form-control @error('received_quantity') is-invalid @enderror"
|
class="form-control @error('received_quantity') is-invalid @enderror"
|
||||||
value="{{ old('received_quantity', \App\Services\Util::formatNumber($model->ordered_quantity)) }}">
|
value="{{ old('received_quantity', \App\Services\Util::formatNumber($model->ordered_quantity)) }}">
|
||||||
|
|
@ -150,7 +155,7 @@
|
||||||
|
|
||||||
@if($model->entry_type === 'ingredient')
|
@if($model->entry_type === 'ingredient')
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="batch_number">{{ __('Chargennummer') }}</label>
|
<label for="batch_number">{{ __('Chargennummer') }} <span class="text-danger">*</span></label>
|
||||||
<input type="text" name="batch_number" id="batch_number" maxlength="100"
|
<input type="text" name="batch_number" id="batch_number" maxlength="100"
|
||||||
class="form-control @error('batch_number') is-invalid @enderror"
|
class="form-control @error('batch_number') is-invalid @enderror"
|
||||||
value="{{ old('batch_number') }}">
|
value="{{ old('batch_number') }}">
|
||||||
|
|
@ -160,7 +165,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="best_before">{{ __('Mindesthaltbarkeit') }}</label>
|
<label for="best_before">{{ __('Mindesthaltbarkeit') }} <span class="text-danger">*</span></label>
|
||||||
<input type="date" name="best_before" id="best_before"
|
<input type="date" name="best_before" id="best_before"
|
||||||
class="form-control @error('best_before') is-invalid @enderror"
|
class="form-control @error('best_before') is-invalid @enderror"
|
||||||
value="{{ old('best_before') }}">
|
value="{{ old('best_before') }}">
|
||||||
|
|
@ -170,11 +175,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="quality_id">{{ __('Materialqualität') }}</label>
|
<label for="quality_id">{{ __('Rohstoffqualität') }} <span class="text-danger">*</span></label>
|
||||||
<select name="quality_id" id="quality_id" class="form-control @error('quality_id') is-invalid @enderror">
|
<select name="quality_id" id="quality_id" class="form-control @error('quality_id') is-invalid @enderror">
|
||||||
<option value="">{{ __('Bitte wählen') }}</option>
|
<option value="">{{ __('Bitte wählen') }}</option>
|
||||||
@foreach($materialQualities as $mq)
|
@foreach($materialQualities as $mq)
|
||||||
<option value="{{ $mq->id }}" @selected((string)old('quality_id') === (string)$mq->id)>{{ $mq->name }}</option>
|
<option value="{{ $mq->id }}" @selected((string)old('quality_id', $model->quality_id) === (string)$mq->id)>{{ $mq->name }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
@error('quality_id')
|
@error('quality_id')
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@
|
||||||
var $out = $row.find('.pi-effective');
|
var $out = $row.find('.pi-effective');
|
||||||
if (!isNaN(g) && !isNaN(f)) {
|
if (!isNaN(g) && !isNaN(f)) {
|
||||||
var eff = g * f;
|
var eff = g * f;
|
||||||
$out.text(eff.toLocaleString('de-DE', { minimumFractionDigits: 3, maximumFractionDigits: 3 }));
|
$out.text(eff.toLocaleString('de-DE', { minimumFractionDigits: 6, maximumFractionDigits: 6 }));
|
||||||
} else {
|
} else {
|
||||||
$out.text('—');
|
$out.text('—');
|
||||||
}
|
}
|
||||||
|
|
@ -100,10 +100,10 @@
|
||||||
$cell.text('—').removeClass('text-danger text-success');
|
$cell.text('—').removeClass('text-danger text-success');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var formatted = total.toLocaleString('de-DE', { minimumFractionDigits: 3, maximumFractionDigits: 3 }) + ' %';
|
var formatted = total.toLocaleString('de-DE', { minimumFractionDigits: 6, maximumFractionDigits: 6 }) + ' %';
|
||||||
$cell.text(formatted);
|
$cell.text(formatted);
|
||||||
var diff = Math.abs(total - 100);
|
var diff = Math.abs(total - 100);
|
||||||
if (diff < 0.001) {
|
if (diff < 0.000001) {
|
||||||
$cell.removeClass('text-danger').addClass('text-success');
|
$cell.removeClass('text-danger').addClass('text-success');
|
||||||
$cell.attr('title', '');
|
$cell.attr('title', '');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -135,7 +135,7 @@
|
||||||
'<td class="text-muted align-middle ingredient-drag-handle" style="cursor:grab">☰</td>' +
|
'<td class="text-muted align-middle ingredient-drag-handle" style="cursor:grab">☰</td>' +
|
||||||
'<td class="align-middle"></td><td class="align-middle small text-muted"></td><td class="align-middle"></td>' +
|
'<td class="align-middle"></td><td class="align-middle small text-muted"></td><td class="align-middle"></td>' +
|
||||||
'<td><input type="hidden" name="pi_ingredient_id[]" value="' + id + '">' +
|
'<td><input type="hidden" name="pi_ingredient_id[]" value="' + id + '">' +
|
||||||
'<input type="text" name="pi_gram[]" class="form-control form-control-sm pi-gram" value="" autocomplete="off" step="0.001"></td>' +
|
'<input type="text" name="pi_gram[]" class="form-control form-control-sm pi-gram" value="" autocomplete="off" step="0.000001"></td>' +
|
||||||
'<td><input type="text" name="pi_factor[]" class="form-control form-control-sm pi-factor" value="' + factor + '" autocomplete="off"></td>' +
|
'<td><input type="text" name="pi_factor[]" class="form-control form-control-sm pi-factor" value="' + factor + '" autocomplete="off"></td>' +
|
||||||
'<td class="align-middle pi-effective text-right small text-muted">—</td>' +
|
'<td class="align-middle pi-effective text-right small text-muted">—</td>' +
|
||||||
'<td class="align-middle"><a class="text-danger ingredient-row-remove" href="#" title="Entfernen"><i class="far fa-trash-alt"></i></a></td></tr>');
|
'<td class="align-middle"><a class="text-danger ingredient-row-remove" href="#" title="Entfernen"><i class="far fa-trash-alt"></i></a></td></tr>');
|
||||||
|
|
@ -332,7 +332,7 @@
|
||||||
var f = parseDeNumber($row.find('.mfg-factor').val());
|
var f = parseDeNumber($row.find('.mfg-factor').val());
|
||||||
var $out = $row.find('.mfg-effective');
|
var $out = $row.find('.mfg-effective');
|
||||||
if (!isNaN(g) && !isNaN(f)) {
|
if (!isNaN(g) && !isNaN(f)) {
|
||||||
$out.text((g * f).toLocaleString('de-DE', { minimumFractionDigits: 3, maximumFractionDigits: 3 }));
|
$out.text((g * f).toLocaleString('de-DE', { minimumFractionDigits: 6, maximumFractionDigits: 6 }));
|
||||||
} else {
|
} else {
|
||||||
$out.text('—');
|
$out.text('—');
|
||||||
}
|
}
|
||||||
|
|
@ -346,8 +346,8 @@
|
||||||
});
|
});
|
||||||
var $cell = $('#mfg-recipe-total-percent');
|
var $cell = $('#mfg-recipe-total-percent');
|
||||||
if (!hasValue) { $cell.text('—').removeClass('text-danger text-success'); return; }
|
if (!hasValue) { $cell.text('—').removeClass('text-danger text-success'); return; }
|
||||||
$cell.text(total.toLocaleString('de-DE', { minimumFractionDigits: 3, maximumFractionDigits: 3 }) + ' %');
|
$cell.text(total.toLocaleString('de-DE', { minimumFractionDigits: 6, maximumFractionDigits: 6 }) + ' %');
|
||||||
if (Math.abs(total - 100) < 0.001) {
|
if (Math.abs(total - 100) < 0.000001) {
|
||||||
$cell.removeClass('text-danger').addClass('text-success').attr('title', '');
|
$cell.removeClass('text-danger').addClass('text-success').attr('title', '');
|
||||||
} else {
|
} else {
|
||||||
$cell.removeClass('text-success').addClass('text-danger').attr('title', 'Die Gesamtrezeptur ergibt nicht 100 %!');
|
$cell.removeClass('text-success').addClass('text-danger').attr('title', 'Die Gesamtrezeptur ergibt nicht 100 %!');
|
||||||
|
|
@ -368,7 +368,7 @@
|
||||||
'<td class="text-muted align-middle mfg-ingredient-drag-handle" style="cursor:grab">☰</td>' +
|
'<td class="text-muted align-middle mfg-ingredient-drag-handle" style="cursor:grab">☰</td>' +
|
||||||
'<td class="align-middle"></td><td class="align-middle small text-muted"></td><td class="align-middle"></td>' +
|
'<td class="align-middle"></td><td class="align-middle small text-muted"></td><td class="align-middle"></td>' +
|
||||||
'<td><input type="hidden" name="mfg_ingredient_id[]" value="' + id + '">' +
|
'<td><input type="hidden" name="mfg_ingredient_id[]" value="' + id + '">' +
|
||||||
'<input type="text" name="mfg_gram[]" class="form-control form-control-sm mfg-gram" value="" autocomplete="off" step="0.001"></td>' +
|
'<input type="text" name="mfg_gram[]" class="form-control form-control-sm mfg-gram" value="" autocomplete="off" step="0.000001"></td>' +
|
||||||
'<td><input type="text" name="mfg_factor[]" class="form-control form-control-sm mfg-factor" value="' + factor + '" autocomplete="off"></td>' +
|
'<td><input type="text" name="mfg_factor[]" class="form-control form-control-sm mfg-factor" value="' + factor + '" autocomplete="off"></td>' +
|
||||||
'<td class="align-middle mfg-effective text-right small text-muted">—</td>' +
|
'<td class="align-middle mfg-effective text-right small text-muted">—</td>' +
|
||||||
'<td class="align-middle"><a class="text-danger mfg-ingredient-row-remove" href="#" title="Entfernen"><i class="far fa-trash-alt"></i></a></td></tr>');
|
'<td class="align-middle"><a class="text-danger mfg-ingredient-row-remove" href="#" title="Entfernen"><i class="far fa-trash-alt"></i></a></td></tr>');
|
||||||
|
|
|
||||||
|
|
@ -445,8 +445,8 @@
|
||||||
<td>
|
<td>
|
||||||
<input type="hidden" name="pi_ingredient_id[]" value="{{ $ingredient->id }}">
|
<input type="hidden" name="pi_ingredient_id[]" value="{{ $ingredient->id }}">
|
||||||
<input type="text" name="pi_gram[]" class="form-control form-control-sm pi-gram"
|
<input type="text" name="pi_gram[]" class="form-control form-control-sm pi-gram"
|
||||||
value="{{ $ingredient->pivot->gram !== null ? formatNumber($ingredient->pivot->gram, 3) : '' }}"
|
value="{{ $ingredient->pivot->gram !== null ? formatNumber($ingredient->pivot->gram, 6) : '' }}"
|
||||||
autocomplete="off" step="0.001">
|
autocomplete="off" step="0.000001">
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="text" name="pi_factor[]"
|
<input type="text" name="pi_factor[]"
|
||||||
|
|
@ -594,8 +594,8 @@
|
||||||
<td>
|
<td>
|
||||||
<input type="hidden" name="mfg_ingredient_id[]" value="{{ $ingredient->id }}">
|
<input type="hidden" name="mfg_ingredient_id[]" value="{{ $ingredient->id }}">
|
||||||
<input type="text" name="mfg_gram[]" class="form-control form-control-sm mfg-gram"
|
<input type="text" name="mfg_gram[]" class="form-control form-control-sm mfg-gram"
|
||||||
value="{{ $ingredient->pivot->gram !== null ? formatNumber($ingredient->pivot->gram, 3) : '' }}"
|
value="{{ $ingredient->pivot->gram !== null ? formatNumber($ingredient->pivot->gram, 6) : '' }}"
|
||||||
autocomplete="off" step="0.001">
|
autocomplete="off" step="0.000001">
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="text" name="mfg_factor[]"
|
<input type="text" name="mfg_factor[]"
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@
|
||||||
<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>{{ __('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/productions*') ? ' active' : '' }}">
|
||||||
|
|
@ -225,9 +225,7 @@
|
||||||
</li>
|
</li>
|
||||||
@endif
|
@endif
|
||||||
@if (Auth::user()->isAdmin())
|
@if (Auth::user()->isAdmin())
|
||||||
<li class="sidenav-item @if (Request::is(
|
<li class="sidenav-item @if (Request::is('admin/inventory/supplier-categories*', 'admin/inventory/suppliers*')) open @endif">
|
||||||
'admin/inventory/supplier-categories*',
|
|
||||||
'admin/inventory/suppliers*')) 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-bus"></i>
|
<i class="sidenav-icon ion ion-md-bus"></i>
|
||||||
<div>Lieferanten</div>
|
<div>Lieferanten</div>
|
||||||
|
|
@ -248,15 +246,17 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="sidenav-item{{ Request::is('admin/inventory/packaging-items*') && request('category', 'packaging') === 'packaging' ? ' active' : '' }}">
|
<li
|
||||||
<a href="{{ route('admin.inventory.packaging-items.index', ['category' => 'packaging']) }}" class="sidenav-link"><i
|
class="sidenav-item{{ Request::is('admin/inventory/packaging-items*') && request('category', 'packaging') === 'packaging' ? ' active' : '' }}">
|
||||||
class="sidenav-icon ion ion-md-basket"></i>
|
<a href="{{ route('admin.inventory.packaging-items.index', ['category' => 'packaging']) }}"
|
||||||
|
class="sidenav-link"><i class="sidenav-icon ion ion-md-basket"></i>
|
||||||
<div>{{ __('Produktverpackung') }}</div>
|
<div>{{ __('Produktverpackung') }}</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="sidenav-item{{ Request::is('admin/inventory/packaging-items*') && request('category') === 'shipping' ? ' active' : '' }}">
|
<li
|
||||||
<a href="{{ route('admin.inventory.packaging-items.index', ['category' => 'shipping']) }}" class="sidenav-link"><i
|
class="sidenav-item{{ Request::is('admin/inventory/packaging-items*') && request('category') === 'shipping' ? ' active' : '' }}">
|
||||||
class="sidenav-icon ion ion-md-mail"></i>
|
<a href="{{ route('admin.inventory.packaging-items.index', ['category' => 'shipping']) }}"
|
||||||
|
class="sidenav-link"><i class="sidenav-icon ion ion-md-mail"></i>
|
||||||
<div>{{ __('Versandverpackung') }}</div>
|
<div>{{ __('Versandverpackung') }}</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,14 @@
|
||||||
|
|
||||||
<div class="text-left mt-0 mb-2">
|
<div class="text-left mt-0 mb-2">
|
||||||
<button type="submit" class="btn btn-submit" name="action" value="save-user-promotion">Promotion {{ __('save') }}</button>
|
<button type="submit" class="btn btn-submit" name="action" value="save-user-promotion">Promotion {{ __('save') }}</button>
|
||||||
<a href="{{ route('user_promotions') }}" class="btn btn-default">{{ __('back') }}</a>
|
<a href="{{ route('user_promotion') }}" class="btn btn-default">{{ __('back') }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@include('user.promotion.form')
|
@include('user.promotion.form')
|
||||||
|
|
||||||
<div class="text-left mt-0 mb-2">
|
<div class="text-left mt-0 mb-2">
|
||||||
<button type="submit" class="btn btn-submit" name="action" value="save-user-promotion">Promotion {{ __('save') }}</button>
|
<button type="submit" class="btn btn-submit" name="action" value="save-user-promotion">Promotion {{ __('save') }}</button>
|
||||||
<a href="{{ route('user_promotions') }}" class="btn btn-default">{{ __('back') }}</a>
|
<a href="{{ route('user_promotion') }}" class="btn btn-default">{{ __('back') }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!! Form::close() !!}
|
{!! Form::close() !!}
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ test('packaging items full CRUD as admin', function () {
|
||||||
'packaging_material_id' => (string) $material->id,
|
'packaging_material_id' => (string) $material->id,
|
||||||
'supplier_id' => '',
|
'supplier_id' => '',
|
||||||
'name' => 'CRUD-Verpack-Art-upd',
|
'name' => 'CRUD-Verpack-Art-upd',
|
||||||
'category' => 'label',
|
'category' => 'shipping',
|
||||||
'weight_grams' => '5',
|
'weight_grams' => '5',
|
||||||
'min_stock_alert' => '',
|
'min_stock_alert' => '',
|
||||||
'product_id' => '',
|
'product_id' => '',
|
||||||
|
|
@ -256,7 +256,7 @@ test('packaging items full CRUD as admin', function () {
|
||||||
|
|
||||||
$item->refresh();
|
$item->refresh();
|
||||||
expect($item->name)->toBe('CRUD-Verpack-Art-upd');
|
expect($item->name)->toBe('CRUD-Verpack-Art-upd');
|
||||||
expect($item->category)->toBe('label');
|
expect($item->category)->toBe('shipping');
|
||||||
expect($item->supplier_id)->toBeNull();
|
expect($item->supplier_id)->toBeNull();
|
||||||
expect($item->product_id)->toBeNull();
|
expect($item->product_id)->toBeNull();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ test('admin creates pending stock entry with unit gram', function () {
|
||||||
$ingredient = phase3MakeIngredient();
|
$ingredient = phase3MakeIngredient();
|
||||||
$location = Location::factory()->create();
|
$location = Location::factory()->create();
|
||||||
$supplier = Supplier::factory()->create();
|
$supplier = Supplier::factory()->create();
|
||||||
|
$quality = MaterialQuality::factory()->create();
|
||||||
|
|
||||||
$this->actingAs($admin, 'user');
|
$this->actingAs($admin, 'user');
|
||||||
|
|
||||||
|
|
@ -90,6 +91,7 @@ test('admin creates pending stock entry with unit gram', function () {
|
||||||
'ingredient_id' => (string) $ingredient->id,
|
'ingredient_id' => (string) $ingredient->id,
|
||||||
'supplier_id' => (string) $supplier->id,
|
'supplier_id' => (string) $supplier->id,
|
||||||
'location_id' => (string) $location->id,
|
'location_id' => (string) $location->id,
|
||||||
|
'quality_id' => (string) $quality->id,
|
||||||
'ordered_at' => '2026-03-27',
|
'ordered_at' => '2026-03-27',
|
||||||
'ordered_quantity' => '1000',
|
'ordered_quantity' => '1000',
|
||||||
'price_per_kg' => '12,50',
|
'price_per_kg' => '12,50',
|
||||||
|
|
@ -180,6 +182,7 @@ test('listForIndex sorts pending before received', function () {
|
||||||
$ingredient = phase3MakeIngredient();
|
$ingredient = phase3MakeIngredient();
|
||||||
$location = Location::factory()->create();
|
$location = Location::factory()->create();
|
||||||
$supplier = Supplier::factory()->create();
|
$supplier = Supplier::factory()->create();
|
||||||
|
$quality = MaterialQuality::factory()->create();
|
||||||
|
|
||||||
$this->actingAs($admin, 'user');
|
$this->actingAs($admin, 'user');
|
||||||
|
|
||||||
|
|
@ -188,6 +191,7 @@ test('listForIndex sorts pending before received', function () {
|
||||||
'ingredient_id' => (string) $ingredient->id,
|
'ingredient_id' => (string) $ingredient->id,
|
||||||
'supplier_id' => (string) $supplier->id,
|
'supplier_id' => (string) $supplier->id,
|
||||||
'location_id' => (string) $location->id,
|
'location_id' => (string) $location->id,
|
||||||
|
'quality_id' => (string) $quality->id,
|
||||||
'ordered_at' => '2026-01-01',
|
'ordered_at' => '2026-01-01',
|
||||||
'ordered_quantity' => '100',
|
'ordered_quantity' => '100',
|
||||||
'price_per_kg' => '1',
|
'price_per_kg' => '1',
|
||||||
|
|
@ -198,6 +202,7 @@ test('listForIndex sorts pending before received', function () {
|
||||||
'ingredient_id' => (string) $ingredient->id,
|
'ingredient_id' => (string) $ingredient->id,
|
||||||
'supplier_id' => (string) $supplier->id,
|
'supplier_id' => (string) $supplier->id,
|
||||||
'location_id' => (string) $location->id,
|
'location_id' => (string) $location->id,
|
||||||
|
'quality_id' => (string) $quality->id,
|
||||||
'ordered_at' => '2026-06-01',
|
'ordered_at' => '2026-06-01',
|
||||||
'ordered_quantity' => '200',
|
'ordered_quantity' => '200',
|
||||||
'price_per_kg' => '1',
|
'price_per_kg' => '1',
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ test('product saves packaging bom quantities and order', function () {
|
||||||
'packaging_material_id' => $material->id,
|
'packaging_material_id' => $material->id,
|
||||||
'supplier_id' => $supplier->id,
|
'supplier_id' => $supplier->id,
|
||||||
'name' => 'Etikett',
|
'name' => 'Etikett',
|
||||||
'category' => 'label',
|
'category' => 'shipping',
|
||||||
'weight_grams' => 1.5,
|
'weight_grams' => 1.5,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue