Warenwirtschaft: AP-00 bis AP-08 + aktualisierter Entwicklungsplan
Umsetzung der Warenwirtschafts-/Produktmanagement-Erweiterung gemaess Entwicklungsplan V4.0: - AP-00: Regressionsbasis fuer 5.1-Features (ProductPhase51Test) - AP-01: URL-Bugfixes B1/B2 (suppliers/packaging-items, breitere url-Spalten) - AP-04/04.1: iPad-taugliche, vereinheitlichte Tabellen-Aktionen - AP-05: Einstellungen "Allgemein" mit UST-Saetzen (tax_rates) und Lieferzeit-Vorlagen (delivery_times, inkl. Tage-Feld) - AP-06: Lieferanten um Bestellweg, Bestell-Mail/-URL und Lieferzeit erweitert - AP-07/07.1: INCI um Lieferanten-Mehrfachwahl, UST und Lieferzeit erweitert; Lieferanten-Detailansicht im Modal mit pflegbaren INCI-/Verpackungslisten - AP-08: Einkauf um UST-Snapshot, Netto/Brutto-Automatik und Duplizieren erweitert Entwicklungsplan aktualisiert: alle Klaerungspunkte (§5) vom Kunden beantwortet und in die jeweiligen APs eingearbeitet (AP-02/03/09/13/15), neues AP-18 (Hinweise-Doku unter Einstellungen) ergaenzt. Naechster Schritt eindeutig markiert: AP-09 (Produktion auf Hersteller-Rezeptur, kein Fallback, Warnung).
This commit is contained in:
parent
ca3eb663fe
commit
78679e0c55
67 changed files with 3523 additions and 101 deletions
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Inventory;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Inventory\StoreDeliveryTimeRequest;
|
||||||
|
use App\Http\Requests\Inventory\UpdateDeliveryTimeRequest;
|
||||||
|
use App\Models\DeliveryTime;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class DeliveryTimeController extends Controller
|
||||||
|
{
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('admin.inventory.delivery-times.form', [
|
||||||
|
'model' => new DeliveryTime(['active' => true, 'pos' => 0]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreDeliveryTimeRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
DeliveryTime::create($request->validated());
|
||||||
|
|
||||||
|
\Session::flash('alert-save', '1');
|
||||||
|
|
||||||
|
return redirect()->route('admin.inventory.general');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(DeliveryTime $deliveryTime): View
|
||||||
|
{
|
||||||
|
return view('admin.inventory.delivery-times.form', [
|
||||||
|
'model' => $deliveryTime,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateDeliveryTimeRequest $request, DeliveryTime $deliveryTime): RedirectResponse
|
||||||
|
{
|
||||||
|
$deliveryTime->update($request->validated());
|
||||||
|
|
||||||
|
\Session::flash('alert-save', '1');
|
||||||
|
|
||||||
|
return redirect()->route('admin.inventory.general');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(DeliveryTime $deliveryTime): RedirectResponse
|
||||||
|
{
|
||||||
|
$deliveryTime->delete();
|
||||||
|
|
||||||
|
\Session::flash('alert-success', __('Eintrag gelöscht'));
|
||||||
|
|
||||||
|
return redirect()->route('admin.inventory.general');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Inventory;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\DeliveryTime;
|
||||||
|
use App\Models\TaxRate;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
|
||||||
|
class GeneralSettingController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
return view('admin.inventory.general.index', [
|
||||||
|
'taxRates' => TaxRate::query()->orderBy('pos')->orderBy('percent')->get(),
|
||||||
|
'deliveryTimes' => DeliveryTime::query()->orderBy('pos')->orderBy('label')->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ use App\Models\MaterialQuality;
|
||||||
use App\Models\PackagingItem;
|
use App\Models\PackagingItem;
|
||||||
use App\Models\StockEntry;
|
use App\Models\StockEntry;
|
||||||
use App\Models\Supplier;
|
use App\Models\Supplier;
|
||||||
|
use App\Models\TaxRate;
|
||||||
use App\Repositories\StockEntryRepository;
|
use App\Repositories\StockEntryRepository;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
@ -115,6 +116,37 @@ class StockEntryController extends Controller
|
||||||
return redirect()->route('admin.inventory.stock-entries.index');
|
return redirect()->route('admin.inventory.stock-entries.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function copy(StockEntry $stockEntry): RedirectResponse
|
||||||
|
{
|
||||||
|
if (! auth()->user()->isAdmin()) {
|
||||||
|
return redirect()->route('home');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $stockEntry->only([
|
||||||
|
'entry_type',
|
||||||
|
'ingredient_id',
|
||||||
|
'packaging_item_id',
|
||||||
|
'supplier_id',
|
||||||
|
'location_id',
|
||||||
|
'ordered_quantity',
|
||||||
|
'price_per_kg',
|
||||||
|
'price_per_kg_gross',
|
||||||
|
'price_total',
|
||||||
|
'tax_rate_id',
|
||||||
|
'tax_rate_percent',
|
||||||
|
'quality_id',
|
||||||
|
]);
|
||||||
|
$data['ordered_at'] = now()->toDateString();
|
||||||
|
$data['ordered_by'] = (int) auth()->id();
|
||||||
|
$data['status'] = 'pending';
|
||||||
|
|
||||||
|
$copy = $this->stockEntryRepository->create($data);
|
||||||
|
|
||||||
|
\Session::flash('alert-warning', __('Kopie angelegt – bitte Menge/Charge prüfen und speichern.'));
|
||||||
|
|
||||||
|
return redirect()->route('admin.inventory.stock-entries.edit', $copy);
|
||||||
|
}
|
||||||
|
|
||||||
public function destroy(StockEntry $stockEntry): RedirectResponse
|
public function destroy(StockEntry $stockEntry): RedirectResponse
|
||||||
{
|
{
|
||||||
if (! auth()->user()->isAdmin()) {
|
if (! auth()->user()->isAdmin()) {
|
||||||
|
|
@ -223,6 +255,7 @@ class StockEntryController extends Controller
|
||||||
'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(),
|
'materialQualities' => MaterialQuality::query()->orderBy('pos')->orderBy('name')->get(),
|
||||||
|
'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(),
|
||||||
'entryTypeLabels' => [
|
'entryTypeLabels' => [
|
||||||
'ingredient' => __('Rohstoff'),
|
'ingredient' => __('Rohstoff'),
|
||||||
'packaging' => __('Produktverpackung'),
|
'packaging' => __('Produktverpackung'),
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,14 @@ use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Inventory\StoreSupplierRequest;
|
use App\Http\Requests\Inventory\StoreSupplierRequest;
|
||||||
use App\Http\Requests\Inventory\UpdateSupplierRequest;
|
use App\Http\Requests\Inventory\UpdateSupplierRequest;
|
||||||
use App\Models\Country;
|
use App\Models\Country;
|
||||||
|
use App\Models\DeliveryTime;
|
||||||
|
use App\Models\Ingredient;
|
||||||
|
use App\Models\PackagingItem;
|
||||||
use App\Models\Supplier;
|
use App\Models\Supplier;
|
||||||
use App\Models\SupplierCategory;
|
use App\Models\SupplierCategory;
|
||||||
use App\Repositories\SupplierRepository;
|
use App\Repositories\SupplierRepository;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class SupplierController extends Controller
|
class SupplierController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -31,6 +36,7 @@ class SupplierController extends Controller
|
||||||
'model' => new Supplier(['active' => true, 'country_id' => $defaultCountryId]),
|
'model' => new Supplier(['active' => true, 'country_id' => $defaultCountryId]),
|
||||||
'countries' => Country::query()->orderBy('de')->get(),
|
'countries' => Country::query()->orderBy('de')->get(),
|
||||||
'supplierCategories' => SupplierCategory::query()->orderBy('pos')->orderBy('name')->get(),
|
'supplierCategories' => SupplierCategory::query()->orderBy('pos')->orderBy('name')->get(),
|
||||||
|
'deliveryTimes' => DeliveryTime::query()->active()->orderBy('pos')->orderBy('label')->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +49,71 @@ class SupplierController extends Controller
|
||||||
return redirect()->route('admin.inventory.suppliers.index');
|
return redirect()->route('admin.inventory.suppliers.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function show(Supplier $supplier): View
|
||||||
|
{
|
||||||
|
return $this->renderDetails($supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachIngredient(Request $request, Supplier $supplier): View
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'ingredient_id' => ['required', 'integer', 'exists:ingredients,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$supplier->ingredients()->syncWithoutDetaching([$validated['ingredient_id']]);
|
||||||
|
|
||||||
|
return $this->renderDetails($supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detachIngredient(Supplier $supplier, Ingredient $ingredient): View
|
||||||
|
{
|
||||||
|
$supplier->ingredients()->detach($ingredient->id);
|
||||||
|
|
||||||
|
return $this->renderDetails($supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachPackagingItem(Request $request, Supplier $supplier): View
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'packaging_item_id' => ['required', 'integer', 'exists:packaging_items,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
PackagingItem::query()
|
||||||
|
->whereKey($validated['packaging_item_id'])
|
||||||
|
->update(['supplier_id' => $supplier->id]);
|
||||||
|
|
||||||
|
return $this->renderDetails($supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detachPackagingItem(Supplier $supplier, PackagingItem $packagingItem): View
|
||||||
|
{
|
||||||
|
if ((int) $packagingItem->supplier_id === (int) $supplier->id) {
|
||||||
|
$packagingItem->update(['supplier_id' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->renderDetails($supplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function renderDetails(Supplier $supplier): View
|
||||||
|
{
|
||||||
|
$supplier->load(['country', 'supplierCategories', 'ingredients', 'packagingItems']);
|
||||||
|
|
||||||
|
$assignedIngredientIds = $supplier->ingredients->pluck('id')->all();
|
||||||
|
$assignedPackagingItemIds = $supplier->packagingItems->pluck('id')->all();
|
||||||
|
|
||||||
|
return view('admin.inventory.suppliers._details', [
|
||||||
|
'supplier' => $supplier,
|
||||||
|
'availableIngredients' => Ingredient::query()
|
||||||
|
->whereNotIn('id', $assignedIngredientIds)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
'availablePackagingItems' => PackagingItem::query()
|
||||||
|
->whereNotIn('id', $assignedPackagingItemIds)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function edit(Supplier $supplier)
|
public function edit(Supplier $supplier)
|
||||||
{
|
{
|
||||||
$supplier->load('supplierCategories');
|
$supplier->load('supplierCategories');
|
||||||
|
|
@ -51,6 +122,7 @@ class SupplierController extends Controller
|
||||||
'model' => $supplier,
|
'model' => $supplier,
|
||||||
'countries' => Country::query()->orderBy('de')->get(),
|
'countries' => Country::query()->orderBy('de')->get(),
|
||||||
'supplierCategories' => SupplierCategory::query()->orderBy('pos')->orderBy('name')->get(),
|
'supplierCategories' => SupplierCategory::query()->orderBy('pos')->orderBy('name')->get(),
|
||||||
|
'deliveryTimes' => DeliveryTime::query()->active()->orderBy('pos')->orderBy('label')->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
54
app/Http/Controllers/Admin/Inventory/TaxRateController.php
Normal file
54
app/Http/Controllers/Admin/Inventory/TaxRateController.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Inventory;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Inventory\StoreTaxRateRequest;
|
||||||
|
use App\Http\Requests\Inventory\UpdateTaxRateRequest;
|
||||||
|
use App\Models\TaxRate;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class TaxRateController extends Controller
|
||||||
|
{
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('admin.inventory.tax-rates.form', [
|
||||||
|
'model' => new TaxRate(['active' => true, 'pos' => 0]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreTaxRateRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
TaxRate::create($request->validated());
|
||||||
|
|
||||||
|
\Session::flash('alert-save', '1');
|
||||||
|
|
||||||
|
return redirect()->route('admin.inventory.general');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(TaxRate $taxRate): View
|
||||||
|
{
|
||||||
|
return view('admin.inventory.tax-rates.form', [
|
||||||
|
'model' => $taxRate,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateTaxRateRequest $request, TaxRate $taxRate): RedirectResponse
|
||||||
|
{
|
||||||
|
$taxRate->update($request->validated());
|
||||||
|
|
||||||
|
\Session::flash('alert-save', '1');
|
||||||
|
|
||||||
|
return redirect()->route('admin.inventory.general');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(TaxRate $taxRate): RedirectResponse
|
||||||
|
{
|
||||||
|
$taxRate->delete();
|
||||||
|
|
||||||
|
\Session::flash('alert-success', __('Eintrag gelöscht'));
|
||||||
|
|
||||||
|
return redirect()->route('admin.inventory.general');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,12 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreIngredientRequest;
|
||||||
|
use App\Models\DeliveryTime;
|
||||||
use App\Models\Ingredient;
|
use App\Models\Ingredient;
|
||||||
use App\Models\ProductIngredient;
|
use App\Models\ProductIngredient;
|
||||||
use Request;
|
use App\Models\Supplier;
|
||||||
|
use App\Models\TaxRate;
|
||||||
|
|
||||||
class IngredientController extends Controller
|
class IngredientController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -29,40 +32,35 @@ class IngredientController extends Controller
|
||||||
$model = new Ingredient;
|
$model = new Ingredient;
|
||||||
$model->active = true;
|
$model->active = true;
|
||||||
} else {
|
} else {
|
||||||
$model = Ingredient::findOrFail($id);
|
$model = Ingredient::with('suppliers')->findOrFail($id);
|
||||||
}
|
}
|
||||||
$data = [
|
$data = [
|
||||||
'model' => $model,
|
'model' => $model,
|
||||||
// 'trans' => array_keys(config('localization.supportedLocales')),
|
'taxRates' => TaxRate::query()->active()->orderBy('pos')->orderBy('name')->get(),
|
||||||
|
'deliveryTimes' => DeliveryTime::query()->active()->orderBy('pos')->orderBy('label')->get(),
|
||||||
|
'suppliers' => Supplier::query()->where('active', true)->orderBy('name')->get(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('admin.ingredient.edit', $data);
|
return view('admin.ingredient.edit', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store(StoreIngredientRequest $request)
|
||||||
{
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$data['default_factor'] = $data['default_factor'] ?? 1.10;
|
||||||
|
|
||||||
$data = Request::all();
|
$supplierIds = $request->input('supplier_ids', []);
|
||||||
$data['active'] = isset($data['active']) ? true : false;
|
unset($data['supplier_ids']);
|
||||||
if (isset($data['default_factor']) && $data['default_factor'] !== '') {
|
|
||||||
$data['default_factor'] = reFormatNumber($data['default_factor']) ?: 1.10;
|
if ($request->input('id') === 'new') {
|
||||||
}
|
|
||||||
if (isset($data['min_stock_alert']) && $data['min_stock_alert'] === '') {
|
|
||||||
$data['min_stock_alert'] = null;
|
|
||||||
} elseif (isset($data['min_stock_alert']) && $data['min_stock_alert'] !== null) {
|
|
||||||
$data['min_stock_alert'] = reFormatNumber($data['min_stock_alert']);
|
|
||||||
}
|
|
||||||
if (empty($data['material_quality_id'])) {
|
|
||||||
$data['material_quality_id'] = null;
|
|
||||||
}
|
|
||||||
if ($data['id'] === 'new') {
|
|
||||||
$model = Ingredient::create($data);
|
$model = Ingredient::create($data);
|
||||||
} else {
|
} else {
|
||||||
$model = Ingredient::find($data['id']);
|
$model = Ingredient::findOrFail($request->input('id'));
|
||||||
$model->fill($data)->save();
|
$model->fill($data)->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$model->suppliers()->sync($supplierIds);
|
||||||
|
|
||||||
\Session()->flash('alert-save', '1');
|
\Session()->flash('alert-save', '1');
|
||||||
|
|
||||||
return redirect(route('admin_product_ingredients'));
|
return redirect(route('admin_product_ingredients'));
|
||||||
|
|
|
||||||
35
app/Http/Requests/Inventory/StoreDeliveryTimeRequest.php
Normal file
35
app/Http/Requests/Inventory/StoreDeliveryTimeRequest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Inventory;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreDeliveryTimeRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => ['required', 'string', 'max:255'],
|
||||||
|
'days' => ['nullable', 'integer', 'min:0', 'max:65535'],
|
||||||
|
'active' => ['sometimes', 'boolean'],
|
||||||
|
'pos' => ['nullable', 'integer', 'min:0', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'active' => $this->boolean('active'),
|
||||||
|
'days' => $this->input('days', '') === '' ? null : $this->input('days'),
|
||||||
|
'pos' => $this->input('pos', '') === '' ? 0 : $this->input('pos'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ class StorePackagingItemRequest extends FormRequest
|
||||||
'category' => ['required', Rule::in(['packaging', 'shipping'])],
|
'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', 'string', 'max:2048'],
|
||||||
'product_id' => ['nullable', 'integer', 'exists:products,id'],
|
'product_id' => ['nullable', 'integer', 'exists:products,id'],
|
||||||
'active' => ['sometimes', 'boolean'],
|
'active' => ['sometimes', 'boolean'],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,9 @@ class StoreStockEntryRequest extends FormRequest
|
||||||
'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'],
|
'quality_id' => ['nullable', 'integer', 'exists:material_qualities,id'],
|
||||||
|
'tax_rate_id' => ['nullable', 'integer', 'exists:tax_rates,id'],
|
||||||
'price_per_kg' => ['nullable', 'numeric', 'min:0'],
|
'price_per_kg' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'price_per_kg_gross' => ['nullable', 'numeric', 'min:0'],
|
||||||
'price_total' => ['nullable', 'numeric', 'min:0'],
|
'price_total' => ['nullable', 'numeric', 'min:0'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +41,7 @@ class StoreStockEntryRequest extends FormRequest
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'quality_id.required' => __('Bitte eine Rohstoffqualität wählen.'),
|
'quality_id.required' => __('Bitte eine Rohstoffqualität wählen.'),
|
||||||
'price_per_kg.required' => __('Bitte den Netto-Preis pro kg angeben.'),
|
'price_per_kg.required' => __('Bitte den Netto- oder Brutto-Preis pro kg angeben.'),
|
||||||
'price_total.required' => __('Bitte den Gesamtpreis netto angeben.'),
|
'price_total.required' => __('Bitte den Gesamtpreis netto angeben.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +51,7 @@ class StoreStockEntryRequest extends FormRequest
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'ordered_quantity' => reFormatNumber($this->input('ordered_quantity')),
|
'ordered_quantity' => reFormatNumber($this->input('ordered_quantity')),
|
||||||
'price_per_kg' => $this->filled('price_per_kg') ? reFormatNumber($this->input('price_per_kg')) : null,
|
'price_per_kg' => $this->filled('price_per_kg') ? reFormatNumber($this->input('price_per_kg')) : null,
|
||||||
|
'price_per_kg_gross' => $this->filled('price_per_kg_gross') ? reFormatNumber($this->input('price_per_kg_gross')) : null,
|
||||||
'price_total' => $this->filled('price_total') ? reFormatNumber($this->input('price_total')) : null,
|
'price_total' => $this->filled('price_total') ? reFormatNumber($this->input('price_total')) : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -64,8 +67,12 @@ class StoreStockEntryRequest extends FormRequest
|
||||||
if (empty($this->input('quality_id'))) {
|
if (empty($this->input('quality_id'))) {
|
||||||
$validator->errors()->add('quality_id', __('Bitte eine Rohstoffqualität wählen.'));
|
$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) {
|
$net = $this->input('price_per_kg');
|
||||||
$validator->errors()->add('price_per_kg', __('Bitte den Netto-Preis pro kg angeben.'));
|
$gross = $this->input('price_per_kg_gross');
|
||||||
|
$hasNet = is_numeric($net) && (float) $net > 0;
|
||||||
|
$hasGross = is_numeric($gross) && (float) $gross > 0;
|
||||||
|
if (! $hasNet && ! $hasGross) {
|
||||||
|
$validator->errors()->add('price_per_kg', __('Bitte den Netto- oder Brutto-Preis pro kg angeben.'));
|
||||||
}
|
}
|
||||||
} elseif (! empty($type)) {
|
} elseif (! empty($type)) {
|
||||||
if (empty($this->input('packaging_item_id'))) {
|
if (empty($this->input('packaging_item_id'))) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ class StoreSupplierRequest extends FormRequest
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'url' => ['nullable', 'string', 'max:2048'],
|
'url' => ['nullable', 'string', 'max:2048'],
|
||||||
|
'order_method' => ['nullable', 'in:email,online_shop'],
|
||||||
|
'order_email' => ['nullable', 'email', 'max:255'],
|
||||||
|
'order_url' => ['nullable', 'string', 'max:2048'],
|
||||||
|
'delivery_time' => ['nullable', 'string', 'max:255'],
|
||||||
|
'delivery_time_days' => ['nullable', 'integer', 'min:0', 'max:65535'],
|
||||||
'contact_person' => ['nullable', 'string', 'max:255'],
|
'contact_person' => ['nullable', 'string', 'max:255'],
|
||||||
'email' => ['nullable', 'email', 'max:255'],
|
'email' => ['nullable', 'email', 'max:255'],
|
||||||
'phone' => ['nullable', 'string', 'max:100'],
|
'phone' => ['nullable', 'string', 'max:100'],
|
||||||
|
|
@ -34,6 +39,7 @@ class StoreSupplierRequest extends FormRequest
|
||||||
{
|
{
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'active' => $this->boolean('active'),
|
'active' => $this->boolean('active'),
|
||||||
|
'delivery_time_days' => $this->input('delivery_time_days', '') === '' ? null : $this->input('delivery_time_days'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
app/Http/Requests/Inventory/StoreTaxRateRequest.php
Normal file
34
app/Http/Requests/Inventory/StoreTaxRateRequest.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Inventory;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreTaxRateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'percent' => ['required', 'numeric', 'min:0', 'max:100'],
|
||||||
|
'active' => ['sometimes', 'boolean'],
|
||||||
|
'pos' => ['nullable', 'integer', 'min:0', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'active' => $this->boolean('active'),
|
||||||
|
'pos' => $this->input('pos', '') === '' ? 0 : $this->input('pos'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Requests/Inventory/UpdateDeliveryTimeRequest.php
Normal file
35
app/Http/Requests/Inventory/UpdateDeliveryTimeRequest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Inventory;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateDeliveryTimeRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => ['required', 'string', 'max:255'],
|
||||||
|
'days' => ['nullable', 'integer', 'min:0', 'max:65535'],
|
||||||
|
'active' => ['sometimes', 'boolean'],
|
||||||
|
'pos' => ['nullable', 'integer', 'min:0', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'active' => $this->boolean('active'),
|
||||||
|
'days' => $this->input('days', '') === '' ? null : $this->input('days'),
|
||||||
|
'pos' => $this->input('pos', '') === '' ? 0 : $this->input('pos'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ class UpdatePackagingItemRequest extends FormRequest
|
||||||
'category' => ['required', Rule::in(['packaging', 'shipping'])],
|
'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', 'string', 'max:2048'],
|
||||||
'product_id' => ['nullable', 'integer', 'exists:products,id'],
|
'product_id' => ['nullable', 'integer', 'exists:products,id'],
|
||||||
'active' => ['sometimes', 'boolean'],
|
'active' => ['sometimes', 'boolean'],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,9 @@ class UpdateStockEntryRequest extends FormRequest
|
||||||
'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'],
|
'quality_id' => ['nullable', 'integer', 'exists:material_qualities,id'],
|
||||||
|
'tax_rate_id' => ['nullable', 'integer', 'exists:tax_rates,id'],
|
||||||
'price_per_kg' => ['nullable', 'numeric', 'min:0'],
|
'price_per_kg' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'price_per_kg_gross' => ['nullable', 'numeric', 'min:0'],
|
||||||
'price_total' => ['nullable', 'numeric', 'min:0'],
|
'price_total' => ['nullable', 'numeric', 'min:0'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +41,7 @@ class UpdateStockEntryRequest extends FormRequest
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'quality_id.required' => __('Bitte eine Rohstoffqualität wählen.'),
|
'quality_id.required' => __('Bitte eine Rohstoffqualität wählen.'),
|
||||||
'price_per_kg.required' => __('Bitte den Netto-Preis pro kg angeben.'),
|
'price_per_kg.required' => __('Bitte den Netto- oder Brutto-Preis pro kg angeben.'),
|
||||||
'price_total.required' => __('Bitte den Gesamtpreis netto angeben.'),
|
'price_total.required' => __('Bitte den Gesamtpreis netto angeben.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +51,7 @@ class UpdateStockEntryRequest extends FormRequest
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'ordered_quantity' => reFormatNumber($this->input('ordered_quantity')),
|
'ordered_quantity' => reFormatNumber($this->input('ordered_quantity')),
|
||||||
'price_per_kg' => $this->filled('price_per_kg') ? reFormatNumber($this->input('price_per_kg')) : null,
|
'price_per_kg' => $this->filled('price_per_kg') ? reFormatNumber($this->input('price_per_kg')) : null,
|
||||||
|
'price_per_kg_gross' => $this->filled('price_per_kg_gross') ? reFormatNumber($this->input('price_per_kg_gross')) : null,
|
||||||
'price_total' => $this->filled('price_total') ? reFormatNumber($this->input('price_total')) : null,
|
'price_total' => $this->filled('price_total') ? reFormatNumber($this->input('price_total')) : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -64,8 +67,12 @@ class UpdateStockEntryRequest extends FormRequest
|
||||||
if (empty($this->input('quality_id'))) {
|
if (empty($this->input('quality_id'))) {
|
||||||
$validator->errors()->add('quality_id', __('Bitte eine Rohstoffqualität wählen.'));
|
$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) {
|
$net = $this->input('price_per_kg');
|
||||||
$validator->errors()->add('price_per_kg', __('Bitte den Netto-Preis pro kg angeben.'));
|
$gross = $this->input('price_per_kg_gross');
|
||||||
|
$hasNet = is_numeric($net) && (float) $net > 0;
|
||||||
|
$hasGross = is_numeric($gross) && (float) $gross > 0;
|
||||||
|
if (! $hasNet && ! $hasGross) {
|
||||||
|
$validator->errors()->add('price_per_kg', __('Bitte den Netto- oder Brutto-Preis pro kg angeben.'));
|
||||||
}
|
}
|
||||||
} elseif (! empty($type)) {
|
} elseif (! empty($type)) {
|
||||||
if (empty($this->input('packaging_item_id'))) {
|
if (empty($this->input('packaging_item_id'))) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ class UpdateSupplierRequest extends FormRequest
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'url' => ['nullable', 'string', 'max:2048'],
|
'url' => ['nullable', 'string', 'max:2048'],
|
||||||
|
'order_method' => ['nullable', 'in:email,online_shop'],
|
||||||
|
'order_email' => ['nullable', 'email', 'max:255'],
|
||||||
|
'order_url' => ['nullable', 'string', 'max:2048'],
|
||||||
|
'delivery_time' => ['nullable', 'string', 'max:255'],
|
||||||
|
'delivery_time_days' => ['nullable', 'integer', 'min:0', 'max:65535'],
|
||||||
'contact_person' => ['nullable', 'string', 'max:255'],
|
'contact_person' => ['nullable', 'string', 'max:255'],
|
||||||
'email' => ['nullable', 'email', 'max:255'],
|
'email' => ['nullable', 'email', 'max:255'],
|
||||||
'phone' => ['nullable', 'string', 'max:100'],
|
'phone' => ['nullable', 'string', 'max:100'],
|
||||||
|
|
@ -34,6 +39,7 @@ class UpdateSupplierRequest extends FormRequest
|
||||||
{
|
{
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'active' => $this->boolean('active'),
|
'active' => $this->boolean('active'),
|
||||||
|
'delivery_time_days' => $this->input('delivery_time_days', '') === '' ? null : $this->input('delivery_time_days'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
app/Http/Requests/Inventory/UpdateTaxRateRequest.php
Normal file
34
app/Http/Requests/Inventory/UpdateTaxRateRequest.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Inventory;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateTaxRateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'percent' => ['required', 'numeric', 'min:0', 'max:100'],
|
||||||
|
'active' => ['sometimes', 'boolean'],
|
||||||
|
'pos' => ['nullable', 'integer', 'min:0', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'active' => $this->boolean('active'),
|
||||||
|
'pos' => $this->input('pos', '') === '' ? 0 : $this->input('pos'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Http/Requests/StoreIngredientRequest.php
Normal file
61
app/Http/Requests/StoreIngredientRequest.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreIngredientRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, mixed>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'inci' => ['nullable', 'string'],
|
||||||
|
'effect' => ['nullable', 'string'],
|
||||||
|
'pos' => ['nullable', 'integer', 'min:0', 'max:255'],
|
||||||
|
'default_factor' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'min_stock_alert' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'material_quality_id' => ['nullable', 'integer', 'exists:material_qualities,id'],
|
||||||
|
'tax_rate_id' => ['nullable', 'integer', 'exists:tax_rates,id'],
|
||||||
|
'delivery_time' => ['nullable', 'string', 'max:255'],
|
||||||
|
'delivery_time_days' => ['nullable', 'integer', 'min:0', 'max:65535'],
|
||||||
|
'supplier_ids' => ['nullable', 'array'],
|
||||||
|
'supplier_ids.*' => ['integer', 'exists:suppliers,id'],
|
||||||
|
'active' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'active' => $this->boolean('active'),
|
||||||
|
'default_factor' => $this->normalizeDecimal($this->input('default_factor')),
|
||||||
|
'min_stock_alert' => $this->normalizeDecimal($this->input('min_stock_alert')),
|
||||||
|
'material_quality_id' => $this->nullIfEmpty($this->input('material_quality_id')),
|
||||||
|
'tax_rate_id' => $this->nullIfEmpty($this->input('tax_rate_id')),
|
||||||
|
'delivery_time_days' => $this->nullIfEmpty($this->input('delivery_time_days')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeDecimal(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) reFormatNumber($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullIfEmpty(mixed $value): mixed
|
||||||
|
{
|
||||||
|
return $value === '' ? null : $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Models/DeliveryTime.php
Normal file
41
app/Models/DeliveryTime.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\DeliveryTimeFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class DeliveryTime extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<DeliveryTimeFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'label',
|
||||||
|
'days',
|
||||||
|
'active',
|
||||||
|
'pos',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'days' => 'integer',
|
||||||
|
'active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<DeliveryTime> $query
|
||||||
|
* @return Builder<DeliveryTime>
|
||||||
|
*/
|
||||||
|
public function scopeActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('active', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class Ingredient
|
* Class Ingredient
|
||||||
|
|
@ -56,6 +57,7 @@ class Ingredient extends Model
|
||||||
'pos' => 'int',
|
'pos' => 'int',
|
||||||
'default_factor' => 'decimal:2',
|
'default_factor' => 'decimal:2',
|
||||||
'min_stock_alert' => 'decimal:2',
|
'min_stock_alert' => 'decimal:2',
|
||||||
|
'delivery_time_days' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
|
@ -70,6 +72,9 @@ class Ingredient extends Model
|
||||||
'default_factor',
|
'default_factor',
|
||||||
'min_stock_alert',
|
'min_stock_alert',
|
||||||
'material_quality_id',
|
'material_quality_id',
|
||||||
|
'tax_rate_id',
|
||||||
|
'delivery_time',
|
||||||
|
'delivery_time_days',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -80,6 +85,24 @@ class Ingredient extends Model
|
||||||
return $this->belongsTo(MaterialQuality::class);
|
return $this->belongsTo(MaterialQuality::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<TaxRate, $this>
|
||||||
|
*/
|
||||||
|
public function taxRate(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(TaxRate::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<Supplier, $this>
|
||||||
|
*/
|
||||||
|
public function suppliers(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Supplier::class, 'ingredient_supplier')
|
||||||
|
->withPivot(['preferred', 'supplier_sku', 'url'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
public function products()
|
public function products()
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Product::class, 'product_ingredients')
|
return $this->belongsToMany(Product::class, 'product_ingredients')
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,10 @@ class StockEntry extends Model
|
||||||
'ordered_at',
|
'ordered_at',
|
||||||
'ordered_quantity',
|
'ordered_quantity',
|
||||||
'price_per_kg',
|
'price_per_kg',
|
||||||
|
'price_per_kg_gross',
|
||||||
'price_total',
|
'price_total',
|
||||||
|
'tax_rate_id',
|
||||||
|
'tax_rate_percent',
|
||||||
'received_by',
|
'received_by',
|
||||||
'received_at',
|
'received_at',
|
||||||
'received_quantity',
|
'received_quantity',
|
||||||
|
|
@ -46,7 +49,9 @@ class StockEntry extends Model
|
||||||
'ordered_quantity' => 'decimal:2',
|
'ordered_quantity' => 'decimal:2',
|
||||||
'received_quantity' => 'decimal:2',
|
'received_quantity' => 'decimal:2',
|
||||||
'price_per_kg' => 'decimal:4',
|
'price_per_kg' => 'decimal:4',
|
||||||
|
'price_per_kg_gross' => 'decimal:4',
|
||||||
'price_total' => 'decimal:4',
|
'price_total' => 'decimal:4',
|
||||||
|
'tax_rate_percent' => 'decimal:2',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,6 +87,14 @@ class StockEntry extends Model
|
||||||
return $this->belongsTo(Location::class);
|
return $this->belongsTo(Location::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<TaxRate, $this>
|
||||||
|
*/
|
||||||
|
public function taxRate(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(TaxRate::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return BelongsTo<MaterialQuality, $this>
|
* @return BelongsTo<MaterialQuality, $this>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ class Supplier extends Model
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'url',
|
'url',
|
||||||
|
'order_method',
|
||||||
|
'order_email',
|
||||||
|
'order_url',
|
||||||
|
'delivery_time',
|
||||||
|
'delivery_time_days',
|
||||||
'contact_person',
|
'contact_person',
|
||||||
'email',
|
'email',
|
||||||
'phone',
|
'phone',
|
||||||
|
|
@ -33,6 +38,7 @@ class Supplier extends Model
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'active' => 'boolean',
|
'active' => 'boolean',
|
||||||
|
'delivery_time_days' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,4 +70,14 @@ class Supplier extends Model
|
||||||
{
|
{
|
||||||
return $this->hasMany(PackagingItem::class);
|
return $this->hasMany(PackagingItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<Ingredient, $this>
|
||||||
|
*/
|
||||||
|
public function ingredients(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Ingredient::class, 'ingredient_supplier')
|
||||||
|
->withPivot(['preferred', 'supplier_sku', 'url'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
app/Models/TaxRate.php
Normal file
41
app/Models/TaxRate.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\TaxRateFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class TaxRate extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<TaxRateFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'percent',
|
||||||
|
'active',
|
||||||
|
'pos',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'percent' => 'decimal:2',
|
||||||
|
'active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<TaxRate> $query
|
||||||
|
* @return Builder<TaxRate>
|
||||||
|
*/
|
||||||
|
public function scopeActive(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('active', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Repositories;
|
namespace App\Repositories;
|
||||||
|
|
||||||
use App\Models\StockEntry;
|
use App\Models\StockEntry;
|
||||||
|
use App\Models\TaxRate;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class StockEntryRepository
|
class StockEntryRepository
|
||||||
|
|
@ -13,6 +14,7 @@ class StockEntryRepository
|
||||||
public function create(array $data): StockEntry
|
public function create(array $data): StockEntry
|
||||||
{
|
{
|
||||||
$data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece';
|
$data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece';
|
||||||
|
$data = $this->resolvePrices($data);
|
||||||
|
|
||||||
return StockEntry::query()->create($data);
|
return StockEntry::query()->create($data);
|
||||||
}
|
}
|
||||||
|
|
@ -26,11 +28,62 @@ class StockEntryRepository
|
||||||
$data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece';
|
$data['unit'] = ($data['entry_type'] ?? '') === 'ingredient' ? 'gram' : 'piece';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$data = $this->resolvePrices($data);
|
||||||
|
|
||||||
$stockEntry->update($data);
|
$stockEntry->update($data);
|
||||||
|
|
||||||
return $stockEntry->fresh();
|
return $stockEntry->fresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergänzt Netto-/Brutto-Preis pro kg und den UST-Snapshot.
|
||||||
|
* Es genügt, Netto oder Brutto anzugeben; der jeweils fehlende Wert wird
|
||||||
|
* aus dem gewählten Steuersatz berechnet.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function resolvePrices(array $data): array
|
||||||
|
{
|
||||||
|
if (($data['entry_type'] ?? '') !== 'ingredient') {
|
||||||
|
$data['price_per_kg'] = null;
|
||||||
|
$data['price_per_kg_gross'] = null;
|
||||||
|
$data['tax_rate_id'] = null;
|
||||||
|
$data['tax_rate_percent'] = null;
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$percent = null;
|
||||||
|
if (! empty($data['tax_rate_id'])) {
|
||||||
|
$percent = TaxRate::query()->whereKey($data['tax_rate_id'])->value('percent');
|
||||||
|
}
|
||||||
|
$data['tax_rate_percent'] = $percent;
|
||||||
|
|
||||||
|
$factor = 1 + ((float) ($percent ?? 0)) / 100;
|
||||||
|
|
||||||
|
$net = isset($data['price_per_kg']) && $data['price_per_kg'] !== null && $data['price_per_kg'] !== ''
|
||||||
|
? (float) $data['price_per_kg']
|
||||||
|
: null;
|
||||||
|
$gross = isset($data['price_per_kg_gross']) && $data['price_per_kg_gross'] !== null && $data['price_per_kg_gross'] !== ''
|
||||||
|
? (float) $data['price_per_kg_gross']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($net !== null && $gross === null) {
|
||||||
|
$gross = round($net * $factor, 4);
|
||||||
|
} elseif ($gross !== null && $net === null) {
|
||||||
|
$net = $factor > 0 ? round($gross / $factor, 4) : $gross;
|
||||||
|
} elseif ($net !== null && $gross !== null) {
|
||||||
|
$gross = round($net * $factor, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['price_per_kg'] = $net;
|
||||||
|
$data['price_per_kg_gross'] = $gross;
|
||||||
|
$data['price_total'] = null;
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ class SupplierRepository
|
||||||
return collect($data)->only([
|
return collect($data)->only([
|
||||||
'name',
|
'name',
|
||||||
'url',
|
'url',
|
||||||
|
'order_method',
|
||||||
|
'order_email',
|
||||||
|
'order_url',
|
||||||
|
'delivery_time',
|
||||||
|
'delivery_time_days',
|
||||||
'contact_person',
|
'contact_person',
|
||||||
'email',
|
'email',
|
||||||
'phone',
|
'phone',
|
||||||
|
|
|
||||||
27
database/factories/DeliveryTimeFactory.php
Normal file
27
database/factories/DeliveryTimeFactory.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\DeliveryTime;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<DeliveryTime>
|
||||||
|
*/
|
||||||
|
class DeliveryTimeFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = DeliveryTime::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => $this->faker->randomElement(['1–3 Werktage', '3–5 Werktage', '1–2 Wochen']),
|
||||||
|
'days' => $this->faker->randomElement([3, 5, 14]),
|
||||||
|
'active' => true,
|
||||||
|
'pos' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
database/factories/TaxRateFactory.php
Normal file
27
database/factories/TaxRateFactory.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\TaxRate;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<TaxRate>
|
||||||
|
*/
|
||||||
|
class TaxRateFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = TaxRate::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->faker->randomElement(['Standard', 'Ermäßigt', 'Steuerfrei']),
|
||||||
|
'percent' => $this->faker->randomElement([19.00, 7.00, 0.00]),
|
||||||
|
'active' => true,
|
||||||
|
'pos' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?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('suppliers', function (Blueprint $table) {
|
||||||
|
$table->string('url', 2048)->nullable()->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('packaging_items', function (Blueprint $table) {
|
||||||
|
$table->string('url', 2048)->nullable()->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('suppliers', function (Blueprint $table) {
|
||||||
|
$table->string('url')->nullable()->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('packaging_items', function (Blueprint $table) {
|
||||||
|
$table->string('url', 500)->nullable()->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('tax_rates', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->decimal('percent', 5, 2);
|
||||||
|
$table->boolean('active')->default(true);
|
||||||
|
$table->unsignedTinyInteger('pos')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('tax_rates');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('delivery_times', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('label');
|
||||||
|
$table->boolean('active')->default(true);
|
||||||
|
$table->unsignedTinyInteger('pos')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('delivery_times');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?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('suppliers', function (Blueprint $table) {
|
||||||
|
$table->enum('order_method', ['email', 'online_shop'])->nullable()->after('url');
|
||||||
|
$table->string('order_email')->nullable()->after('order_method');
|
||||||
|
$table->string('order_url', 2048)->nullable()->after('order_email');
|
||||||
|
$table->string('delivery_time')->nullable()->after('order_url');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('suppliers', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['order_method', 'order_email', 'order_url', 'delivery_time']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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('delivery_times', function (Blueprint $table) {
|
||||||
|
$table->unsignedSmallInteger('days')->nullable()->after('label');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('delivery_times', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('days');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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('suppliers', function (Blueprint $table) {
|
||||||
|
$table->unsignedSmallInteger('delivery_time_days')->nullable()->after('delivery_time');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('suppliers', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('delivery_time_days');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?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('ingredients', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('tax_rate_id')->nullable()->after('material_quality_id');
|
||||||
|
$table->string('delivery_time')->nullable()->after('tax_rate_id');
|
||||||
|
$table->unsignedSmallInteger('delivery_time_days')->nullable()->after('delivery_time');
|
||||||
|
|
||||||
|
$table->foreign('tax_rate_id')->references('id')->on('tax_rates')->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('ingredients', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['tax_rate_id']);
|
||||||
|
$table->dropColumn(['tax_rate_id', 'delivery_time', 'delivery_time_days']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?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::create('ingredient_supplier', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedInteger('ingredient_id');
|
||||||
|
$table->unsignedBigInteger('supplier_id');
|
||||||
|
$table->boolean('preferred')->default(false);
|
||||||
|
$table->string('supplier_sku')->nullable();
|
||||||
|
$table->string('url', 2048)->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['ingredient_id', 'supplier_id']);
|
||||||
|
$table->foreign('ingredient_id')->references('id')->on('ingredients')->cascadeOnDelete();
|
||||||
|
$table->foreign('supplier_id')->references('id')->on('suppliers')->cascadeOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('ingredient_supplier');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?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('stock_entries', function (Blueprint $table) {
|
||||||
|
$table->decimal('price_per_kg_gross', 10, 4)->nullable()->after('price_per_kg');
|
||||||
|
$table->unsignedBigInteger('tax_rate_id')->nullable()->after('price_total');
|
||||||
|
$table->decimal('tax_rate_percent', 5, 2)->nullable()->after('tax_rate_id');
|
||||||
|
|
||||||
|
$table->foreign('tax_rate_id')->references('id')->on('tax_rates')->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('stock_entries', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['tax_rate_id']);
|
||||||
|
$table->dropColumn(['price_per_kg_gross', 'tax_rate_id', 'tax_rate_percent']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\DeliveryTime;
|
||||||
use App\Models\Location;
|
use App\Models\Location;
|
||||||
use App\Models\MaterialQuality;
|
use App\Models\MaterialQuality;
|
||||||
use App\Models\PackagingMaterial;
|
use App\Models\PackagingMaterial;
|
||||||
|
use App\Models\TaxRate;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class InventoryStammdatenSeeder extends Seeder
|
class InventoryStammdatenSeeder extends Seeder
|
||||||
|
|
@ -45,5 +47,29 @@ class InventoryStammdatenSeeder extends Seeder
|
||||||
['pos' => $pos]
|
['pos' => $pos]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$taxRates = [
|
||||||
|
['name' => 'Standard', 'percent' => 19.00],
|
||||||
|
['name' => 'Ermäßigt', 'percent' => 7.00],
|
||||||
|
['name' => 'Steuerfrei', 'percent' => 0.00],
|
||||||
|
];
|
||||||
|
foreach ($taxRates as $pos => $taxRate) {
|
||||||
|
TaxRate::query()->firstOrCreate(
|
||||||
|
['percent' => $taxRate['percent']],
|
||||||
|
['name' => $taxRate['name'], 'active' => true, 'pos' => $pos]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deliveryTimes = [
|
||||||
|
['label' => '1–3 Werktage', 'days' => 3],
|
||||||
|
['label' => '3–5 Werktage', 'days' => 5],
|
||||||
|
['label' => '1–2 Wochen', 'days' => 14],
|
||||||
|
];
|
||||||
|
foreach ($deliveryTimes as $pos => $deliveryTime) {
|
||||||
|
DeliveryTime::query()->firstOrCreate(
|
||||||
|
['label' => $deliveryTime['label']],
|
||||||
|
['days' => $deliveryTime['days'], 'active' => true, 'pos' => $pos]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,425 @@
|
||||||
|
# Aktualisierter Entwicklungsplan: Warenwirtschaft, Produktion & Produktbestand
|
||||||
|
|
||||||
|
> **Version:** 4.0 - Stand 02.06.2026
|
||||||
|
> **Ersetzt:** `entwicklungsplan-aktualisiert-27-04-2026.md` (V3.0) als operative Arbeitsgrundlage
|
||||||
|
> **Referenzen:** `entwicklungsplan.md` (V2.0), `briefing-anpassungen-27-04-2026.md`, `feedback.md`, `konzept-final.md`, `docs/Todos.md`
|
||||||
|
> **Methodik:** Backlog aus kleinen, sequenziell abarbeitbaren Arbeitspaketen (AP). Jedes AP hat Ziel, konkrete Schritte mit Dateipfaden, DB-Änderungen, Akzeptanzkriterien und Tests. Reihenfolge ist so gewählt, dass jedes AP einzeln deploybar ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Was dieses Dokument neu macht
|
||||||
|
|
||||||
|
Gegenüber V3.0 wurde der **reale Code-Stand verifiziert** (nicht nur die Protokolltabelle übernommen). Daraus ergeben sich Korrekturen, neu entdeckte Bugs und eine feinere Zerlegung in einzeln umsetzbare Schritte.
|
||||||
|
|
||||||
|
Geprüfte Dateien u. a.: `routes/web.php`, `app/Http/Controllers/Admin/Inventory/*`, `app/Services/ProductionService.php`, `app/Http/Requests/Inventory/*`, `resources/views/admin/inventory/*`, Migrationen unter `database/migrations/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0a. Umsetzungsprotokoll V4.0 (laufend)
|
||||||
|
|
||||||
|
> Jede abgeschlossene Teil-Lieferung wird hier mit Datum, betroffenen Dateien und Test-Status protokolliert.
|
||||||
|
|
||||||
|
| Datum | AP | Kurzbeschreibung | Tests |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 02.06.2026 | **AP-01** | URL-Bugfixes B1/B2 umgesetzt: `suppliers/form.blade.php` und `packaging-items/form.blade.php` von `type="url"` auf `type="text"` (placeholder `https://`); `Store/UpdatePackagingItemRequest` URL-Regel `url\|max:500` → `string\|max:2048`; Migration `2026_06_02_145358_widen_url_columns_in_inventory_tables` (suppliers.url + packaging_items.url → varchar(2048)). | `tests/Feature/InventoryUrlFieldsTest.php` (3 grün); Regression Phase 2+3 grün (17) |
|
||||||
|
| 02.06.2026 | **AP-04** | iPad-taugliche Tabellen-Aktionen (B5): neue Partial `resources/views/admin/inventory/partials/table-actions-style.blade.php` (`@once`-Style für `.wawi-table td .btn`, min. 42px Touch-Target, mehr Abstand); Klasse `wawi-table` + Partial-Include in allen 8 Index-Views (locations, material-qualities, packaging-materials, supplier-categories, suppliers, packaging-items, stock-entries, productions). | Render-Regression Phase 2+3+5 grün (21) |
|
||||||
|
| 02.06.2026 | **AP-00** | Regressionsbasis für umgesetzte 5.1-Features als Pest-Tests nachgezogen: INCI-Rohstoffqualität-Relation, Hersteller-Rezeptur getrennt von Produkt-Rezeptur, Produkt-Kopie inkl. beider Rezepturen, „nur aktive Produkte" im Produktions-Formular, Produktion edit/copy rendern. | `tests/Feature/ProductPhase51Test.php` (5 grün) |
|
||||||
|
| 02.06.2026 | **AP-04.1** | Aktionsspalten vereinheitlicht (Kunden-Feedback): einheitliches Schema **Spalte 1 = Ansicht + Bearbeiten** (+ Kopieren bei Produktion), **letzte Spalte = Löschen**. Umgebaut: `stock-entries/index` (Ansicht/Bearbeiten nach vorn, Löschen ans Ende, DataTables `order`/`columnDefs` an verschobene Spalten angepasst) und `productions/index` (Ansicht/Bearbeiten/Kopieren nach vorn). Stammdaten-Tabellen waren bereits konform (kein `show`-Route → keine Ansicht). CSS-Feinjustierung der Button-Abstände durch Kunden in `partials/table-actions-style.blade.php` übernommen. | Produktions-Index-Render + Aktionslinks geprüft; stock-entries Index-Render grün (13) |
|
||||||
|
| 02.06.2026 | **AP-05 (Teil 1: UST)** | Neuer Unterpunkt **Warenwirtschaft → Einstellungen → „Allgemein"** als erweiterbarer Container für kleinteilige Einstellungen (erstes Modul = Umsatzsteuersätze). Tabelle `tax_rates` (`name`, `percent` DECIMAL(5,2), `active`, `pos`) via Migration `2026_06_02_152721_create_tax_rates_table`; Model `TaxRate` (casts `percent`/`active`, `scopeActive`), `TaxRateFactory`, Seeder-Erweiterung `InventoryStammdatenSeeder` (19/7/0, idempotent per `firstOrCreate`). CRUD: `GeneralSettingController@index` (Allgemein-Seite), `TaxRateController` (create/store/edit/update/destroy, Redirect zurück auf `general`), `Store/UpdateTaxRateRequest`. Views `admin/inventory/general/index.blade.php` (Karte „Umsatzsteuersätze" mit Tabelle + Neu/Bearbeiten/Löschen) und `admin/inventory/tax-rates/form.blade.php`. Routen in `superadmin`-Gruppe (`admin.inventory.general`, Resource `tax-rates` ohne index/show). Sidenav-Eintrag „Allgemein" inkl. `open`/`active`-Logik. Migration + Seed auf DB ausgeführt (3 Default-Sätze vorhanden). | `tests/Feature/TaxRateSettingsTest.php` (7 grün, 24 Assertions): Render, CRUD, Validierung Pflicht/Bereich, `active`-Scope, Zugriffsschutz Nicht-SuperAdmin |
|
||||||
|
| 02.06.2026 | **AP-05 (Teil 2: Lieferzeiten)** | Zweite Karte „Lieferzeit-Vorlagen" auf der Allgemein-Seite. Tabelle `delivery_times` (`label`, `active`, `pos`) via Migration `2026_06_02_153243_create_delivery_times_table`; Model `DeliveryTime` (cast `active`, `scopeActive`), `DeliveryTimeFactory`, Seeder-Erweiterung (1–3 / 3–5 Werktage / 1–2 Wochen, idempotent). CRUD: `DeliveryTimeController` (create/store/edit/update/destroy → Redirect `general`), `Store/UpdateDeliveryTimeRequest`. `GeneralSettingController` um `deliveryTimes` erweitert. View `admin/inventory/delivery-times/form.blade.php` + zweite Karte in `general/index`. Route Resource `delivery-times` (ohne index/show) in `superadmin`-Gruppe. Sidenav `open`/`active` um `delivery-times` ergänzt. Migration + Seed auf DB ausgeführt. | `tests/Feature/DeliveryTimeSettingsTest.php` (7 grün, 21 Assertions): Render, CRUD, Validierung, `active`-Scope, Zugriffsschutz |
|
||||||
|
| 02.06.2026 | **AP-06 (Lieferanten erweitern)** | Felder `order_method` ENUM(`email`,`online_shop`), `order_email`, `order_url`, `delivery_time` (Freitext) an `suppliers` via Migration `2026_06_02_154755_add_order_fields_to_suppliers_table`. `Supplier` fillable erweitert; `Store/UpdateSupplierRequest` Regeln (`order_method` in:email,online_shop; `order_email` email; `order_url` string max 2048; `delivery_time` string). `SupplierRepository::extractSupplierAttributes` erweitert. `SupplierController` create/edit übergeben aktive `deliveryTimes` als Vorlagen. `suppliers/form.blade.php`: Bestellweg-Select + bedingte Felder (Bestell-E-Mail / Bestell-URL via JS-Toggle) + Lieferzeit-Textfeld mit `<datalist>` aus aktiven Lieferzeit-Vorlagen. Migration auf DB ausgeführt. | `tests/Feature/SupplierOrderFieldsTest.php` (6 grün): Formular zeigt nur aktive Vorlagen, Speichern E-Mail-/Shop-Bestellweg, Update, Validierung Bestellweg/Bestell-E-Mail. Regression `InventoryPhase2Test` (9 grün) |
|
||||||
|
| 02.06.2026 | **AP-06 (Nachtrag: Lieferzeit in Tagen)** | Lieferzeit-Vorlagen erhalten festes Feld `days` (ganze Tage bis Wareneingang, Basis für spätere „rechtzeitig bestellen"-Ableitung). Migration `2026_06_02_160411_add_days_to_delivery_times_table` (`delivery_times.days` unsignedSmallInt nullable) + `2026_06_02_160411_add_delivery_time_days_to_suppliers_table` (`suppliers.delivery_time_days`). `DeliveryTime` (fillable+cast `days`), Factory/Seeder (3/5/14 Tage, Bestandsdaten nachgepflegt). `Store/UpdateDeliveryTimeRequest` + `Store/UpdateSupplierRequest` um `days`/`delivery_time_days` (nullable int) erweitert; `SupplierRepository` + `Supplier` cast. Views: Tage-Feld in `delivery-times/form`, Spalte „Tage" in `general/index`, Tage-Feld im `suppliers/form` + JS-Autofill (`data-days` an Datalist-Optionen setzt Tage bei Vorlagenauswahl, manuell überschreibbar). Migrationen auf DB ausgeführt, Default-Vorlagen mit Tagen befüllt. | `DeliveryTimeSettingsTest` (10 grün): days speichern/optional/Integer-Validierung; `SupplierOrderFieldsTest` (9 grün): `delivery_time_days` speichern, Integer-Validierung, `data-days`-Ausgabe |
|
||||||
|
| 02.06.2026 | **AP-08 (Einkauf erweitern)** | Einkauf um UST-Satz + Netto/Brutto-Automatik + Duplizieren erweitert. Migration `2026_06_02_181548_add_price_fields_to_stock_entries_table` (`price_per_kg_gross` DECIMAL(10,4), `tax_rate_id` FK→`tax_rates` nullOnDelete, `tax_rate_percent` DECIMAL(5,2) als Snapshot). `price_per_kg` bleibt das bestehende **Netto**-Feld (kein Rename → keine Migration der Bestandsdaten/Tests). `StockEntry`: fillable + casts (`price_per_kg_gross`/`tax_rate_percent`) + `taxRate()` belongsTo. `Store/UpdateStockEntryRequest`: Regeln `tax_rate_id` (exists) + `price_per_kg_gross` (numeric), Reformat dt. Zahl, neue Regel „bei Rohstoff genau eines von Netto/Brutto verpflichtend". **Berechnung zentral im `StockEntryRepository::resolvePrices()`:** UST-Prozent als Snapshot, fehlender Netto-/Brutto-Wert wird aus dem Faktor `(1+%/100)` berechnet (Netto↔Brutto), bei Verpackung Preisfelder/UST genullt (Netto-Gesamt bleibt). View `_form`: UST-Dropdown (aktive `tax_rates`, `data-percent`) + Netto-/Brutto-Felder nebeneinander; `_scripts`: JS rechnet live Netto↔Brutto bei Eingabe und UST-Wechsel (dt. Zahlenformat). `show`: Anzeige Netto/Brutto/USt. **Duplizieren:** Route `stock-entries/{stock_entry}/copy` + `StockEntryController@copy` legt direkt eine `pending`-Kopie der Stufe-1-Felder an (Charge/MHD/Eingangsdaten leer, `ordered_at`=heute, `ordered_by`=aktueller User) und leitet zur Bearbeitung; Kopieren-Button in `index` (Aktionsspalte) + `show`-Header. Migration auf DB ausgeführt. | `tests/Feature/StockEntryPriceTest.php` (6 grün): Netto→Brutto, Brutto→Netto, ohne UST Netto=Brutto, Netto/Brutto-Pflicht, Duplizieren erzeugt pending-Kopie ohne Chargendaten, Copy-Zugriffsschutz. Regression `InventoryPhase3Test` (8 grün) |
|
||||||
|
| 02.06.2026 | **AP-07.1 (Lieferanten-Detailansicht/Modal)** | Zwischenschritt (Kunde): Lieferanten-Zuordnungen auch von der Lieferantenseite aus sichtbar/pflegbar. `Supplier::ingredients()` belongsToMany (Gegenstück zu `Ingredient::suppliers()`). Resource `suppliers` `show` reaktiviert + neue Routen `suppliers.ingredients.attach/detach` und `suppliers.packaging-items.attach/detach` (admin-Gruppe). `SupplierController`: `show()` + `attach/detachIngredient()` + `attach/detachPackagingItem()` rendern gemeinsames Partial `suppliers/_details.blade.php` (Stammdaten + zwei kleine Listen „Zugeordnete INCIs" / „Zugeordnete Verpackungsartikel" mit Entfernen-Button und Hinzufügen-Auswahl der noch nicht zugeordneten Einträge). Index: Augen-Button (Spalte 1) öffnet Bootstrap-Modal, lädt Details per AJAX; Hinzufügen/Entfernen via delegiertem jQuery-AJAX (X-CSRF-TOKEN-Header) und ersetzt den Modal-Body mit dem neu gerenderten Partial. Verpackungsartikel-Zuordnung = `packaging_items.supplier_id` setzen/leeren. | `tests/Feature/SupplierDetailsTest.php` (7 grün): show zeigt zugeordnete INCIs/Verpackung, INCI attach/detach, Verpackung attach/detach, Validierung, Zugriffsschutz Nicht-Admin |
|
||||||
|
| 02.06.2026 | **AP-07 (INCI erweitern)** | INCI/Rohstoffe um Lieferanten-Mehrfachwahl, UST-Satz und eigene Lieferzeit (inkl. Tage-Autofill) erweitert. Migration `2026_06_02_161237_add_order_fields_to_ingredients_table` (`ingredients.tax_rate_id` FK→`tax_rates` nullOnDelete, `delivery_time` VARCHAR, `delivery_time_days` unsignedSmallInt) + `2026_06_02_161237_create_ingredient_supplier_table` (Pivot `ingredient_id` (unsignedInt, passend zu altem `increments`) / `supplier_id`, `preferred` bool, `supplier_sku`, `url`(2048), unique-Paar, cascadeOnDelete). `Ingredient`: fillable + cast `delivery_time_days`, Relationen `taxRate()` belongsTo + `suppliers()` belongsToMany (Pivot `preferred`/`supplier_sku`/`url`). **O1 erledigt:** `IngredientController::store()` von `Request::all()` auf neuen `App\Http\Requests\StoreIngredientRequest` umgestellt (validiert + normalisiert deutsche Dezimalzahlen `default_factor`/`min_stock_alert`, leere FKs→null). `edit()` lädt aktive `taxRates`, aktive `deliveryTimes`, aktive `suppliers` + eager-load `suppliers`; nach Speichern `suppliers()->sync()`. Single-Endpoint-Schema (`admin_product_ingredient_store` für Neu+Update) beibehalten → ein FormRequest genügt. View `admin/ingredient/form.blade.php`: UST-Dropdown (aktive `tax_rates`), Select2-Mehrfachwahl Lieferanten, Lieferzeit-Textfeld mit `<datalist>` (`data-days`) + Tage-Feld; `edit.blade.php` `@section('scripts')` mit Select2-Init + Tage-Autofill (manuell überschreibbar). Lieferzeit-Logik: INCI-Lieferzeit hat Vorrang vor Lieferanten-Lieferzeit (Auswertung erst in AP-10). Migrationen auf DB ausgeführt. | `tests/Feature/IngredientOrderFieldsTest.php` (6 grün): Formular zeigt Lieferanten/UST/aktive Vorlagen+`data-days`, Speichern mit UST/Lieferzeit/Tagen/Lieferanten, Lieferanten-Sync bei Update, Validierung Tage-Integer/UST-Existenz/Name-Pflicht. Regression `SupplierOrderFieldsTest` (8) + `ProductPhase51Test` (5) grün |
|
||||||
|
|
||||||
|
**Status Roadmap:** AP-00, AP-01, AP-04, AP-05, AP-06 (inkl. Nachtrag) erledigt; **AP-07 erledigt** (INCI: Lieferanten-Mehrfachwahl, UST-Satz, eigene Lieferzeit inkl. Tage-Autofill, `ingredient_supplier`-Pivot; O1 `IngredientController` auf FormRequest umgestellt) inkl. **AP-07.1** (Lieferanten-Detailansicht im Modal mit pflegbaren INCI-/Verpackungs-Listen); **AP-08 erledigt** (Einkauf: UST-Snapshot, Netto/Brutto-Automatik, Duplizieren).
|
||||||
|
|
||||||
|
> **Alle Klärungspunkte aus §5 sind beantwortet** (Kunde, 02.06.2026) und in die jeweiligen APs eingearbeitet — keine Blocker mehr offen.
|
||||||
|
>
|
||||||
|
> **➡️ NÄCHSTER SCHRITT: AP-09 (Produktion korrigieren).** Konkret: (1) Produktion **ausschließlich** auf Hersteller-Rezeptur umstellen + **Warnung**, wenn keine gepflegt ist (kein Fallback); (2) Chargen-Dropdown-Label + nur Chargen mit Restbestand; (3) B3 „Weitere Charge"-JS-Fix (genau eine Zeile); (4) Soll-Neuberechnung ohne Überschreiben manueller Eingaben; (5) B4 iPad-Layout der Kopfdaten; (6) Produktentwicklung-Platzhalterseite („Briefing ausstehend"). Danach AP-02/AP-03, dann die großen Übersichten. **Neu:** AP-18 (Hinweise-Doku unter Einstellungen) kann jederzeit dazwischengezogen werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Verifizierter Ist-Stand (02.06.2026)
|
||||||
|
|
||||||
|
### Umgesetzt und im Code vorhanden
|
||||||
|
|
||||||
|
| Bereich | Status | Belegt durch |
|
||||||
|
|---|---|---|
|
||||||
|
| Produktmanagement (Produkte, INCIs, Kategorien, Attribute) | vorhanden | `app/Http/Controllers/ProductController.php`, `IngredientController.php` |
|
||||||
|
| Rezeptur + Hersteller-Rezeptur (Prozent, Faktor, 100%-Summe) | vorhanden | `product_ingredients.recipe_type`, `Product::p_ingredients()` / `manufacturer_ingredients()` |
|
||||||
|
| Haltbarkeit am Produkt (PAO / festes MHD) | vorhanden | `products.shelf_life_type`, `shelf_life_months` |
|
||||||
|
| Stammdaten (Lagerorte, Lieferanten, Kategorien, Rohstoffqualität, Verpackungsmaterial, Produkt-/Versandverpackung) | vorhanden | `app/Http/Controllers/Admin/Inventory/*`, Migrationen `2026_03_27_*` |
|
||||||
|
| INCI mit Rohstoffqualität | vorhanden | `ingredients.material_quality_id` |
|
||||||
|
| Verpackung & Material am Produkt (BOM) | vorhanden | `product_packagings`, `Product::packagings()` |
|
||||||
|
| Einkauf & Wareneingang (zweistufig pending → received, Charge, MHD) | vorhanden | `stock_entries`, `StockEntryController`, `ReceiveStockEntryRequest` |
|
||||||
|
| Produktion (Chargen-Zuordnung, Soll-Verbrauch, MHD-Warnung, Packaging-Snapshot, edit/copy) | vorhanden | `ProductionService`, `ProductionController`, `production_*`-Tabellen |
|
||||||
|
| Tests Phase 0–5 | vorhanden | `tests/Feature/ProductPhase0/1/4Test.php`, `InventoryPhase2/3Test.php`, `ProductionPhase5Test.php` |
|
||||||
|
|
||||||
|
### Noch NICHT im Code vorhanden (entgegen Eindruck aus V3.0-Lesart)
|
||||||
|
|
||||||
|
- **Phase 5.2 ist vollständig offen** — keine der dort beschriebenen DB-Strukturen existiert:
|
||||||
|
- kein `tax_rates` / `tax_rate_id` / `tax_rate_percent`
|
||||||
|
- kein `is_set`, `main_product_id`, `product_set_items`
|
||||||
|
- kein `order_method` / `order_email` / `order_url` / `delivery_time` an `suppliers`
|
||||||
|
- kein `ingredient_supplier`-Pivot, kein `tax_rate`/`delivery_time` an `ingredients`
|
||||||
|
- kein `price_per_kg_net` / `price_per_kg_gross` an `stock_entries`
|
||||||
|
- kein `out_of_stock_until` an `products`
|
||||||
|
- kein `product_stock_movements`, kein `InventoryService`, keine Bestandsseiten
|
||||||
|
- **Produktion basiert noch auf `p_ingredients` (Produkt-Rezeptur), nicht auf der Hersteller-Rezeptur** (`ProductionService::store()` und `buildRecipePayload()` laden `p_ingredients`). Briefing fordert Hersteller-Rezeptur als Basis → offen.
|
||||||
|
- **Kein Rohstoffbestand / Produktbestand / Historie**, kein Ausgang/Ausschuss, kein Audit-Trail, kein 2FA, keine blockbasierten Rechte, keine Warenwirtschafts-Einstellungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Gefundene Bugs & Optimierungen (sofort vor Feature-Arbeit)
|
||||||
|
|
||||||
|
Diese Punkte sind klein, konkret und blockieren teils die tägliche Nutzung. Sie werden als **Phase 5.1.x Nachzügler** vorgezogen.
|
||||||
|
|
||||||
|
### B1 — Lieferanten-URL: Speichern schlägt fehl, wenn URL ausgefüllt ist (umgekehrtes Verhalten)
|
||||||
|
- **Symptom (Kunde):** „Neuer Lieferant: sagt ‚URL eingeben‘, obwohl eine drinsteht. Nehme ich sie raus, geht das Abspeichern."
|
||||||
|
- **Ursache:** `resources/views/admin/inventory/suppliers/form.blade.php` Zeile 56 nutzt `<input type="url">`. Die native Browser-Validierung lehnt eine ausgefüllte, aber nicht streng schema-konforme URL (z. B. ohne `https://` oder mit kodierten Parametern) ab; ein **leeres** Feld ist gültig → exakt das gemeldete „umgekehrte" Verhalten. Die Server-Validierung ist bereits korrekt (`nullable|string|max:2048`).
|
||||||
|
- **Fix:** `type="url"` → `type="text"` (Server validiert ohnehin als String). Damit werden auch Konfigurator-URLs mit Parametern akzeptiert (siehe B2).
|
||||||
|
- **Aufwand:** ~15 Min.
|
||||||
|
|
||||||
|
### B2 — URL-Felder müssen Konfigurator-URLs mit Parametern akzeptieren
|
||||||
|
- **Anforderung (Todos Z. 1–2):** URLs wie
|
||||||
|
`https://www.kartonsaufmass.de/bestellen?bom_configuration=%7B%2522length%2522:125,...%7D`
|
||||||
|
müssen gespeichert werden können (Versandverpackungs-Konfiguratoren).
|
||||||
|
- **Status (verifiziert):** Lieferanten-URL ist bereits `nullable|string|max:2048` (ok, mit B1-Fix vollständig). **`PackagingItem` ist NICHT ok:** `Store/UpdatePackagingItemRequest` haben `['nullable','url','max:500']` — die `url`-Regel lehnt kodierte Konfigurator-URLs ab und `max:500` ist für lange Konfigurator-Links zu kurz. → auf `['nullable','string','max:2048']` ändern und Blade-Input auf `type="text"`. Konsistent für alle URL-Felder im Warenwirtschaftsmodul.
|
||||||
|
- **Aufwand:** ~30 Min.
|
||||||
|
|
||||||
|
### B3 — „Weitere Charge": es erscheinen zwei Felder statt einem
|
||||||
|
- **Anforderung (Todos Z. 14, Briefing 5.2.6):** Klick auf „Weitere Charge" soll genau **eine** neue Zeile/Dropdown hinzufügen.
|
||||||
|
- **Status:** JS-Fehler in der Produktions-Create/Edit-View (Chargen-Splitting). Wird in **AP-09** (Produktionskorrekturen) sauber behoben, da es mit der Soll-Neuberechnung zusammenhängt.
|
||||||
|
|
||||||
|
### B4 — iPad: Produktionsdatum und Stückzahl überlappen grafisch
|
||||||
|
- **Anforderung (Todos Z. 86, Briefing 5.2.6 D):** Responsive Grid der Kopfdaten in `productions/create.blade.php` / `edit.blade.php` reparieren. → Teil von **AP-09**.
|
||||||
|
|
||||||
|
### B5 — Tabellen-Aktionsicons (Auge/Stift/Mülleimer) zu klein/zu eng (iPad)
|
||||||
|
- **Anforderung (Todos Z. 36, Briefing 5.2.3 C):** Betrifft **alle** Tabellen im Modul. Eine gemeinsame CSS-Utility-Klasse (z. B. `.wawi-actions` mit größeren Touch-Targets + Abstand) einführen und in allen `index.blade.php` anwenden. → **AP-04** (Querschnitt, früh, weil überall sichtbar).
|
||||||
|
|
||||||
|
### Optimierungen (Konsistenz/Sauberkeit)
|
||||||
|
- **O1:** `IngredientController` nutzt noch `Request::all()` statt FormRequest → bei INCI-Erweiterung (AP-07) auf `StoreIngredientRequest`/`UpdateIngredientRequest` umstellen.
|
||||||
|
- **O2:** Offene Tests aus Phase 5.1 (Menü-Labels, INCI-Qualität, Prozent-Rezeptur, 100%-Summe, Hersteller-Rezeptur, Produktion edit/copy, nur aktive Produkte) sind im Plan als `[ ]` markiert, aber Features sind umgesetzt → **AP-00** schreibt diese Tests nach, um eine grüne Regressionsbasis zu haben, bevor 5.2 beginnt.
|
||||||
|
- **O3:** Steuerart als **Enum, später änderbar** gewünscht (feedback/Briefing INCI B). Lösung: konfigurierbare `tax_rates`-Stammdaten statt Hardcode-Enum (AP-05).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Priorisierte Roadmap (Phasenüberblick)
|
||||||
|
|
||||||
|
| Reihenfolge | AP | Titel | Abhängigkeit | Aufwand |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | AP-00 | Regressionsbasis: offene 5.1-Tests nachziehen | – | 1 Tag |
|
||||||
|
| 2 | AP-01 | Quick-Fixes B1/B2 (URL-Felder) | – | 0,5 Tag |
|
||||||
|
| 3 | AP-04 | Querschnitt: iPad-taugliche Tabellen-Aktionen (B5) | – | 0,5–1 Tag |
|
||||||
|
| 4 | AP-05 | Einstellungen: UST-Sätze & Lieferzeiten (Stammdaten) | – | 1–2 Tage |
|
||||||
|
| 5 | AP-06 | Lieferanten erweitern (Bestellweg, Lieferzeit) | AP-05 | 1–2 Tage |
|
||||||
|
| 6 | AP-07 | INCI erweitern (Lieferanten-Mehrfachwahl, UST, Lieferzeit) | AP-05, AP-06 | 2–3 Tage |
|
||||||
|
| 7 | AP-08 | Einkauf erweitern (UST, Netto/Brutto, Duplizieren) | AP-05 | 2–3 Tage |
|
||||||
|
| 8 | AP-09 | Produktion korrigieren (Hersteller-Rezeptur, Charge-JS, iPad, Produktentwicklung-Platzhalter) | – | 2–4 Tage |
|
||||||
|
| 9 | AP-02 | Produkt-Klassen: Einzelprodukt vs. Set + Hauptprodukt | – | 3–5 Tage |
|
||||||
|
| 10 | AP-03 | „Nicht vorrätig" mit Zeitangabe | – | 1–2 Tage |
|
||||||
|
| 11 | AP-10 | Rohstoffbestand (InventoryService + Übersicht) | AP-06, AP-07, AP-09 | 4–6 Tage |
|
||||||
|
| 12 | AP-11 | Produktbestand + Historie + manuelle Bewegungen | AP-02, AP-10 | 5–8 Tage |
|
||||||
|
| 13 | AP-12 | Ausgang/Ausschuss (Rohstoffe/Verpackung) | AP-10 | 2–3 Tage |
|
||||||
|
| 14 | AP-13 | Shop-Anbindung: Bestand bei Verkauf reduzieren (inkl. Sets) | AP-02, AP-11 | 3–5 Tage |
|
||||||
|
| 15 | AP-14 | Audit-Trail (inventory_logs) | AP-10–13 | 2–3 Tage |
|
||||||
|
| 16 | AP-15 | Blockbasierte Rechte | AP-05+ | 5–8 Tage |
|
||||||
|
| 17 | AP-16 | 2FA Google Authenticator für Admins | – | 3–5 Tage |
|
||||||
|
| 18 | AP-17 | Warenwirtschafts-Einstellungen (Alarm-Mail, Default-Lager, Schwellwerte) | AP-10/11 | 1–2 Tage |
|
||||||
|
| 19 | AP-18 | Hinweise-/Doku-Seite (Einstellungen → Hinweise, MD-basiert) | – | 0,5 Tag |
|
||||||
|
|
||||||
|
> **Leitplanke:** AP-00 bis AP-09 sind „Korrektur & Datenmodell-Vorbereitung". Erst danach werden die großen Übersichten (Rohstoff-/Produktbestand) gebaut, weil sie auf den neuen Stammdatenfeldern aufsetzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Arbeitspakete im Detail
|
||||||
|
|
||||||
|
### AP-00 — Regressionsbasis: offene 5.1-Tests nachziehen
|
||||||
|
**Ziel:** Grüne Test-Suite als Sicherheitsnetz, bevor 5.2 beginnt.
|
||||||
|
|
||||||
|
**Schritte**
|
||||||
|
- Pest-Feature-Tests ergänzen (`php artisan make:test --pest <Name>`):
|
||||||
|
- Menü-Labels (Rohstoffqualität, Verpackungsmaterial, Produkt-/Versandverpackung).
|
||||||
|
- INCI mit `material_quality_id` speichern + Anzeige im Produktformular-Katalog.
|
||||||
|
- Rezeptur in Prozent (3 Nachkomma) speichern; 100%-Summen-Validierung (grün/rot).
|
||||||
|
- Hersteller-Rezeptur getrennt speichern (`recipe_type=manufacturer`).
|
||||||
|
- Produktion `edit`/`update` und `copy`.
|
||||||
|
- Nur aktive Produkte im Produktions-Dropdown.
|
||||||
|
|
||||||
|
**Akzeptanz:** `php artisan test` läuft vollständig grün; neue Tests decken die genannten Features ab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-01 — Quick-Fixes URL-Felder (B1 + B2)
|
||||||
|
**Schritte**
|
||||||
|
- `resources/views/admin/inventory/suppliers/form.blade.php`: `type="url"` → `type="text"` (Zeile ~56).
|
||||||
|
- URL-Validierung aller Warenwirtschafts-FormRequests prüfen (Supplier ist ok). `Store/UpdatePackagingItemRequest`: falls `url`-Regel → auf `['nullable','string','max:2048']` ändern; zugehöriges Blade-Input auf `type="text"`.
|
||||||
|
- `vendor/bin/pint --dirty`.
|
||||||
|
|
||||||
|
**Akzeptanz**
|
||||||
|
- Lieferant mit ausgefüllter URL (auch ohne `https://` und mit kodierten Parametern) speichert ohne Fehler.
|
||||||
|
- Konfigurator-URL aus Todos Z. 2 wird unverändert gespeichert.
|
||||||
|
|
||||||
|
**Tests:** Feature-Test „Supplier mit Parameter-URL speichern", „PackagingItem mit Parameter-URL speichern".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-04 — Querschnitt: iPad-taugliche Tabellen-Aktionen (B5)
|
||||||
|
**Schritte**
|
||||||
|
- Gemeinsame CSS-Klasse `.wawi-actions` (größere Buttons, mehr Abstand, Touch-Target ≥ 44px) in vorhandenes Admin-CSS aufnehmen (Laravel Mix; danach `npm run dev`/`prod`).
|
||||||
|
- In allen `resources/views/admin/inventory/**/index.blade.php` die Aktionsspalte (Auge/Stift/Mülleimer) auf die Klasse umstellen.
|
||||||
|
|
||||||
|
**Akzeptanz:** Aktionen sind auf dem iPad gut und einzeln klickbar; Optik in allen Modul-Tabellen konsistent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-05 — Einstellungen: UST-Sätze & Lieferzeiten
|
||||||
|
**Ziel:** Konfigurierbare Steuersätze und Lieferzeit-Vorlagen als Stammdaten (Basis für AP-06/07/08).
|
||||||
|
|
||||||
|
**DB**
|
||||||
|
- `tax_rates`: `name` (z. B. „Standard"), `percent` DECIMAL(5,2), `active` bool, `pos`. Seeder: 19,00 / 7,00 / 0,00.
|
||||||
|
- `delivery_times`: `label` VARCHAR (Freitext, z. B. „3–5 Werktage"), `days` (ganze Tage bis Wareneingang, optional – Basis für „rechtzeitig bestellen"-Ableitung), `active`, `pos`.
|
||||||
|
|
||||||
|
**Code**
|
||||||
|
- Models `TaxRate`, `DeliveryTime` (`make:model -mf`), CRUD-Controller unter `Admin/Inventory/`, FormRequests, Views `index`+`form`, Routen unter `admin/inventory` (`superadmin`), Sidenav-Einträge.
|
||||||
|
|
||||||
|
**Akzeptanz:** SuperAdmin pflegt UST-Sätze und Lieferzeiten; nur aktive Sätze sind in Dropdowns wählbar; historische (deaktivierte) Sätze bleiben referenzierbar.
|
||||||
|
|
||||||
|
> **Entscheidung (O3):** UST als Stammdaten-Tabelle statt PHP-Enum, weil „später änderbar" gefordert ist und historische Werte erhalten bleiben müssen.
|
||||||
|
|
||||||
|
> **Umgesetzte Struktur (Kunde, 02.06.2026):** Unter **Warenwirtschaft → Einstellungen** neuer Unterpunkt **„Allgemein"** als Sammelseite für kleinteilige Einstellungen. Sektion 1 = Umsatzsteuersätze, Sektion 2 = Lieferzeit-Vorlagen (jeweils Tabelle, neue Einträge jederzeit ergänzbar). Weitere kleinteilige Einstellungen (Default-Werte etc.) werden später als zusätzliche Karten auf derselben „Allgemein"-Seite ergänzt.
|
||||||
|
>
|
||||||
|
> **Status:** Erledigt — Teil 1 (`tax_rates`) und Teil 2 (`delivery_times`) als CRUD unter „Allgemein".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-06 — Lieferanten erweitern
|
||||||
|
**DB (`suppliers`)**
|
||||||
|
- `order_method` ENUM(`email`,`online_shop`) nullable.
|
||||||
|
- `order_email` nullable (falls abweichend von `email`).
|
||||||
|
- `order_url` nullable (falls abweichend von `url`).
|
||||||
|
- `delivery_time` VARCHAR nullable (Freitext; optional Verknüpfung mit `delivery_times` als Vorlage, aber Freitext bleibt führend).
|
||||||
|
|
||||||
|
**Code**
|
||||||
|
- Migration + `Supplier` fillable/casts; `Store/UpdateSupplierRequest` erweitern; `suppliers/form.blade.php`: Radio/Select Bestellweg + bedingte Felder + Lieferzeit-Textfeld (mit Vorlagen-Datalist aus `delivery_times`).
|
||||||
|
|
||||||
|
**Akzeptanz:** Pro Lieferant ist Bestellweg + Ziel (Mail/Shop) + Lieferzeit hinterlegt und editierbar; Daten stehen später dem Rohstoffbestand für Bestell-Links zur Verfügung.
|
||||||
|
|
||||||
|
> **Status:** Erledigt (02.06.2026). Migration `2026_06_02_154755_add_order_fields_to_suppliers_table` (`order_method`, `order_email`, `order_url`, `delivery_time`). Formular mit Bestellweg-Select, JS-gesteuerten bedingten Feldern (E-Mail vs. URL) und Lieferzeit-Datalist aus aktiven `delivery_times`. Freitext bleibt führend, Vorlagen sind nur Eingabehilfe. Tests: `tests/Feature/SupplierOrderFieldsTest.php`.
|
||||||
|
>
|
||||||
|
> **Nachtrag (02.06.2026, Kunde):** Lieferzeit ist jetzt zusätzlich als fester Tageswert auswertbar. Lieferzeit-Vorlagen haben Feld `days` (ganze Tage). Lieferant hat `delivery_time_days` (`2026_06_02_160411_*`). Beim Auswählen einer Vorlage im Lieferzeit-Feld setzt JS automatisch den Tageswert (manuell überschreibbar). Dieser Tageswert ist die Grundlage, um später Rohstoffe rechtzeitig vor MHD/Bedarf zu bestellen. Gleiche Auto-Befüllung wird in AP-07 (INCI) übernommen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-07 — INCI erweitern
|
||||||
|
**DB**
|
||||||
|
- Pivot `ingredient_supplier`: `ingredient_id`, `supplier_id`, optional `preferred` bool, `supplier_sku`, `url`.
|
||||||
|
- `ingredients`: `tax_rate_id` nullable FK, `delivery_time` VARCHAR nullable.
|
||||||
|
|
||||||
|
**Code**
|
||||||
|
- `Ingredient`: `suppliers()` belongsToMany, `taxRate()` belongsTo; fillable/casts.
|
||||||
|
- **O1:** `IngredientController` auf `StoreIngredientRequest`/`UpdateIngredientRequest` umstellen (Ersatz für `Request::all()`).
|
||||||
|
- `admin/ingredient/form.blade.php`: Select2-Mehrfachauswahl Lieferanten, UST-Dropdown (aktive `tax_rates`), Lieferzeit-Textfeld.
|
||||||
|
|
||||||
|
**Lieferzeit-Logik:** INCI-Lieferzeit überschreibt Lieferanten-Lieferzeit (Auswertung erst im Rohstoffbestand AP-10).
|
||||||
|
|
||||||
|
**Akzeptanz:** INCI kann mehrere Lieferanten, einen UST-Satz und eine eigene Lieferzeit haben; alles wird gespeichert und angezeigt.
|
||||||
|
|
||||||
|
> **Status:** Erledigt (02.06.2026). Migrationen `2026_06_02_161237_add_order_fields_to_ingredients_table` (`tax_rate_id` FK, `delivery_time`, `delivery_time_days`) + `2026_06_02_161237_create_ingredient_supplier_table` (Pivot mit `preferred`/`supplier_sku`/`url`). `Ingredient`: `taxRate()` belongsTo, `suppliers()` belongsToMany (mit Pivot-Feldern), cast `delivery_time_days`. **O1 umgesetzt:** `IngredientController` nutzt jetzt `StoreIngredientRequest` (statt `Request::all()`) und synct Lieferanten via `suppliers()->sync()`. Formular: UST-Dropdown, Select2-Lieferanten-Mehrfachwahl, Lieferzeit-Textfeld mit `data-days`-Datalist + Tage-Feld inkl. JS-Autofill (manuell überschreibbar). Der bestehende Single-Endpoint (`admin_product_ingredient_store` für Neu+Update) wurde beibehalten, daher genügt ein FormRequest. Pivot-Zusatzfelder (`preferred`/`supplier_sku`/`url`) sind im Schema vorbereitet, das Formular synct vorerst nur die Lieferanten-Zuordnung. Tests: `tests/Feature/IngredientOrderFieldsTest.php` (6 grün).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-08 — Einkauf erweitern
|
||||||
|
**DB (`stock_entries`)**
|
||||||
|
- `tax_rate_id` nullable FK + Snapshot `tax_rate_percent` DECIMAL(5,2) (für historische Korrektheit).
|
||||||
|
- `price_per_kg_net` DECIMAL(10,4) nullable, `price_per_kg_gross` DECIMAL(10,4) nullable.
|
||||||
|
|
||||||
|
**Code**
|
||||||
|
- Migration + `StockEntry` fillable/casts; `Store/UpdateStockEntryRequest` erweitern (genau eines von Netto/Brutto verpflichtend bei Rohstoff).
|
||||||
|
- `stock-entries/_form.blade.php` + `_scripts.blade.php`: UST-Dropdown; JS berechnet Netto↔Brutto gegenseitig beim Eintragen/UST-Wechsel (einheitliche Rundung).
|
||||||
|
- **Duplizieren:** Route `GET stock-entries/{stock_entry}/copy` + `StockEntryController@copy`: dupliziert Stufe-1-Felder, setzt `status=pending`, lässt Charge/MHD/Eingangsdaten leer.
|
||||||
|
|
||||||
|
**Akzeptanz**
|
||||||
|
- Einkauf mit Netto **oder** Brutto anlegbar; Gegenfeld wird automatisch korrekt berechnet.
|
||||||
|
- UST-Wechsel aktualisiert das Gegenfeld.
|
||||||
|
- Ausgefüllter Einkauf für weitere Kanister/Chargen mit einem Klick duplizierbar.
|
||||||
|
|
||||||
|
**Tests:** Netto→Brutto-Berechnung, Brutto→Netto, Duplizieren erzeugt `pending`-Kopie ohne Chargendaten.
|
||||||
|
|
||||||
|
> **Status:** Erledigt (02.06.2026). Migration `2026_06_02_181548_add_price_fields_to_stock_entries_table` (`price_per_kg_gross`, `tax_rate_id`, `tax_rate_percent`). Das bereits vorhandene `price_per_kg` dient als Netto-Feld (`price_per_kg_net`), ergänzt um `price_per_kg_gross`; bewusst kein Rename, um Bestandsdaten/Factory/Tests stabil zu halten. Netto/Brutto-Umrechnung zentral in `StockEntryRepository::resolvePrices()` (UST-Prozent-Snapshot, fehlender Wert wird berechnet), live im Formular via JS. Duplizieren über `stock-entries/{id}/copy` legt direkt eine `pending`-Kopie der Stufe-1-Felder an. Verpackungspreis bleibt Netto-Gesamt ohne UST/Brutto (außerhalb des Plan-Scopes, kann später ergänzt werden). Tests: `tests/Feature/StockEntryPriceTest.php` (6 grün).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-09 — Produktion korrigieren
|
||||||
|
**Ziel:** Produktion auf Hersteller-Rezeptur stellen, JS-/iPad-Fehler beheben, Platzhalter Produktentwicklung.
|
||||||
|
|
||||||
|
**Code**
|
||||||
|
- **Basis Hersteller-Rezeptur:** `ProductionService::store()`, `updateProduction()`, `requiredGramsByIngredient()`, `buildRecipePayload()` von `p_ingredients` auf `manufacturer_ingredients` umstellen (Pivot `gram`/`factor` analog). `ProductionController::recipeJson()` entsprechend.
|
||||||
|
> **Entscheidung (§5.1, geklärt):** Produktion nutzt **ausschließlich** die Hersteller-Rezeptur. **Kein Fallback** auf die Produkt-Rezeptur. Ist für das gewählte Produkt **keine Hersteller-Rezeptur** gepflegt, muss im Produktions-Formular direkt eine **deutliche Warnung** erscheinen (kein stilles Laden der Produkt-Rezeptur, Produktion ohne Hersteller-Rezeptur blockieren bzw. unmissverständlich warnen).
|
||||||
|
- **Chargen-Dropdown-Label:** `Lieferant - Chargennr. - dd.mm.yyyy` (kein „MHD"-Text). Nur Chargen mit **Restbestand > 0** anzeigen (Restbestand = `received_quantity` − bereits in `production_ingredients` verbrauchte Menge dieser Charge). Erfordert Verbrauchsabfrage je `stock_entry_id`.
|
||||||
|
- **B3 JS-Fix:** „Weitere Charge" fügt genau **eine** Zeile/ein Dropdown hinzu.
|
||||||
|
- **Soll-Neuberechnung stabil:** Ändert sich oben die Stückzahl, bleiben bereits eingetragene Chargen/Ist-Mengen erhalten; nur Soll-Gramm werden neu berechnet (keine Überschreibung manueller Eingaben).
|
||||||
|
- **UI vereinfachen:** Spaltenüberschriften „Charge"/„Menge" pro Rohstoffzeile entfernen; `g` hinter Mengen; weniger Linien.
|
||||||
|
- **B4 iPad-Fix:** Bootstrap-Grid der Kopfdaten (Produktionsdatum / Stückzahl) responsive ohne Überlappung.
|
||||||
|
- **Produktentwicklung-Platzhalter (§5.5, geklärt):** Sidenav-Unterpunkt unter „Produktion"; Route + simple View mit Hinweistext, dass hier **noch ein genaues Briefing aussteht** (keine Bestandsbuchung, keine Logik).
|
||||||
|
|
||||||
|
**Akzeptanz:** Produktion rechnet auf Basis Hersteller-Rezeptur; **fehlt diese, erscheint eine Warnung** (kein Fallback); Chargenliste zeigt nur verfügbare Chargen im geforderten Label; „Weitere Charge" erzeugt eine Zeile; Stückzahländerung zerstört keine Eingaben; iPad-Layout sauber; Menüpunkt Produktentwicklung mit „Briefing ausstehend"-Hinweis sichtbar.
|
||||||
|
|
||||||
|
**Tests:** Soll-Verbrauch aus Hersteller-Rezeptur; **Warnung bei fehlender Hersteller-Rezeptur**; Charge ohne Restbestand erscheint nicht; Service-Berechnung bei Stückzahländerung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-02 — Produkt-Klassen: Einzelprodukt vs. Set + Hauptprodukt
|
||||||
|
> **Entscheidung (§5.6, geklärt):** Echte Sets via Pivot (`product_set_items`) — mehrere Einzelprodukte bündelbar, nicht nur „genau ein Hauptprodukt".
|
||||||
|
|
||||||
|
**DB (`products`)**
|
||||||
|
- `is_set` bool default 0.
|
||||||
|
- `main_product_id` nullable FK auf `products` (Child→Hauptprodukt).
|
||||||
|
- `main_product_quantity` UINT nullable (z. B. 50 für „50 × 15 ml").
|
||||||
|
- Pivot `product_set_items`: `set_product_id`, `component_product_id`, `quantity`.
|
||||||
|
|
||||||
|
**Code**
|
||||||
|
- `Product`: `setItems()`, `mainProduct()`, Scopes `mainProducts()` / `singleProducts()`.
|
||||||
|
- Produktformular: Checkbox „Ist Set"; bei aktiv Karten Rezeptur/Verpackung/Warenwirtschaft ausblenden, Karte „Set-Bestandteile" einblenden (Modal wie Rezeptur, nur Einzelprodukte wählbar, mit Menge).
|
||||||
|
- Validierung: Set enthält nur Einzelprodukte (keine Sets), mind. 1 Bestandteil; Einzelprodukt darf Rezeptur/Packaging/Warenwirtschaft pflegen.
|
||||||
|
|
||||||
|
**Akzeptanz:** Sets bestehen aus Einzelprodukten mit Menge; Sets sind nicht produzierbar; Produktbestand (AP-11) zeigt nur Haupt-/Einzelprodukte; Set-Verkauf reduziert später die enthaltenen Einzelprodukte (AP-13).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-03 — „Nicht vorrätig" mit Zeitangabe
|
||||||
|
**DB (`products`)**
|
||||||
|
- `out_of_stock_until` DATE nullable (Empfehlung: aus Tagen berechnet, sauber für Resttage).
|
||||||
|
- `out_of_stock_indefinite` bool default 0 (zweites Kästchen „auf unbestimmte Zeit vergriffen", ohne Tagefeld).
|
||||||
|
|
||||||
|
**Code**
|
||||||
|
- Produktformular: Checkbox „Nicht vorrätig" + Tagefeld → `out_of_stock_until = now()->addDays($tage)`; zweite Checkbox „unbestimmt".
|
||||||
|
- Shop-/Bestellansicht: bei `out_of_stock_until` in der Zukunft Hinweis „In ca. X Tagen wieder da!" (Resttage dynamisch); bei `indefinite` entsprechender Dauerhinweis.
|
||||||
|
|
||||||
|
**Entscheidung (§5.3, geklärt):** Vorerst **nur Hinweis**, der Kauf bleibt möglich. In der Hinweise-Doku (AP-18) ist zu dokumentieren, dass **künftig optional eine Kauf-Sperre** ergänzt werden kann/muss.
|
||||||
|
|
||||||
|
**Akzeptanz:** Produkt zeitweise/unbefristet als nicht vorrätig markierbar; Resttage zählen automatisch herunter; nach Ablauf verschwindet der Hinweis ohne manuelles Zutun.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-10 — Rohstoffbestand (InventoryService + Übersicht)
|
||||||
|
**Code**
|
||||||
|
- `app/Services/InventoryService.php`: Restbestand je Rohstoff/Charge/Lagerort = `SUM(received_quantity)` − `SUM(production_ingredients.quantity_used)` − `SUM(stock_disposals.quantity)` (Ausgang ab AP-12).
|
||||||
|
- Controller + View „Rohstoffbestand" (Sidenav-Menüpunkt). Spalten: INCI/Rohstoff, Qualität, Gesamtbestand, Bestand je Lagerort (dynamisch aus `locations`), verbraucht/Produktion, Meldebestand/Bedarf, Status, Lieferanten, Lieferzeit (INCI vor Lieferant), Bestellaktion (`mailto:`/Shop-Link je `order_method`).
|
||||||
|
- Nur Chargen mit Restbestand > 0 einbeziehen; kritische Rohstoffe visuell markieren.
|
||||||
|
|
||||||
|
**Akzeptanz:** Reale Restbestände sichtbar; Bestellweg direkt aus der Übersicht erreichbar; kritische Rohstoffe hervorgehoben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-11 — Produktbestand + Historie
|
||||||
|
**DB**
|
||||||
|
- `product_stock_movements`: `product_id`, `direction` ENUM(`in`,`out`), `quantity`, `reason`, `source` (produktion/verkauf/manuell/set), `user_id`, `created_at`, `reference_type`/`reference_id` (polymorph, nullable).
|
||||||
|
- Schwellwerte: Felder an `products` (`min_product_stock`, `critical_product_stock`) oder eigene Tabelle.
|
||||||
|
- **Initialisierung (Briefing):** Lagerbestand einmalig einpflegbar (Anfangsbestand als `in`-Bewegung mit Grund „Initialbestand").
|
||||||
|
|
||||||
|
**Code**
|
||||||
|
- Bestand = `SUM(in)` − `SUM(out)`. Manuelle Bewegung: Menge + Grund + Richtung Pflicht.
|
||||||
|
- Hauptmenü „Produktbestand" (nur Hauptprodukte, Suche, Checkbox „nur kritische", Buttons `+`/`−`/`Produzieren`, rot/gelb-Markierung) + Untermenü „Historie" (filterbar Produkt/Quelle/Zeitraum/User; revisionssicher, Korrektur nur per Gegenbuchung).
|
||||||
|
|
||||||
|
**Akzeptanz:** Bestand schnell pflegbar; jede Bewegung in der Historie; nur Hauptprodukte sichtbar; Kritisch-Filter funktioniert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-12 — Ausgang / Ausschuss (Rohstoffe/Verpackung)
|
||||||
|
- `stock_disposals` (Typ, Artikel, Charge optional, Lagerort, Menge, Einheit, Grund Pflicht, User, Datum) + Controller/Views; Integration in `InventoryService`.
|
||||||
|
- **Akzeptanz:** Ausgang reduziert Rohstoff-/Verpackungsbestand; Grund ist Pflicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-13 — Shop-Anbindung: Bestand bei Verkauf reduzieren
|
||||||
|
- **Entscheidung (§5.2, geklärt):** Bestandsabzug erfolgt **beim Versand** (erst wenn der Versand gebucht ist, wurde das Produkt real „aus dem Regal" genommen). Dieser Hinweis ist auch in der Hinweise-Doku (AP-18) zu hinterlegen.
|
||||||
|
- Beim Statuswechsel auf **versendet** `product_stock_movements`-`out`-Buchung; bei Sets die enthaltenen Einzelprodukte (× Menge) reduzieren. Stornos/Retouren als Gegenbuchung (Detailregel bei Umsetzung festzurren).
|
||||||
|
- **Akzeptanz:** Versand reduziert Produktbestand; Set-Versand reduziert Einzelprodukte; jede Buchung in der Historie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-14 — Audit-Trail
|
||||||
|
- `inventory_logs` (polymorph) + Observer auf `StockEntry`/`Production`/`StockDisposal`/`ProductStockMovement`.
|
||||||
|
- **Akzeptanz:** Jede Bestandsbewegung wird mit User/Zeit/Änderungen protokolliert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-15 — Blockbasierte Rechte
|
||||||
|
- **Entscheidung (§5.4, geklärt):** Blockrechte gelten **nur für Warenwirtschaft und Produktmanagement**, nicht für alle Admin-Bereiche. In der Hinweise-Doku (AP-18) dokumentieren, dass die Rechte **bei Bedarf später ausgebaut** werden können/müssen, falls sie nicht ausreichen.
|
||||||
|
- `admin_permission_blocks` + `admin_permission_user` (view/edit pro Block: Produkte, Einkauf, Rohstoffbestand, Produktbestand, Produktion, Lieferanten, Einstellungen, Historie); Middleware/Gates; Sidenav zeigt nur erlaubte Blöcke.
|
||||||
|
- Bestehende Level (`copyreader`/`admin`/`superadmin`) bleiben als Grundschutz.
|
||||||
|
- **Akzeptanz:** SuperAdmin vergibt pro Mitarbeiter view/edit je Block (Warenwirtschaft + Produktmanagement); Leserecht ohne Schreibrecht greift; gesperrte Blöcke unsichtbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-16 — 2FA Google Authenticator (Admins)
|
||||||
|
- TOTP-Secret am `App\User` (Guard `user`), Setup-Flow, Login-Zwischenschritt; Recovery-Codes.
|
||||||
|
- **Akzeptanz:** Bei aktivem 2FA kein Zugriff auf geschützte Bereiche ohne Code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-17 — Warenwirtschafts-Einstellungen
|
||||||
|
- Über bestehendes `Setting`-Model: `inventory_alert_email`, `inventory_alert_enabled`, `inventory_default_location`, optional Produktbestands-Schwellwerte, Standardtexte „Nicht vorrätig". SuperAdmin-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AP-18 — Hinweise-/Doku-Seite (Einstellungen → Hinweise)
|
||||||
|
> **Anforderung (§5, Kunde):** Eine als MD gepflegte Doku, die unter **Warenwirtschaft → Einstellungen → „Hinweise"** im Admin sichtbar ist, damit auch der Kunde Einsicht hat.
|
||||||
|
|
||||||
|
**Code**
|
||||||
|
- Markdown-Datei im Repo (z. B. `docs/hinweise.md` oder `resources/docs/hinweise.md`) als Pflege-Quelle.
|
||||||
|
- Route + View unter `admin/inventory` (Einstellungen-Gruppe), die das MD gerendert anzeigt (Parsedown o. Ä.); Sidenav-Eintrag „Hinweise".
|
||||||
|
|
||||||
|
**Inhalt (laufend zu pflegen):**
|
||||||
|
- Kurzer **Entwicklungsstand / Überblick** (was fertig ist, was offen ist).
|
||||||
|
- Wichtige Hinweise & **noch nötige Schritte** verständlich für den Kunden.
|
||||||
|
- Festgehaltene **offene/spätere Entscheidungen**, u. a.:
|
||||||
|
- „Nicht vorrätig" kann künftig optional zur **Kauf-Sperre** ausgebaut werden (§5.3).
|
||||||
|
- **Blockrechte** ggf. später über Warenwirtschaft/Produktmanagement hinaus ausbauen (§5.4).
|
||||||
|
- Shop-Bestandsabzug erfolgt **bei Versand** (§5.2).
|
||||||
|
- **Akzeptanz:** Kunde sieht unter Einstellungen → Hinweise eine lesbare, gepflegte Statusseite.
|
||||||
|
|
||||||
|
> **Empfehlung:** Früh als Platzhalter anlegen und mit jedem AP fortschreiben, damit der Kunde jederzeit den Stand sieht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Klärungspunkte — ALLE GEKLÄRT (Kunde, 02.06.2026)
|
||||||
|
|
||||||
|
> Alle Punkte sind beantwortet und in die jeweiligen Arbeitspakete eingearbeitet. Keine Blocker mehr offen.
|
||||||
|
|
||||||
|
1. **Produktion-Basis** → **Ausschließlich Hersteller-Rezeptur.** Kein Fallback. Ist keine angelegt, erscheint direkt eine Warnung. → eingearbeitet in **AP-09**.
|
||||||
|
|
||||||
|
2. **Shop-Bestandsabzug** → **Beim Versand** (erst mit gebuchtem Versand ist das Produkt real „aus dem Regal"). Als Hinweis dokumentieren. → eingearbeitet in **AP-13** + Hinweis in **AP-18**.
|
||||||
|
|
||||||
|
3. **„Nicht vorrätig"** → Vorerst **nur Hinweis**. Dokumentieren, dass künftig optional eine **Kauf-Sperre** ergänzt werden kann. → eingearbeitet in **AP-03** + Hinweis in **AP-18**.
|
||||||
|
|
||||||
|
4. **Blockrechte-Geltung** → **Nur Warenwirtschaft und Produktmanagement.** Dokumentieren, dass die Rechte bei Bedarf später ausgebaut werden können. → eingearbeitet in **AP-15** + Hinweis in **AP-18**.
|
||||||
|
|
||||||
|
5. **Produktentwicklung** → **Platzhalter-Seite** mit Hinweis, dass ein genaues Briefing noch aussteht. → eingearbeitet in **AP-09**.
|
||||||
|
|
||||||
|
6. **Child-Produkt / Sets** → **Echte Sets via Pivot** (`product_set_items`). → eingearbeitet in **AP-02**.
|
||||||
|
|
||||||
|
7. **Hinweise-Doku (neu):** MD-basierte Doku-Seite unter **Einstellungen → Hinweise** mit Entwicklungsstand, wichtigen Hinweisen und noch nötigen Schritten, einsehbar auch für den Kunden. → neues **AP-18**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Empfohlene Sofort-Reihenfolge (nächste Schritte)
|
||||||
|
|
||||||
|
✅ **Erledigt:** AP-00, AP-01, AP-04 (+ AP-04.1), AP-05, AP-06 (+ Nachtrag), AP-07 (+ AP-07.1), AP-08.
|
||||||
|
|
||||||
|
**➡️ Hier geht es weiter:**
|
||||||
|
1. **AP-09** Produktionskorrekturen: ausschließlich Hersteller-Rezeptur (+ Warnung bei fehlender Rezeptur, kein Fallback), Chargen-Label + Restbestandsfilter, B3 „Weitere Charge"-Fix, stabile Soll-Neuberechnung, B4 iPad-Layout, Produktentwicklung-Platzhalter.
|
||||||
|
2. **AP-18** Hinweise-Doku (Einstellungen → Hinweise) — kann parallel/früh als Platzhalter angelegt und laufend gepflegt werden.
|
||||||
|
3. Datenmodell **AP-02** (Sets via Pivot) / **AP-03** („Nicht vorrätig", nur Hinweis).
|
||||||
|
4. Große Übersichten **AP-10/AP-11** und Folge-APs (AP-12–AP-17).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Pflege dieses Dokuments
|
||||||
|
|
||||||
|
- Jedes abgeschlossene AP hier mit Datum + Kurzbeschreibung + Test-Status protokollieren (analog Umsetzungsprotokoll in `entwicklungsplan.md`).
|
||||||
|
- Bei DB-Änderungen: Migration-Dateinamen referenzieren; bei Modellen Casts in `casts()`-Methode pflegen (L11-Konvention).
|
||||||
|
- Vor jedem Commit: `vendor/bin/pint --dirty` und betroffene Tests (`php artisan test --filter=...`).
|
||||||
|
|
@ -35,3 +35,27 @@
|
||||||
{!! Form::close() !!}
|
{!! Form::close() !!}
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
@section('scripts')
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#supplier_ids').select2({
|
||||||
|
theme: 'default',
|
||||||
|
placeholder: '{{ __('Lieferanten wählen') }}',
|
||||||
|
width: '100%',
|
||||||
|
closeOnSelect: false
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#delivery_time').on('input change', function() {
|
||||||
|
var value = $(this).val();
|
||||||
|
var option = $('#ingredient_delivery_time_options option').filter(function() {
|
||||||
|
return this.value === value;
|
||||||
|
}).first();
|
||||||
|
|
||||||
|
if (option.length && option.data('days') !== undefined && option.data('days') !== '') {
|
||||||
|
$('#delivery_time_days').val(option.data('days'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,56 @@
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group col-sm-4">
|
||||||
|
<label class="form-label" for="tax_rate_id">{{ __('Umsatzsteuer') }}</label>
|
||||||
|
<select name="tax_rate_id" id="tax_rate_id" class="form-control">
|
||||||
|
<option value="">{{ __('— keine Angabe —') }}</option>
|
||||||
|
@foreach($taxRates as $taxRate)
|
||||||
|
<option value="{{ $taxRate->id }}" @selected((string)old('tax_rate_id', $model->tax_rate_id) === (string)$taxRate->id)>{{ $taxRate->name }} ({{ formatNumber($taxRate->percent) }} %)</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$selectedSupplierIds = old(
|
||||||
|
'supplier_ids',
|
||||||
|
$model->exists ? $model->suppliers->pluck('id')->all() : [],
|
||||||
|
);
|
||||||
|
@endphp
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col">
|
||||||
|
<label class="form-label" for="supplier_ids">{{ __('Lieferanten') }}</label>
|
||||||
|
<div class="light-style">
|
||||||
|
<select name="supplier_ids[]" id="supplier_ids" class="w-100" multiple="multiple"
|
||||||
|
data-placeholder="{{ __('Lieferanten wählen') }}">
|
||||||
|
@foreach($suppliers as $supplier)
|
||||||
|
<option value="{{ $supplier->id }}" @selected(in_array($supplier->id, $selectedSupplierIds, false))>{{ $supplier->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col-sm-6">
|
||||||
|
<label class="form-label" for="delivery_time">{{ __('Lieferzeit') }}</label>
|
||||||
|
<input type="text" name="delivery_time" id="delivery_time" list="ingredient_delivery_time_options"
|
||||||
|
class="form-control" value="{{ old('delivery_time', $model->delivery_time) }}"
|
||||||
|
placeholder="{{ __('z. B. 3–5 Werktage') }}">
|
||||||
|
<datalist id="ingredient_delivery_time_options">
|
||||||
|
@foreach($deliveryTimes as $deliveryTime)
|
||||||
|
<option value="{{ $deliveryTime->label }}" data-days="{{ $deliveryTime->days }}"></option>
|
||||||
|
@endforeach
|
||||||
|
</datalist>
|
||||||
|
<small class="text-muted">{{ __('Lieferzeit des Rohstoffs (hat Vorrang vor der Lieferanten-Lieferzeit).') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-sm-2">
|
||||||
|
<label class="form-label" for="delivery_time_days">{{ __('Tage') }}</label>
|
||||||
|
<input type="number" name="delivery_time_days" id="delivery_time_days" min="0" max="65535"
|
||||||
|
class="form-control" value="{{ old('delivery_time_days', $model->delivery_time_days) }}"
|
||||||
|
placeholder="{{ __('z. B. 5') }}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="card">
|
||||||
|
<h6 class="card-header">{{ $model->exists ? __('Lieferzeit-Vorlage bearbeiten') : __('Lieferzeit-Vorlage anlegen') }}</h6>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ $model->exists ? route('admin.inventory.delivery-times.update', $model) : route('admin.inventory.delivery-times.store') }}">
|
||||||
|
@csrf
|
||||||
|
@if($model->exists)
|
||||||
|
@method('PUT')
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="label">{{ __('Bezeichnung') }}</label>
|
||||||
|
<input type="text" name="label" id="label" class="form-control @error('label') is-invalid @enderror"
|
||||||
|
value="{{ old('label', $model->label) }}" placeholder="{{ __('z. B. 3–5 Werktage') }}" required>
|
||||||
|
@error('label')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="days">{{ __('Lieferzeit in Tagen') }}</label>
|
||||||
|
<input type="number" name="days" id="days" min="0" max="65535"
|
||||||
|
class="form-control @error('days') is-invalid @enderror"
|
||||||
|
value="{{ old('days', $model->days) }}" placeholder="{{ __('z. B. 5') }}">
|
||||||
|
<small class="form-text text-muted">{{ __('Ganze Tage bis zum Wareneingang. Wird später für „rechtzeitig bestellen"-Hinweise verwendet.') }}</small>
|
||||||
|
@error('days')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pos">{{ __('Sortierung') }}</label>
|
||||||
|
<input type="number" name="pos" id="pos" min="0" max="255"
|
||||||
|
class="form-control @error('pos') is-invalid @enderror"
|
||||||
|
value="{{ old('pos', $model->pos) }}">
|
||||||
|
@error('pos')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="custom-control custom-checkbox">
|
||||||
|
<input type="checkbox" name="active" value="1" class="custom-control-input"
|
||||||
|
@checked(old('active', $model->active))>
|
||||||
|
<span class="custom-control-label">{{ __('Aktiv') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">{{ __('Speichern') }}</button>
|
||||||
|
<a href="{{ route('admin.inventory.general') }}" class="btn btn-outline-secondary">{{ __('Zurück') }}</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
118
resources/views/admin/inventory/general/index.blade.php
Normal file
118
resources/views/admin/inventory/general/index.blade.php
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@include('admin.inventory.partials.table-actions-style')
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<h6 class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>{{ __('Umsatzsteuersätze') }}</span>
|
||||||
|
<a href="{{ route('admin.inventory.tax-rates.create') }}"
|
||||||
|
class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
||||||
|
</h6>
|
||||||
|
<div class="card-datatable table-responsive">
|
||||||
|
<table class="table table-striped table-bordered wawi-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="max-width: 60px;"> </th>
|
||||||
|
<th>{{ __('Name') }}</th>
|
||||||
|
<th>{{ __('Satz') }}</th>
|
||||||
|
<th>{{ __('Status') }}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($taxRates as $taxRate)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ route('admin.inventory.tax-rates.edit', $taxRate) }}"
|
||||||
|
class="btn icon-btn btn-sm btn-primary">
|
||||||
|
<span class="far fa-edit"></span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ $taxRate->name }}</td>
|
||||||
|
<td>{{ number_format($taxRate->percent, 2, ',', '.') }} %</td>
|
||||||
|
<td>
|
||||||
|
@if ($taxRate->active)
|
||||||
|
<span class="badge badge-pill badge-success"><i class="fa fa-check"></i></span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form action="{{ route('admin.inventory.tax-rates.destroy', $taxRate) }}" method="post"
|
||||||
|
class="d-inline" onsubmit="return confirm('{{ __('Really delete entry?') }}');">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="btn btn-link text-danger p-0"
|
||||||
|
title="{{ __('Delete') }}"><i class="far fa-trash-alt"></i></button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center text-muted">
|
||||||
|
{{ __('Noch keine Umsatzsteuersätze angelegt.') }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h6 class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span>{{ __('Lieferzeit-Vorlagen') }}</span>
|
||||||
|
<a href="{{ route('admin.inventory.delivery-times.create') }}"
|
||||||
|
class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
||||||
|
</h6>
|
||||||
|
<div class="card-datatable table-responsive">
|
||||||
|
<table class="table table-striped table-bordered wawi-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="max-width: 60px;"> </th>
|
||||||
|
<th>{{ __('Bezeichnung') }}</th>
|
||||||
|
<th>{{ __('Tage') }}</th>
|
||||||
|
<th>{{ __('Status') }}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($deliveryTimes as $deliveryTime)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ route('admin.inventory.delivery-times.edit', $deliveryTime) }}"
|
||||||
|
class="btn icon-btn btn-sm btn-primary">
|
||||||
|
<span class="far fa-edit"></span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ $deliveryTime->label }}</td>
|
||||||
|
<td>{{ $deliveryTime->days !== null ? $deliveryTime->days : '–' }}</td>
|
||||||
|
<td>
|
||||||
|
@if ($deliveryTime->active)
|
||||||
|
<span class="badge badge-pill badge-success"><i class="fa fa-check"></i></span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-pill badge-danger"><i class="fa fa-times"></i></span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form action="{{ route('admin.inventory.delivery-times.destroy', $deliveryTime) }}"
|
||||||
|
method="post" class="d-inline"
|
||||||
|
onsubmit="return confirm('{{ __('Really delete entry?') }}');">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="btn btn-link text-danger p-0"
|
||||||
|
title="{{ __('Delete') }}"><i class="far fa-trash-alt"></i></button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center text-muted">
|
||||||
|
{{ __('Noch keine Lieferzeit-Vorlagen angelegt.') }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
@extends('layouts.layout-2')
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@include('admin.inventory.partials.table-actions-style')
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h6 class="card-header d-flex justify-content-between align-items-center">
|
<h6 class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span>{{ __('Lagerorte') }}</span>
|
<span>{{ __('Lagerorte') }}</span>
|
||||||
<a href="{{ route('admin.inventory.locations.create') }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
<a href="{{ route('admin.inventory.locations.create') }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
||||||
</h6>
|
</h6>
|
||||||
<div class="card-datatable table-responsive">
|
<div class="card-datatable table-responsive">
|
||||||
<table class="datatables-style table table-striped table-bordered">
|
<table class="datatables-style table table-striped table-bordered wawi-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="max-width: 60px;"> </th>
|
<th style="max-width: 60px;"> </th>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
@extends('layouts.layout-2')
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@include('admin.inventory.partials.table-actions-style')
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h6 class="card-header d-flex justify-content-between align-items-center">
|
<h6 class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span>{{ __('Rohstoffqualität') }}</span>
|
<span>{{ __('Rohstoffqualität') }}</span>
|
||||||
<a href="{{ route('admin.inventory.material-qualities.create') }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
<a href="{{ route('admin.inventory.material-qualities.create') }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
||||||
</h6>
|
</h6>
|
||||||
<div class="card-datatable table-responsive">
|
<div class="card-datatable table-responsive">
|
||||||
<table class="datatables-style table table-striped table-bordered">
|
<table class="datatables-style table table-striped table-bordered wawi-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="max-width: 60px;"> </th>
|
<th style="max-width: 60px;"> </th>
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="url">{{ __('URL (Onlineshop)') }}</label>
|
<label for="url">{{ __('URL (Onlineshop)') }}</label>
|
||||||
<input type="url" name="url" id="url" class="form-control @error('url') is-invalid @enderror"
|
<input type="text" name="url" id="url" class="form-control @error('url') is-invalid @enderror"
|
||||||
value="{{ old('url', $model->url) }}" placeholder="https://">
|
value="{{ old('url', $model->url) }}" placeholder="https://">
|
||||||
@error('url')
|
@error('url')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
@extends('layouts.layout-2')
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@include('admin.inventory.partials.table-actions-style')
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h6 class="card-header d-flex justify-content-between align-items-center">
|
<h6 class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span>{{ $pageTitle }}</span>
|
<span>{{ $pageTitle }}</span>
|
||||||
<a href="{{ route('admin.inventory.packaging-items.create', ['category' => $category]) }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
<a href="{{ route('admin.inventory.packaging-items.create', ['category' => $category]) }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
||||||
</h6>
|
</h6>
|
||||||
<div class="card-datatable table-responsive">
|
<div class="card-datatable table-responsive">
|
||||||
<table class="datatables-style table table-striped table-bordered">
|
<table class="datatables-style table table-striped table-bordered wawi-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="max-width: 60px;"> </th>
|
<th style="max-width: 60px;"> </th>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
@extends('layouts.layout-2')
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@include('admin.inventory.partials.table-actions-style')
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h6 class="card-header d-flex justify-content-between align-items-center">
|
<h6 class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span>{{ __('Verpackungsmaterial') }}</span>
|
<span>{{ __('Verpackungsmaterial') }}</span>
|
||||||
<a href="{{ route('admin.inventory.packaging-materials.create') }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
<a href="{{ route('admin.inventory.packaging-materials.create') }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
||||||
</h6>
|
</h6>
|
||||||
<div class="card-datatable table-responsive">
|
<div class="card-datatable table-responsive">
|
||||||
<table class="datatables-style table table-striped table-bordered">
|
<table class="datatables-style table table-striped table-bordered wawi-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="max-width: 60px;"> </th>
|
<th style="max-width: 60px;"> </th>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
@once
|
||||||
|
<style>
|
||||||
|
/* AP-04: iPad-taugliche Aktions-Buttons in Warenwirtschafts-Tabellen */
|
||||||
|
.wawi-table1 td .btn {
|
||||||
|
min-width: 42px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0.5rem 0.65rem !important;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0.15rem 0.55rem 0.15rem 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wawi-table1 td .btn:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wawi-table1 td form.d-inline {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wawi-table1 td .btn .far,
|
||||||
|
.wawi-table1 td .btn .fa,
|
||||||
|
.wawi-table1 td .btn span {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.wawi-table td .btn {
|
||||||
|
|
||||||
|
margin: 0.15rem 0.65rem 0.45rem 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endonce
|
||||||
|
|
@ -1,26 +1,38 @@
|
||||||
@extends('layouts.layout-2')
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@include('admin.inventory.partials.table-actions-style')
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h6 class="card-header d-flex justify-content-between align-items-center">
|
<h6 class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span>{{ __('Produktionen') }}</span>
|
<span>{{ __('Produktionen') }}</span>
|
||||||
<a href="{{ route('admin.inventory.productions.create') }}" class="btn btn-sm btn-primary">{{ __('Neue Produktion') }}</a>
|
<a href="{{ route('admin.inventory.productions.create') }}" class="btn btn-sm btn-primary">{{ __('Neue Produktion') }}</a>
|
||||||
</h6>
|
</h6>
|
||||||
<div class="card-datatable table-responsive">
|
<div class="card-datatable table-responsive">
|
||||||
<table class="datatables-style table table-striped table-bordered">
|
<table class="datatables-style table table-striped table-bordered wawi-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="max-width: 130px;"></th>
|
||||||
<th>{{ __('Datum') }}</th>
|
<th>{{ __('Datum') }}</th>
|
||||||
<th>{{ __('Produkt') }}</th>
|
<th>{{ __('Produkt') }}</th>
|
||||||
<th>{{ __('Stück') }}</th>
|
<th>{{ __('Stück') }}</th>
|
||||||
<th>{{ __('Standort') }}</th>
|
<th>{{ __('Standort') }}</th>
|
||||||
<th>{{ __('MHD-Hinweis') }}</th>
|
<th>{{ __('MHD-Hinweis') }}</th>
|
||||||
<th style="max-width: 80px;"></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach($values as $row)
|
@foreach($values as $row)
|
||||||
<tr>
|
<tr>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
<a href="{{ route('admin.inventory.productions.show', $row) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Ansicht') }}">
|
||||||
|
<span class="far fa-eye"></span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.inventory.productions.edit', $row) }}" class="btn icon-btn btn-sm btn-secondary" title="{{ __('Bearbeiten') }}">
|
||||||
|
<span class="far fa-edit"></span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('admin.inventory.productions.copy', $row) }}" class="btn icon-btn btn-sm btn-outline-secondary" title="{{ __('Kopieren') }}">
|
||||||
|
<span class="far fa-copy"></span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td data-sort="{{ $row->produced_at?->timestamp ?? 0 }}">{{ $row->produced_at?->format('d.m.Y') }}</td>
|
<td data-sort="{{ $row->produced_at?->timestamp ?? 0 }}">{{ $row->produced_at?->format('d.m.Y') }}</td>
|
||||||
<td>{{ $row->product?->name ?? '—' }}</td>
|
<td>{{ $row->product?->name ?? '—' }}</td>
|
||||||
<td>{{ $row->quantity }}</td>
|
<td>{{ $row->quantity }}</td>
|
||||||
|
|
@ -32,17 +44,6 @@
|
||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td class="text-nowrap">
|
|
||||||
<a href="{{ route('admin.inventory.productions.show', $row) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Details') }}">
|
|
||||||
<span class="far fa-eye"></span>
|
|
||||||
</a>
|
|
||||||
<a href="{{ route('admin.inventory.productions.edit', $row) }}" class="btn icon-btn btn-sm btn-outline-primary" title="{{ __('Bearbeiten') }}">
|
|
||||||
<span class="far fa-edit"></span>
|
|
||||||
</a>
|
|
||||||
<a href="{{ route('admin.inventory.productions.copy', $row) }}" class="btn icon-btn btn-sm btn-outline-secondary" title="{{ __('Kopieren') }}">
|
|
||||||
<span class="far fa-copy"></span>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
@endforeach
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -54,7 +55,8 @@
|
||||||
$('.datatables-style').dataTable({
|
$('.datatables-style').dataTable({
|
||||||
"bLengthChange": false,
|
"bLengthChange": false,
|
||||||
"iDisplayLength": 100,
|
"iDisplayLength": 100,
|
||||||
"order": [[0, "desc"]],
|
"order": [[1, "desc"]],
|
||||||
|
"columnDefs": [{"orderable": false, "targets": [0]}],
|
||||||
"language": {"url": "/js/German.json"}
|
"language": {"url": "/js/German.json"}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -115,14 +115,51 @@
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="price-per-kg-block" class="form-group" style="display:none;">
|
<div id="price-per-kg-block" style="display:none;">
|
||||||
<label for="price_per_kg">{{ __('Netto-Preis pro kg') }} <span class="text-danger">*</span></label>
|
<div class="form-group">
|
||||||
<input type="text" name="price_per_kg" id="price_per_kg"
|
<label for="tax_rate_id">{{ __('Umsatzsteuer') }}</label>
|
||||||
class="form-control @error('price_per_kg') is-invalid @enderror"
|
<select name="tax_rate_id" id="tax_rate_id" class="form-control @error('tax_rate_id') is-invalid @enderror">
|
||||||
value="{{ old('price_per_kg', $model->price_per_kg !== null ? \App\Services\Util::formatNumber($model->price_per_kg) : '') }}">
|
<option value="" data-percent="0">{{ __('— keine Angabe —') }}</option>
|
||||||
@error('price_per_kg')
|
@foreach ($taxRates as $taxRate)
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<option value="{{ $taxRate->id }}" data-percent="{{ $taxRate->percent }}"
|
||||||
@enderror
|
@selected((string) old('tax_rate_id', $model->tax_rate_id) === (string) $taxRate->id)>
|
||||||
|
{{ $taxRate->name }} ({{ \App\Services\Util::formatNumber($taxRate->percent) }} %)
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<small class="text-muted">{{ __('Steuersatz zur Umrechnung zwischen Netto und Brutto.') }}</small>
|
||||||
|
@error('tax_rate_id')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col-md-6">
|
||||||
|
<label for="price_per_kg">{{ __('Netto-Preis pro kg') }} <span class="text-danger">*</span></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<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) : '') }}">
|
||||||
|
<div class="input-group-append"><span class="input-group-text">€</span></div>
|
||||||
|
@error('price_per_kg')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-md-6">
|
||||||
|
<label for="price_per_kg_gross">{{ __('Brutto-Preis pro kg') }}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" name="price_per_kg_gross" id="price_per_kg_gross"
|
||||||
|
class="form-control @error('price_per_kg_gross') is-invalid @enderror"
|
||||||
|
value="{{ old('price_per_kg_gross', $model->price_per_kg_gross !== null ? \App\Services\Util::formatNumber($model->price_per_kg_gross) : '') }}">
|
||||||
|
<div class="input-group-append"><span class="input-group-text">€</span></div>
|
||||||
|
@error('price_per_kg_gross')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block mb-2">{{ __('Netto oder Brutto genügt – der jeweils andere Wert wird automatisch berechnet.') }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="price-total-block" class="form-group" style="display:none;">
|
<div id="price-total-block" class="form-group" style="display:none;">
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,43 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseNumber(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var normalized = String(value).trim().replace(/\./g, '').replace(',', '.');
|
||||||
|
if (normalized === '' || isNaN(normalized)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parseFloat(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return value.toFixed(2).replace('.', ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentFactor() {
|
||||||
|
var percent = parseFloat($('#tax_rate_id option:selected').data('percent')) || 0;
|
||||||
|
return 1 + percent / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcFromNet() {
|
||||||
|
var net = parseNumber($('#price_per_kg').val());
|
||||||
|
if (net === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('#price_per_kg_gross').val(formatNumber(net * currentFactor()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcFromGross() {
|
||||||
|
var gross = parseNumber($('#price_per_kg_gross').val());
|
||||||
|
if (gross === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var factor = currentFactor();
|
||||||
|
$('#price_per_kg').val(formatNumber(factor > 0 ? gross / factor : gross));
|
||||||
|
}
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
toggleBlocks();
|
toggleBlocks();
|
||||||
initIngredientSelect2();
|
initIngredientSelect2();
|
||||||
|
|
@ -75,6 +112,16 @@
|
||||||
$('#packaging_item_id').val(null).trigger('change');
|
$('#packaging_item_id').val(null).trigger('change');
|
||||||
initPackagingSelect2();
|
initPackagingSelect2();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#price_per_kg').on('input', recalcFromNet);
|
||||||
|
$('#price_per_kg_gross').on('input', recalcFromGross);
|
||||||
|
$('#tax_rate_id').on('change', function () {
|
||||||
|
if (parseNumber($('#price_per_kg').val()) !== null) {
|
||||||
|
recalcFromNet();
|
||||||
|
} else {
|
||||||
|
recalcFromGross();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})(jQuery);
|
})(jQuery);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
@extends('layouts.layout-2')
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@include('admin.inventory.partials.table-actions-style')
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h6 class="card-header d-flex justify-content-between align-items-center">
|
<h6 class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span>{{ __('Wareneingang') }}</span>
|
<span>{{ __('Wareneingang') }}</span>
|
||||||
|
|
@ -9,21 +10,37 @@
|
||||||
@endif
|
@endif
|
||||||
</h6>
|
</h6>
|
||||||
<div class="card-datatable table-responsive">
|
<div class="card-datatable table-responsive">
|
||||||
<table class="datatables-style table table-striped table-bordered">
|
<table class="datatables-style table table-striped table-bordered wawi-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="max-width: 90px;"></th>
|
||||||
<th>{{ __('Status') }}</th>
|
<th>{{ __('Status') }}</th>
|
||||||
<th>{{ __('Bestellt') }}</th>
|
<th>{{ __('Bestellt') }}</th>
|
||||||
<th>{{ __('Art') }}</th>
|
<th>{{ __('Art') }}</th>
|
||||||
<th>{{ __('Artikel') }}</th>
|
<th>{{ __('Artikel') }}</th>
|
||||||
<th>{{ __('Lieferant') }}</th>
|
<th>{{ __('Lieferant') }}</th>
|
||||||
<th>{{ __('Menge') }}</th>
|
<th>{{ __('Menge') }}</th>
|
||||||
<th style="max-width: 80px;"></th>
|
<th style="max-width: 60px;"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach($values as $row)
|
@foreach($values as $row)
|
||||||
<tr>
|
<tr>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
<a href="{{ route('admin.inventory.stock-entries.show', $row) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Ansicht') }}">
|
||||||
|
<span class="far fa-eye"></span>
|
||||||
|
</a>
|
||||||
|
@if(Auth::user()->isAdmin() && $row->status === 'pending')
|
||||||
|
<a href="{{ route('admin.inventory.stock-entries.edit', $row) }}" class="btn icon-btn btn-sm btn-secondary" title="{{ __('Bearbeiten') }}">
|
||||||
|
<span class="far fa-edit"></span>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
@if(Auth::user()->isAdmin())
|
||||||
|
<a href="{{ route('admin.inventory.stock-entries.copy', $row) }}" class="btn icon-btn btn-sm btn-info" title="{{ __('Duplizieren') }}">
|
||||||
|
<span class="far fa-copy"></span>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
<td data-sort="{{ $row->status === 'pending' ? 0 : 1 }}">
|
<td data-sort="{{ $row->status === 'pending' ? 0 : 1 }}">
|
||||||
@if($row->status === 'pending')
|
@if($row->status === 'pending')
|
||||||
<span class="badge badge-warning">{{ __('Offen') }}</span>
|
<span class="badge badge-warning">{{ __('Offen') }}</span>
|
||||||
|
|
@ -51,18 +68,12 @@
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ route('admin.inventory.stock-entries.show', $row) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Details') }}">
|
|
||||||
<span class="far fa-eye"></span>
|
|
||||||
</a>
|
|
||||||
@if(Auth::user()->isAdmin() && $row->status === 'pending')
|
@if(Auth::user()->isAdmin() && $row->status === 'pending')
|
||||||
<a href="{{ route('admin.inventory.stock-entries.edit', $row) }}" class="btn icon-btn btn-sm btn-secondary">
|
|
||||||
<span class="far fa-edit"></span>
|
|
||||||
</a>
|
|
||||||
<form action="{{ route('admin.inventory.stock-entries.destroy', $row) }}" method="post" class="d-inline"
|
<form action="{{ route('admin.inventory.stock-entries.destroy', $row) }}" method="post" class="d-inline"
|
||||||
onsubmit="return confirm(@json(__('Eintrag wirklich löschen?')));">
|
onsubmit="return confirm(@json(__('Eintrag wirklich löschen?')));">
|
||||||
@csrf
|
@csrf
|
||||||
@method('DELETE')
|
@method('DELETE')
|
||||||
<button type="submit" class="btn btn-link text-danger p-0" title="{{ __('Delete') }}"><i class="far fa-trash-alt"></i></button>
|
<button type="submit" class="btn btn-link text-danger p-0" title="{{ __('Löschen') }}"><i class="far fa-trash-alt"></i></button>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -77,7 +88,8 @@
|
||||||
$('.datatables-style').dataTable({
|
$('.datatables-style').dataTable({
|
||||||
"bLengthChange": false,
|
"bLengthChange": false,
|
||||||
"iDisplayLength": 100,
|
"iDisplayLength": 100,
|
||||||
"order": [[0, "asc"], [1, "desc"]],
|
"order": [[1, "asc"], [2, "desc"]],
|
||||||
|
"columnDefs": [{"orderable": false, "targets": [0, 7]}],
|
||||||
"language": {"url": "/js/German.json"}
|
"language": {"url": "/js/German.json"}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<a href="{{ route('admin.inventory.stock-entries.index') }}" class="btn btn-sm btn-outline-secondary">{{ __('Zurück zur Liste') }}</a>
|
<a href="{{ route('admin.inventory.stock-entries.index') }}" class="btn btn-sm btn-outline-secondary">{{ __('Zurück zur Liste') }}</a>
|
||||||
|
@if(Auth::user()->isAdmin())
|
||||||
|
<a href="{{ route('admin.inventory.stock-entries.copy', $model) }}" class="btn btn-sm btn-outline-info">{{ __('Duplizieren') }}</a>
|
||||||
|
@endif
|
||||||
@if(Auth::user()->isAdmin() && $model->isPending())
|
@if(Auth::user()->isAdmin() && $model->isPending())
|
||||||
<a href="{{ route('admin.inventory.stock-entries.edit', $model) }}" class="btn btn-sm btn-primary">{{ __('Bearbeiten') }}</a>
|
<a href="{{ route('admin.inventory.stock-entries.edit', $model) }}" class="btn btn-sm btn-primary">{{ __('Bearbeiten') }}</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -70,10 +73,16 @@
|
||||||
<dd class="col-sm-9">
|
<dd class="col-sm-9">
|
||||||
@if($model->entry_type === 'ingredient')
|
@if($model->entry_type === 'ingredient')
|
||||||
@if($model->price_per_kg !== null)
|
@if($model->price_per_kg !== null)
|
||||||
{{ \App\Services\Util::formatNumber($model->price_per_kg) }} € / kg
|
{{ \App\Services\Util::formatNumber($model->price_per_kg) }} € / kg {{ __('netto') }}
|
||||||
@else
|
@else
|
||||||
—
|
—
|
||||||
@endif
|
@endif
|
||||||
|
@if($model->price_per_kg_gross !== null)
|
||||||
|
<span class="text-muted">· {{ \App\Services\Util::formatNumber($model->price_per_kg_gross) }} € / kg {{ __('brutto') }}</span>
|
||||||
|
@endif
|
||||||
|
@if($model->tax_rate_percent !== null)
|
||||||
|
<span class="text-muted">· {{ __('USt.') }} {{ \App\Services\Util::formatNumber($model->tax_rate_percent) }} %</span>
|
||||||
|
@endif
|
||||||
@else
|
@else
|
||||||
@if($model->price_total !== null)
|
@if($model->price_total !== null)
|
||||||
{{ \App\Services\Util::formatNumber($model->price_total) }} € {{ __('netto') }}
|
{{ \App\Services\Util::formatNumber($model->price_total) }} € {{ __('netto') }}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
@extends('layouts.layout-2')
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@include('admin.inventory.partials.table-actions-style')
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h6 class="card-header d-flex justify-content-between align-items-center">
|
<h6 class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span>{{ __('Lieferanten-Kategorien') }}</span>
|
<span>{{ __('Lieferanten-Kategorien') }}</span>
|
||||||
<a href="{{ route('admin.inventory.supplier-categories.create') }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
<a href="{{ route('admin.inventory.supplier-categories.create') }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
||||||
</h6>
|
</h6>
|
||||||
<div class="card-datatable table-responsive">
|
<div class="card-datatable table-responsive">
|
||||||
<table class="datatables-style table table-striped table-bordered">
|
<table class="datatables-style table table-striped table-bordered wawi-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="max-width: 60px;"> </th>
|
<th style="max-width: 60px;"> </th>
|
||||||
|
|
|
||||||
161
resources/views/admin/inventory/suppliers/_details.blade.php
Normal file
161
resources/views/admin/inventory/suppliers/_details.blade.php
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
@php
|
||||||
|
$orderMethodLabels = [
|
||||||
|
'email' => __('Per E-Mail'),
|
||||||
|
'online_shop' => __('Online-Shop'),
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
<div id="supplier-details" data-supplier-id="{{ $supplier->id }}">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-5">{{ __('Name') }}</dt>
|
||||||
|
<dd class="col-sm-7">{{ $supplier->name }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-5">{{ __('Land') }}</dt>
|
||||||
|
<dd class="col-sm-7">{{ $supplier->country?->de ?? '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-5">{{ __('Kategorien') }}</dt>
|
||||||
|
<dd class="col-sm-7">
|
||||||
|
@forelse($supplier->supplierCategories as $cat)
|
||||||
|
<span class="badge badge-secondary">{{ $cat->name }}</span>
|
||||||
|
@empty
|
||||||
|
—
|
||||||
|
@endforelse
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-5">{{ __('Webseite') }}</dt>
|
||||||
|
<dd class="col-sm-7">
|
||||||
|
@if($supplier->url)
|
||||||
|
<a href="{{ $supplier->url }}" target="_blank" rel="noopener">{{ $supplier->url }}</a>
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-5">{{ __('Bestellweg') }}</dt>
|
||||||
|
<dd class="col-sm-7">{{ $orderMethodLabels[$supplier->order_method] ?? '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-5">{{ __('Bestell-E-Mail') }}</dt>
|
||||||
|
<dd class="col-sm-7">{{ $supplier->order_email ?: '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-5">{{ __('Bestell-URL') }}</dt>
|
||||||
|
<dd class="col-sm-7">
|
||||||
|
@if($supplier->order_url)
|
||||||
|
<a href="{{ $supplier->order_url }}" target="_blank" rel="noopener">{{ $supplier->order_url }}</a>
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-5">{{ __('Lieferzeit') }}</dt>
|
||||||
|
<dd class="col-sm-7">
|
||||||
|
{{ $supplier->delivery_time ?: '—' }}
|
||||||
|
@if($supplier->delivery_time_days !== null)
|
||||||
|
<span class="text-muted">({{ $supplier->delivery_time_days }} {{ __('Tage') }})</span>
|
||||||
|
@endif
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-5">{{ __('Ansprechpartner') }}</dt>
|
||||||
|
<dd class="col-sm-7">{{ $supplier->contact_person ?: '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-5">{{ __('E-Mail') }}</dt>
|
||||||
|
<dd class="col-sm-7">{{ $supplier->email ?: '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-5">{{ __('Telefon') }}</dt>
|
||||||
|
<dd class="col-sm-7">{{ $supplier->phone ?: '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($supplier->notes)
|
||||||
|
<div class="mt-2">
|
||||||
|
<strong>{{ __('Notizen') }}:</strong>
|
||||||
|
<div class="text-muted">{!! nl2br(e($supplier->notes)) !!}</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<h6 class="font-weight-bold">{{ __('Zugeordnete INCIs') }}</h6>
|
||||||
|
<ul class="list-group mb-2" id="supplier-ingredient-list">
|
||||||
|
@forelse($supplier->ingredients as $ingredient)
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center py-1">
|
||||||
|
<span>{{ $ingredient->name }}</span>
|
||||||
|
<span class="text-nowrap">
|
||||||
|
<a href="{{ route('admin_product_ingredient_edit', $ingredient->id) }}" target="_blank"
|
||||||
|
class="btn btn-link p-0 mr-2" title="{{ __('Zur Bearbeitung') }}">
|
||||||
|
<i class="fas fa-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-link text-danger p-0 js-detach-ingredient"
|
||||||
|
data-url="{{ route('admin.inventory.suppliers.ingredients.detach', [$supplier, $ingredient]) }}"
|
||||||
|
title="{{ __('Entfernen') }}">
|
||||||
|
<i class="far fa-trash-alt"></i>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="list-group-item text-muted py-1">{{ __('Noch keine INCIs zugeordnet.') }}</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<select class="form-control" id="add-ingredient-select">
|
||||||
|
<option value="">{{ __('INCI wählen …') }}</option>
|
||||||
|
@foreach($availableIngredients as $ingredient)
|
||||||
|
<option value="{{ $ingredient->id }}">{{ $ingredient->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<div class="input-group-append">
|
||||||
|
<button type="button" class="btn btn-primary js-attach-ingredient"
|
||||||
|
data-url="{{ route('admin.inventory.suppliers.ingredients.attach', $supplier) }}"
|
||||||
|
data-select="#add-ingredient-select">
|
||||||
|
{{ __('Hinzufügen') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<h6 class="font-weight-bold">{{ __('Zugeordnete Verpackungsartikel') }}</h6>
|
||||||
|
<ul class="list-group mb-2" id="supplier-packaging-list">
|
||||||
|
@forelse($supplier->packagingItems as $item)
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center py-1">
|
||||||
|
<span>{{ $item->name }}</span>
|
||||||
|
<span class="text-nowrap">
|
||||||
|
<a href="{{ route('admin.inventory.packaging-items.edit', $item) }}" target="_blank"
|
||||||
|
class="btn btn-link p-0 mr-2" title="{{ __('Zur Bearbeitung') }}">
|
||||||
|
<i class="fas fa-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-link text-danger p-0 js-detach-packaging"
|
||||||
|
data-url="{{ route('admin.inventory.suppliers.packaging-items.detach', [$supplier, $item]) }}"
|
||||||
|
title="{{ __('Entfernen') }}">
|
||||||
|
<i class="far fa-trash-alt"></i>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
@empty
|
||||||
|
<li class="list-group-item text-muted py-1">{{ __('Noch keine Verpackungsartikel zugeordnet.') }}</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<select class="form-control" id="add-packaging-select">
|
||||||
|
<option value="">{{ __('Verpackungsartikel wählen …') }}</option>
|
||||||
|
@foreach($availablePackagingItems as $item)
|
||||||
|
<option value="{{ $item->id }}">{{ $item->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<div class="input-group-append">
|
||||||
|
<button type="button" class="btn btn-primary js-attach-packaging"
|
||||||
|
data-url="{{ route('admin.inventory.suppliers.packaging-items.attach', $supplier) }}"
|
||||||
|
data-select="#add-packaging-select">
|
||||||
|
{{ __('Hinzufügen') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -2,89 +2,166 @@
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@php
|
@php
|
||||||
$selectedCategoryIds = old('supplier_category_ids', $model->exists ? $model->supplierCategories->pluck('id')->all() : []);
|
$selectedCategoryIds = old(
|
||||||
|
'supplier_category_ids',
|
||||||
|
$model->exists ? $model->supplierCategories->pluck('id')->all() : [],
|
||||||
|
);
|
||||||
@endphp
|
@endphp
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h6 class="card-header">{{ $model->exists ? __('Lieferant bearbeiten') : __('Lieferant anlegen') }}</h6>
|
<h6 class="card-header">{{ $model->exists ? __('Lieferant bearbeiten') : __('Lieferant anlegen') }}</h6>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="{{ $model->exists ? route('admin.inventory.suppliers.update', $model) : route('admin.inventory.suppliers.store') }}">
|
<form method="post"
|
||||||
|
action="{{ $model->exists ? route('admin.inventory.suppliers.update', $model) : route('admin.inventory.suppliers.store') }}">
|
||||||
@csrf
|
@csrf
|
||||||
@if($model->exists)
|
@if ($model->exists)
|
||||||
@method('PUT')
|
@method('PUT')
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">{{ __('Name') }}</label>
|
<label for="name">{{ __('Name') }}</label>
|
||||||
<input type="text" name="name" id="name" class="form-control @error('name') is-invalid @enderror"
|
<input type="text" name="name" id="name"
|
||||||
value="{{ old('name', $model->name) }}" required>
|
class="form-control @error('name') is-invalid @enderror" value="{{ old('name', $model->name) }}"
|
||||||
|
required>
|
||||||
@error('name')
|
@error('name')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="supplier_category_ids">{{ __('Kategorien') }}</label>
|
<label for="supplier_category_ids">{{ __('Kategorien') }}</label>
|
||||||
<div class="light-style">
|
<div class="light-style">
|
||||||
<select name="supplier_category_ids[]" id="supplier_category_ids" class="w-100" multiple="multiple" data-placeholder="{{ __('Kategorien wählen') }}">
|
<select name="supplier_category_ids[]" id="supplier_category_ids" class="w-100" multiple="multiple"
|
||||||
@foreach($supplierCategories as $cat)
|
data-placeholder="{{ __('Kategorien wählen') }}">
|
||||||
<option value="{{ $cat->id }}" @selected(in_array($cat->id, $selectedCategoryIds, true))>{{ $cat->name }}</option>
|
@foreach ($supplierCategories as $cat)
|
||||||
|
<option value="{{ $cat->id }}" @selected(in_array($cat->id, $selectedCategoryIds, true))>{{ $cat->name }}
|
||||||
|
</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@error('supplier_category_ids')
|
@error('supplier_category_ids')
|
||||||
<div class="text-danger small">{{ $message }}</div>
|
<div class="text-danger small">{{ $message }}</div>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="country_id">{{ __('Land') }}</label>
|
<label for="country_id">{{ __('Land') }}</label>
|
||||||
<select name="country_id" id="country_id" class="form-control @error('country_id') is-invalid @enderror" required>
|
<select name="country_id" id="country_id" class="form-control @error('country_id') is-invalid @enderror"
|
||||||
|
required>
|
||||||
<option value="">{{ __('Bitte wählen') }}</option>
|
<option value="">{{ __('Bitte wählen') }}</option>
|
||||||
@foreach($countries as $country)
|
@foreach ($countries as $country)
|
||||||
<option value="{{ $country->id }}" @selected((string)old('country_id', $model->country_id) === (string)$country->id)>
|
<option value="{{ $country->id }}" @selected((string) old('country_id', $model->country_id) === (string) $country->id)>
|
||||||
{{ $country->de }} ({{ $country->code }})
|
{{ $country->de }} ({{ $country->code }})
|
||||||
</option>
|
</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
@error('country_id')
|
@error('country_id')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="url">{{ __('Webseite') }}</label>
|
<label for="url">{{ __('Webseite') }}</label>
|
||||||
<input type="url" name="url" id="url" class="form-control @error('url') is-invalid @enderror"
|
<input type="text" name="url" id="url"
|
||||||
value="{{ old('url', $model->url) }}">
|
class="form-control @error('url') is-invalid @enderror" value="{{ old('url', $model->url) }}"
|
||||||
|
placeholder="https://">
|
||||||
@error('url')
|
@error('url')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$orderMethod = old('order_method', $model->order_method);
|
||||||
|
@endphp
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group col-md-6">
|
||||||
|
<label for="order_method">{{ __('Bestellweg') }}</label>
|
||||||
|
<select name="order_method" id="order_method"
|
||||||
|
class="form-control custom-select @error('order_method') is-invalid @enderror">
|
||||||
|
<option value="" @selected($orderMethod === null || $orderMethod === '')>{{ __('Keine Angabe') }}</option>
|
||||||
|
<option value="email" @selected($orderMethod === 'email')>{{ __('Per E-Mail') }}</option>
|
||||||
|
<option value="online_shop" @selected($orderMethod === 'online_shop')>{{ __('Online-Shop') }}</option>
|
||||||
|
</select>
|
||||||
|
@error('order_method')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-md-4">
|
||||||
|
<label for="delivery_time">{{ __('Lieferzeit') }}</label>
|
||||||
|
<input type="text" name="delivery_time" id="delivery_time" list="delivery_time_options"
|
||||||
|
class="form-control @error('delivery_time') is-invalid @enderror"
|
||||||
|
value="{{ old('delivery_time', $model->delivery_time) }}"
|
||||||
|
placeholder="{{ __('z. B. 3–5 Werktage') }}">
|
||||||
|
<datalist id="delivery_time_options">
|
||||||
|
@foreach ($deliveryTimes as $deliveryTime)
|
||||||
|
<option value="{{ $deliveryTime->label }}" data-days="{{ $deliveryTime->days }}"></option>
|
||||||
|
@endforeach
|
||||||
|
</datalist>
|
||||||
|
@error('delivery_time')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-md-2">
|
||||||
|
<label for="delivery_time_days">{{ __('Tage') }}</label>
|
||||||
|
<input type="number" name="delivery_time_days" id="delivery_time_days" min="0" max="65535"
|
||||||
|
class="form-control @error('delivery_time_days') is-invalid @enderror"
|
||||||
|
value="{{ old('delivery_time_days', $model->delivery_time_days) }}"
|
||||||
|
placeholder="{{ __('z. B. 5') }}">
|
||||||
|
@error('delivery_time_days')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group js-order-email" @if ($orderMethod !== 'email') style="display: none;" @endif>
|
||||||
|
<label for="order_email">{{ __('Bestell-E-Mail') }} <small
|
||||||
|
class="text-muted">{{ __('(falls abweichend)') }}</small></label>
|
||||||
|
<input type="email" name="order_email" id="order_email"
|
||||||
|
class="form-control @error('order_email') is-invalid @enderror"
|
||||||
|
value="{{ old('order_email', $model->order_email) }}">
|
||||||
|
@error('order_email')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group js-order-url" @if ($orderMethod !== 'online_shop') style="display: none;" @endif>
|
||||||
|
<label for="order_url">{{ __('Bestell-URL') }} <small
|
||||||
|
class="text-muted">{{ __('(falls abweichend)') }}</small></label>
|
||||||
|
<input type="text" name="order_url" id="order_url"
|
||||||
|
class="form-control @error('order_url') is-invalid @enderror"
|
||||||
|
value="{{ old('order_url', $model->order_url) }}" placeholder="https://">
|
||||||
|
@error('order_url')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group col-md-6">
|
<div class="form-group col-md-6">
|
||||||
<label for="contact_person">{{ __('Ansprechpartner') }}</label>
|
<label for="contact_person">{{ __('Ansprechpartner') }}</label>
|
||||||
<input type="text" name="contact_person" id="contact_person" class="form-control @error('contact_person') is-invalid @enderror"
|
<input type="text" name="contact_person" id="contact_person"
|
||||||
value="{{ old('contact_person', $model->contact_person) }}">
|
class="form-control @error('contact_person') is-invalid @enderror"
|
||||||
|
value="{{ old('contact_person', $model->contact_person) }}">
|
||||||
@error('contact_person')
|
@error('contact_person')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group col-md-6">
|
<div class="form-group col-md-6">
|
||||||
<label for="email">{{ __('E-Mail') }}</label>
|
<label for="email">{{ __('E-Mail') }}</label>
|
||||||
<input type="email" name="email" id="email" class="form-control @error('email') is-invalid @enderror"
|
<input type="email" name="email" id="email"
|
||||||
value="{{ old('email', $model->email) }}">
|
class="form-control @error('email') is-invalid @enderror"
|
||||||
|
value="{{ old('email', $model->email) }}">
|
||||||
@error('email')
|
@error('email')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="phone">{{ __('Telefon') }}</label>
|
<label for="phone">{{ __('Telefon') }}</label>
|
||||||
<input type="text" name="phone" id="phone" class="form-control @error('phone') is-invalid @enderror"
|
<input type="text" name="phone" id="phone"
|
||||||
value="{{ old('phone', $model->phone) }}">
|
class="form-control @error('phone') is-invalid @enderror"
|
||||||
|
value="{{ old('phone', $model->phone) }}">
|
||||||
@error('phone')
|
@error('phone')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -92,20 +169,21 @@
|
||||||
<label for="notes">{{ __('Notizen') }}</label>
|
<label for="notes">{{ __('Notizen') }}</label>
|
||||||
<textarea name="notes" id="notes" rows="3" class="form-control @error('notes') is-invalid @enderror">{{ old('notes', $model->notes) }}</textarea>
|
<textarea name="notes" id="notes" rows="3" class="form-control @error('notes') is-invalid @enderror">{{ old('notes', $model->notes) }}</textarea>
|
||||||
@error('notes')
|
@error('notes')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="custom-control custom-checkbox">
|
<label class="custom-control custom-checkbox">
|
||||||
<input type="checkbox" name="active" value="1" class="custom-control-input"
|
<input type="checkbox" name="active" value="1" class="custom-control-input"
|
||||||
@checked(old('active', $model->active))>
|
@checked(old('active', $model->active))>
|
||||||
<span class="custom-control-label">{{ __('Aktiv') }}</span>
|
<span class="custom-control-label">{{ __('Aktiv') }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">{{ __('Speichern') }}</button>
|
<button type="submit" class="btn btn-primary">{{ __('Speichern') }}</button>
|
||||||
<a href="{{ route('admin.inventory.suppliers.index') }}" class="btn btn-outline-secondary">{{ __('Zurück') }}</a>
|
<a href="{{ route('admin.inventory.suppliers.index') }}"
|
||||||
|
class="btn btn-outline-secondary">{{ __('Zurück') }}</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -113,13 +191,33 @@
|
||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function() {
|
||||||
$('#supplier_category_ids').select2({
|
$('#supplier_category_ids').select2({
|
||||||
theme: 'default',
|
theme: 'default',
|
||||||
placeholder: '{{ __('Kategorien wählen') }}',
|
placeholder: '{{ __('Kategorien wählen') }}',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
closeOnSelect: false
|
closeOnSelect: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function toggleOrderFields() {
|
||||||
|
var method = $('#order_method').val();
|
||||||
|
$('.js-order-email').toggle(method === 'email');
|
||||||
|
$('.js-order-url').toggle(method === 'online_shop');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#order_method').on('change', toggleOrderFields);
|
||||||
|
toggleOrderFields();
|
||||||
|
|
||||||
|
$('#delivery_time').on('input change', function () {
|
||||||
|
var value = $(this).val();
|
||||||
|
var option = $('#delivery_time_options option').filter(function () {
|
||||||
|
return this.value === value;
|
||||||
|
}).first();
|
||||||
|
|
||||||
|
if (option.length && option.data('days') !== undefined && option.data('days') !== '') {
|
||||||
|
$('#delivery_time_days').val(option.data('days'));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
@extends('layouts.layout-2')
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
|
@include('admin.inventory.partials.table-actions-style')
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h6 class="card-header d-flex justify-content-between align-items-center">
|
<h6 class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span>{{ __('Lieferanten') }}</span>
|
<span>{{ __('Lieferanten') }}</span>
|
||||||
<a href="{{ route('admin.inventory.suppliers.create') }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
<a href="{{ route('admin.inventory.suppliers.create') }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
|
||||||
</h6>
|
</h6>
|
||||||
<div class="card-datatable table-responsive">
|
<div class="card-datatable table-responsive">
|
||||||
<table class="datatables-style table table-striped table-bordered">
|
<table class="datatables-style table table-striped table-bordered wawi-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="max-width: 60px;"> </th>
|
<th style="max-width: 60px;"> </th>
|
||||||
|
|
@ -21,8 +22,13 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach($values as $value)
|
@foreach($values as $value)
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td class="text-nowrap">
|
||||||
<a href="{{ route('admin.inventory.suppliers.edit', $value) }}" class="btn icon-btn btn-sm btn-primary">
|
<button type="button" class="btn icon-btn btn-sm btn-info js-show-supplier"
|
||||||
|
data-url="{{ route('admin.inventory.suppliers.show', $value) }}"
|
||||||
|
data-name="{{ $value->name }}" title="{{ __('Ansicht') }}">
|
||||||
|
<span class="far fa-eye"></span>
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('admin.inventory.suppliers.edit', $value) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Bearbeiten') }}">
|
||||||
<span class="far fa-edit"></span>
|
<span class="far fa-edit"></span>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -54,6 +60,22 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal fade" id="supplierShowModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{ __('Lieferant') }}</h5>
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="supplierShowBody">
|
||||||
|
<div class="text-center text-muted py-4">{{ __('Lädt …') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$('.datatables-style').dataTable({
|
$('.datatables-style').dataTable({
|
||||||
|
|
@ -62,6 +84,46 @@
|
||||||
"order": [[1, "asc"]],
|
"order": [[1, "asc"]],
|
||||||
"language": {"url": "/js/German.json"}
|
"language": {"url": "/js/German.json"}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var csrfToken = $('meta[name="csrf-token"]').attr('content');
|
||||||
|
var $modal = $('#supplierShowModal');
|
||||||
|
var $body = $('#supplierShowBody');
|
||||||
|
|
||||||
|
function loadDetails(url, method) {
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
method: method || 'GET',
|
||||||
|
headers: {'X-CSRF-TOKEN': csrfToken},
|
||||||
|
data: method && method !== 'GET' ? arguments[2] : undefined
|
||||||
|
}).done(function (html) {
|
||||||
|
$body.html(html);
|
||||||
|
}).fail(function () {
|
||||||
|
$body.html('<div class="alert alert-danger mb-0">{{ __('Fehler beim Laden.') }}</div>');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).on('click', '.js-show-supplier', function () {
|
||||||
|
$modal.find('.modal-title').text($(this).data('name'));
|
||||||
|
$body.html('<div class="text-center text-muted py-4">{{ __('Lädt …') }}</div>');
|
||||||
|
loadDetails($(this).data('url'), 'GET');
|
||||||
|
$modal.modal('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.js-attach-ingredient, .js-attach-packaging', function () {
|
||||||
|
var $btn = $(this);
|
||||||
|
var value = $($btn.data('select')).val();
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var payload = $btn.hasClass('js-attach-ingredient')
|
||||||
|
? {ingredient_id: value}
|
||||||
|
: {packaging_item_id: value};
|
||||||
|
loadDetails($btn.data('url'), 'POST', payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.js-detach-ingredient, .js-detach-packaging', function () {
|
||||||
|
loadDetails($(this).data('url'), 'DELETE');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
|
||||||
60
resources/views/admin/inventory/tax-rates/form.blade.php
Normal file
60
resources/views/admin/inventory/tax-rates/form.blade.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
@extends('layouts.layout-2')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="card">
|
||||||
|
<h6 class="card-header">{{ $model->exists ? __('Umsatzsteuersatz bearbeiten') : __('Umsatzsteuersatz anlegen') }}</h6>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="{{ $model->exists ? route('admin.inventory.tax-rates.update', $model) : route('admin.inventory.tax-rates.store') }}">
|
||||||
|
@csrf
|
||||||
|
@if($model->exists)
|
||||||
|
@method('PUT')
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">{{ __('Name') }}</label>
|
||||||
|
<input type="text" name="name" id="name" class="form-control @error('name') is-invalid @enderror"
|
||||||
|
value="{{ old('name', $model->name) }}" placeholder="{{ __('z. B. Standard') }}" required>
|
||||||
|
@error('name')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="percent">{{ __('Satz in %') }}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="number" name="percent" id="percent" step="0.01" min="0" max="100"
|
||||||
|
class="form-control @error('percent') is-invalid @enderror"
|
||||||
|
value="{{ old('percent', $model->percent) }}" required>
|
||||||
|
<div class="input-group-append">
|
||||||
|
<span class="input-group-text">%</span>
|
||||||
|
</div>
|
||||||
|
@error('percent')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pos">{{ __('Sortierung') }}</label>
|
||||||
|
<input type="number" name="pos" id="pos" min="0" max="255"
|
||||||
|
class="form-control @error('pos') is-invalid @enderror"
|
||||||
|
value="{{ old('pos', $model->pos) }}">
|
||||||
|
@error('pos')
|
||||||
|
<div class="invalid-feedback">{{ $message }}</div>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="custom-control custom-checkbox">
|
||||||
|
<input type="checkbox" name="active" value="1" class="custom-control-input"
|
||||||
|
@checked(old('active', $model->active))>
|
||||||
|
<span class="custom-control-label">{{ __('Aktiv') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">{{ __('Speichern') }}</button>
|
||||||
|
<a href="{{ route('admin.inventory.general') }}" class="btn btn-outline-secondary">{{ __('Zurück') }}</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
@ -263,6 +263,9 @@
|
||||||
@endif
|
@endif
|
||||||
@if (Auth::user()->isSuperAdmin())
|
@if (Auth::user()->isSuperAdmin())
|
||||||
<li class="sidenav-item @if (Request::is(
|
<li class="sidenav-item @if (Request::is(
|
||||||
|
'admin/inventory/general*',
|
||||||
|
'admin/inventory/tax-rates*',
|
||||||
|
'admin/inventory/delivery-times*',
|
||||||
'admin/inventory/locations*',
|
'admin/inventory/locations*',
|
||||||
'admin/inventory/material-qualities*',
|
'admin/inventory/material-qualities*',
|
||||||
'admin/inventory/packaging-materials*')) open @endif">
|
'admin/inventory/packaging-materials*')) open @endif">
|
||||||
|
|
@ -271,6 +274,12 @@
|
||||||
<div>Einstellungen</div>
|
<div>Einstellungen</div>
|
||||||
</a>
|
</a>
|
||||||
<ul class="sidenav-menu">
|
<ul class="sidenav-menu">
|
||||||
|
<li class="sidenav-item{{ Request::is('admin/inventory/general*', 'admin/inventory/tax-rates*', 'admin/inventory/delivery-times*') ? ' active' : '' }}">
|
||||||
|
<a href="{{ route('admin.inventory.general') }}" class="sidenav-link"><i
|
||||||
|
class="sidenav-icon ion ion-md-options"></i>
|
||||||
|
<div>{{ __('Allgemein') }}</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="sidenav-item{{ Request::is('admin/inventory/locations*') ? ' active' : '' }}">
|
<li class="sidenav-item{{ Request::is('admin/inventory/locations*') ? ' active' : '' }}">
|
||||||
<a href="{{ route('admin.inventory.locations.index') }}" class="sidenav-link"><i
|
<a href="{{ route('admin.inventory.locations.index') }}" class="sidenav-link"><i
|
||||||
class="sidenav-icon ion ion-ios-pin"></i>
|
class="sidenav-icon ion ion-ios-pin"></i>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\Inventory\DeliveryTimeController as InventoryDeliveryTimeController;
|
||||||
|
use App\Http\Controllers\Admin\Inventory\GeneralSettingController as InventoryGeneralSettingController;
|
||||||
use App\Http\Controllers\Admin\Inventory\LocationController as InventoryLocationController;
|
use App\Http\Controllers\Admin\Inventory\LocationController as InventoryLocationController;
|
||||||
use App\Http\Controllers\Admin\Inventory\MaterialQualityController as InventoryMaterialQualityController;
|
use App\Http\Controllers\Admin\Inventory\MaterialQualityController as InventoryMaterialQualityController;
|
||||||
use App\Http\Controllers\Admin\Inventory\PackagingItemController as InventoryPackagingItemController;
|
use App\Http\Controllers\Admin\Inventory\PackagingItemController as InventoryPackagingItemController;
|
||||||
|
|
@ -8,6 +10,7 @@ use App\Http\Controllers\Admin\Inventory\ProductionController as InventoryProduc
|
||||||
use App\Http\Controllers\Admin\Inventory\StockEntryController as InventoryStockEntryController;
|
use App\Http\Controllers\Admin\Inventory\StockEntryController as InventoryStockEntryController;
|
||||||
use App\Http\Controllers\Admin\Inventory\SupplierCategoryController as InventorySupplierCategoryController;
|
use App\Http\Controllers\Admin\Inventory\SupplierCategoryController as InventorySupplierCategoryController;
|
||||||
use App\Http\Controllers\Admin\Inventory\SupplierController as InventorySupplierController;
|
use App\Http\Controllers\Admin\Inventory\SupplierController as InventorySupplierController;
|
||||||
|
use App\Http\Controllers\Admin\Inventory\TaxRateController as InventoryTaxRateController;
|
||||||
use App\Http\Controllers\AdminPromotionController;
|
use App\Http\Controllers\AdminPromotionController;
|
||||||
use App\Http\Controllers\AdminUserController;
|
use App\Http\Controllers\AdminUserController;
|
||||||
use App\Http\Controllers\AttributeController;
|
use App\Http\Controllers\AttributeController;
|
||||||
|
|
@ -274,11 +277,18 @@ Route::domain(config('app.domain'))->group(function () {
|
||||||
Route::resource('locations', InventoryLocationController::class)->except(['show']);
|
Route::resource('locations', InventoryLocationController::class)->except(['show']);
|
||||||
Route::resource('material-qualities', InventoryMaterialQualityController::class)->except(['show']);
|
Route::resource('material-qualities', InventoryMaterialQualityController::class)->except(['show']);
|
||||||
Route::resource('packaging-materials', InventoryPackagingMaterialController::class)->except(['show']);
|
Route::resource('packaging-materials', InventoryPackagingMaterialController::class)->except(['show']);
|
||||||
|
Route::get('general', [InventoryGeneralSettingController::class, 'index'])->name('general');
|
||||||
|
Route::resource('tax-rates', InventoryTaxRateController::class)->except(['index', 'show']);
|
||||||
|
Route::resource('delivery-times', InventoryDeliveryTimeController::class)->except(['index', 'show']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['admin'])->prefix('admin/inventory')->name('admin.inventory.')->group(function () {
|
Route::middleware(['admin'])->prefix('admin/inventory')->name('admin.inventory.')->group(function () {
|
||||||
Route::resource('supplier-categories', InventorySupplierCategoryController::class)->except(['show']);
|
Route::resource('supplier-categories', InventorySupplierCategoryController::class)->except(['show']);
|
||||||
Route::resource('suppliers', InventorySupplierController::class)->except(['show']);
|
Route::post('suppliers/{supplier}/ingredients', [InventorySupplierController::class, 'attachIngredient'])->name('suppliers.ingredients.attach');
|
||||||
|
Route::delete('suppliers/{supplier}/ingredients/{ingredient}', [InventorySupplierController::class, 'detachIngredient'])->name('suppliers.ingredients.detach');
|
||||||
|
Route::post('suppliers/{supplier}/packaging-items', [InventorySupplierController::class, 'attachPackagingItem'])->name('suppliers.packaging-items.attach');
|
||||||
|
Route::delete('suppliers/{supplier}/packaging-items/{packagingItem}', [InventorySupplierController::class, 'detachPackagingItem'])->name('suppliers.packaging-items.detach');
|
||||||
|
Route::resource('suppliers', InventorySupplierController::class);
|
||||||
Route::resource('packaging-items', InventoryPackagingItemController::class)->except(['show']);
|
Route::resource('packaging-items', InventoryPackagingItemController::class)->except(['show']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -287,6 +297,7 @@ Route::domain(config('app.domain'))->group(function () {
|
||||||
Route::get('api/packaging-items/search', [InventoryStockEntryController::class, 'searchPackagingItems'])->name('api.packaging-items.search');
|
Route::get('api/packaging-items/search', [InventoryStockEntryController::class, 'searchPackagingItems'])->name('api.packaging-items.search');
|
||||||
Route::get('api/products/{product}/recipe', [InventoryProductionController::class, 'recipeJson'])->name('api.products.recipe');
|
Route::get('api/products/{product}/recipe', [InventoryProductionController::class, 'recipeJson'])->name('api.products.recipe');
|
||||||
Route::put('stock-entries/{stock_entry}/receive', [InventoryStockEntryController::class, 'receive'])->name('stock-entries.receive');
|
Route::put('stock-entries/{stock_entry}/receive', [InventoryStockEntryController::class, 'receive'])->name('stock-entries.receive');
|
||||||
|
Route::get('stock-entries/{stock_entry}/copy', [InventoryStockEntryController::class, 'copy'])->name('stock-entries.copy');
|
||||||
Route::resource('stock-entries', InventoryStockEntryController::class);
|
Route::resource('stock-entries', InventoryStockEntryController::class);
|
||||||
Route::resource('productions', InventoryProductionController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update']);
|
Route::resource('productions', InventoryProductionController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update']);
|
||||||
Route::get('productions/{production}/copy', [InventoryProductionController::class, 'copy'])->name('productions.copy');
|
Route::get('productions/{production}/copy', [InventoryProductionController::class, 'copy'])->name('productions.copy');
|
||||||
|
|
|
||||||
126
tests/Feature/DeliveryTimeSettingsTest.php
Normal file
126
tests/Feature/DeliveryTimeSettingsTest.php
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\DeliveryTime;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function deliveryTimeUser(int $adminLevel = 8): User
|
||||||
|
{
|
||||||
|
$user = User::query()->create([
|
||||||
|
'email' => uniqid('dt_', true).'@test.example',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
$user->forceFill([
|
||||||
|
'admin' => $adminLevel,
|
||||||
|
'confirmed' => true,
|
||||||
|
'active' => true,
|
||||||
|
'wizard' => 100,
|
||||||
|
'blocked' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $user->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('allgemein-seite zeigt lieferzeit-vorlagen', function () {
|
||||||
|
$this->actingAs(deliveryTimeUser(8), 'user');
|
||||||
|
|
||||||
|
DeliveryTime::create(['label' => '3–5 Werktage', 'active' => true, 'pos' => 0]);
|
||||||
|
|
||||||
|
$response = $this->get(route('admin.inventory.general'));
|
||||||
|
|
||||||
|
$response->assertSuccessful();
|
||||||
|
$response->assertSee('Lieferzeit-Vorlagen', false);
|
||||||
|
$response->assertSee('3–5 Werktage', false);
|
||||||
|
$response->assertSee(route('admin.inventory.delivery-times.create'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('superadmin legt lieferzeit-vorlage an', function () {
|
||||||
|
$this->actingAs(deliveryTimeUser(8), 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.delivery-times.create'))->assertSuccessful();
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.delivery-times.store'), [
|
||||||
|
'label' => '1–2 Wochen',
|
||||||
|
'days' => 14,
|
||||||
|
'active' => '1',
|
||||||
|
'pos' => 0,
|
||||||
|
])->assertRedirect(route('admin.inventory.general'));
|
||||||
|
|
||||||
|
$deliveryTime = DeliveryTime::query()->where('label', '1–2 Wochen')->firstOrFail();
|
||||||
|
expect($deliveryTime->active)->toBeTrue()
|
||||||
|
->and($deliveryTime->days)->toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lieferzeit-vorlage ohne tage bleibt erlaubt', function () {
|
||||||
|
$this->actingAs(deliveryTimeUser(8), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.delivery-times.store'), [
|
||||||
|
'label' => 'Auf Anfrage',
|
||||||
|
'days' => '',
|
||||||
|
'active' => '1',
|
||||||
|
])->assertRedirect(route('admin.inventory.general'));
|
||||||
|
|
||||||
|
$deliveryTime = DeliveryTime::query()->where('label', 'Auf Anfrage')->firstOrFail();
|
||||||
|
expect($deliveryTime->days)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tage muss eine ganze zahl sein', function () {
|
||||||
|
$this->actingAs(deliveryTimeUser(8), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.delivery-times.store'), [
|
||||||
|
'label' => 'Falsche Tage',
|
||||||
|
'days' => 'abc',
|
||||||
|
])->assertSessionHasErrors('days');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lieferzeit-vorlage kann bearbeitet und deaktiviert werden', function () {
|
||||||
|
$this->actingAs(deliveryTimeUser(8), 'user');
|
||||||
|
|
||||||
|
$deliveryTime = DeliveryTime::create(['label' => '3–5 Werktage', 'active' => true, 'pos' => 1]);
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.delivery-times.edit', $deliveryTime))->assertSuccessful();
|
||||||
|
|
||||||
|
$this->put(route('admin.inventory.delivery-times.update', $deliveryTime), [
|
||||||
|
'label' => '5–7 Werktage',
|
||||||
|
'pos' => 1,
|
||||||
|
])->assertRedirect(route('admin.inventory.general'));
|
||||||
|
|
||||||
|
$deliveryTime->refresh();
|
||||||
|
expect($deliveryTime->label)->toBe('5–7 Werktage');
|
||||||
|
expect($deliveryTime->active)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lieferzeit-vorlage kann geloescht werden', function () {
|
||||||
|
$this->actingAs(deliveryTimeUser(8), 'user');
|
||||||
|
|
||||||
|
$deliveryTime = DeliveryTime::create(['label' => 'Express', 'active' => true, 'pos' => 2]);
|
||||||
|
|
||||||
|
$this->delete(route('admin.inventory.delivery-times.destroy', $deliveryTime))
|
||||||
|
->assertRedirect(route('admin.inventory.general'));
|
||||||
|
|
||||||
|
expect(DeliveryTime::query()->whereKey($deliveryTime->id)->exists())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bezeichnung ist pflicht', function () {
|
||||||
|
$this->actingAs(deliveryTimeUser(8), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.delivery-times.store'), [
|
||||||
|
'label' => '',
|
||||||
|
])->assertSessionHasErrors('label');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nur aktive vorlagen ueber active-scope', function () {
|
||||||
|
DeliveryTime::create(['label' => 'Aktiv', 'active' => true, 'pos' => 0]);
|
||||||
|
DeliveryTime::create(['label' => 'Inaktiv', 'active' => false, 'pos' => 1]);
|
||||||
|
|
||||||
|
expect(DeliveryTime::query()->active()->pluck('label')->all())->toBe(['Aktiv']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nicht-superadmin hat keinen zugriff auf lieferzeit-anlage', function () {
|
||||||
|
$this->actingAs(deliveryTimeUser(7), 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.delivery-times.create'))->assertRedirect('/home');
|
||||||
|
});
|
||||||
135
tests/Feature/IngredientOrderFieldsTest.php
Normal file
135
tests/Feature/IngredientOrderFieldsTest.php
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\DeliveryTime;
|
||||||
|
use App\Models\Ingredient;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\Models\TaxRate;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function ingredientOrderUser(int $adminLevel): User
|
||||||
|
{
|
||||||
|
$user = User::query()->create([
|
||||||
|
'email' => uniqid('ing_', true).'@test.example',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
$user->forceFill([
|
||||||
|
'admin' => $adminLevel,
|
||||||
|
'confirmed' => true,
|
||||||
|
'active' => true,
|
||||||
|
'wizard' => 100,
|
||||||
|
'blocked' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $user->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingredientOrderSupplier(string $name): Supplier
|
||||||
|
{
|
||||||
|
return Supplier::factory()->create([
|
||||||
|
'name' => $name,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ingredient form shows suppliers, tax rates and delivery time templates', function () {
|
||||||
|
$supplier = ingredientOrderSupplier('Aktiv-Lieferant');
|
||||||
|
TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
|
||||||
|
DeliveryTime::query()->create(['label' => '3–5 Werktage', 'days' => 5, 'active' => true, 'pos' => 0]);
|
||||||
|
DeliveryTime::query()->create(['label' => 'Inaktiv-Vorlage', 'active' => false, 'pos' => 1]);
|
||||||
|
|
||||||
|
$this->actingAs(ingredientOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin_product_ingredient_edit', 'new'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('supplier_ids')
|
||||||
|
->assertSee('tax_rate_id')
|
||||||
|
->assertSee('Aktiv-Lieferant')
|
||||||
|
->assertSee('3–5 Werktage')
|
||||||
|
->assertSee('data-days="5"', false)
|
||||||
|
->assertDontSee('Inaktiv-Vorlage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingredient can be stored with tax rate, delivery time and suppliers', function () {
|
||||||
|
$taxRate = TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
|
||||||
|
$supplierA = ingredientOrderSupplier('Lieferant A');
|
||||||
|
$supplierB = ingredientOrderSupplier('Lieferant B');
|
||||||
|
|
||||||
|
$this->actingAs(ingredientOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin_product_ingredient_store'), [
|
||||||
|
'id' => 'new',
|
||||||
|
'name' => 'Sheabutter',
|
||||||
|
'active' => '1',
|
||||||
|
'tax_rate_id' => (string) $taxRate->id,
|
||||||
|
'delivery_time' => '3–5 Werktage',
|
||||||
|
'delivery_time_days' => '5',
|
||||||
|
'supplier_ids' => [(string) $supplierA->id, (string) $supplierB->id],
|
||||||
|
])->assertRedirect(route('admin_product_ingredients'));
|
||||||
|
|
||||||
|
$ingredient = Ingredient::query()->where('name', 'Sheabutter')->firstOrFail();
|
||||||
|
|
||||||
|
expect($ingredient->tax_rate_id)->toBe($taxRate->id)
|
||||||
|
->and($ingredient->delivery_time)->toBe('3–5 Werktage')
|
||||||
|
->and($ingredient->delivery_time_days)->toBe(5)
|
||||||
|
->and($ingredient->suppliers()->pluck('suppliers.id')->all())
|
||||||
|
->toEqualCanonicalizing([$supplierA->id, $supplierB->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingredient suppliers are synced on update', function () {
|
||||||
|
$supplierA = ingredientOrderSupplier('Lieferant A');
|
||||||
|
$supplierB = ingredientOrderSupplier('Lieferant B');
|
||||||
|
|
||||||
|
$ingredient = Ingredient::query()->create([
|
||||||
|
'name' => 'Lavendelöl',
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
$ingredient->suppliers()->sync([$supplierA->id]);
|
||||||
|
|
||||||
|
$this->actingAs(ingredientOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin_product_ingredient_store'), [
|
||||||
|
'id' => (string) $ingredient->id,
|
||||||
|
'name' => 'Lavendelöl',
|
||||||
|
'active' => '1',
|
||||||
|
'supplier_ids' => [(string) $supplierB->id],
|
||||||
|
])->assertRedirect(route('admin_product_ingredients'));
|
||||||
|
|
||||||
|
expect($ingredient->fresh()->suppliers()->pluck('suppliers.id')->all())
|
||||||
|
->toEqualCanonicalizing([$supplierB->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingredient delivery time days must be an integer', function () {
|
||||||
|
$this->actingAs(ingredientOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin_product_ingredient_store'), [
|
||||||
|
'id' => 'new',
|
||||||
|
'name' => 'Falsche-Tage',
|
||||||
|
'active' => '1',
|
||||||
|
'delivery_time_days' => 'viele',
|
||||||
|
])->assertSessionHasErrors('delivery_time_days');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingredient tax rate must exist', function () {
|
||||||
|
$this->actingAs(ingredientOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin_product_ingredient_store'), [
|
||||||
|
'id' => 'new',
|
||||||
|
'name' => 'Falscher-Steuersatz',
|
||||||
|
'active' => '1',
|
||||||
|
'tax_rate_id' => '999999',
|
||||||
|
])->assertSessionHasErrors('tax_rate_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingredient name is required', function () {
|
||||||
|
$this->actingAs(ingredientOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin_product_ingredient_store'), [
|
||||||
|
'id' => 'new',
|
||||||
|
'name' => '',
|
||||||
|
'active' => '1',
|
||||||
|
])->assertSessionHasErrors('name');
|
||||||
|
});
|
||||||
100
tests/Feature/InventoryUrlFieldsTest.php
Normal file
100
tests/Feature/InventoryUrlFieldsTest.php
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Country;
|
||||||
|
use App\Models\PackagingItem;
|
||||||
|
use App\Models\PackagingMaterial;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konfigurator-URL mit kodierten Parametern (Versandverpackung), siehe docs/Todos.md.
|
||||||
|
*/
|
||||||
|
function configuratorUrl(): string
|
||||||
|
{
|
||||||
|
return 'https://www.kartonsaufmass.de/bestellen?bom_configuration=%7B%2522length%2522:125,%2522width%2522:125,%2522height%2522:30,%2522amount%2522:100%7D';
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlTestUser(int $adminLevel): User
|
||||||
|
{
|
||||||
|
$user = User::query()->create([
|
||||||
|
'email' => uniqid('urlfield_', true).'@test.example',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
$user->forceFill([
|
||||||
|
'admin' => $adminLevel,
|
||||||
|
'confirmed' => true,
|
||||||
|
'active' => true,
|
||||||
|
'wizard' => 100,
|
||||||
|
'blocked' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $user->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlTestCountry(): Country
|
||||||
|
{
|
||||||
|
return Country::query()->firstOrCreate(
|
||||||
|
['code' => 'DE'],
|
||||||
|
[
|
||||||
|
'phone' => '00',
|
||||||
|
'en' => 'Germany',
|
||||||
|
'de' => 'Deutschland',
|
||||||
|
'es' => 'Germany',
|
||||||
|
'fr' => 'Germany',
|
||||||
|
'it' => 'Germany',
|
||||||
|
'ru' => 'Germany',
|
||||||
|
'active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('supplier speichert URL mit Parametern (B1/B2)', function () {
|
||||||
|
$country = urlTestCountry();
|
||||||
|
$user = urlTestUser(7);
|
||||||
|
$this->actingAs($user, 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.suppliers.store'), [
|
||||||
|
'name' => 'Kartons-auf-Mass',
|
||||||
|
'country_id' => (string) $country->id,
|
||||||
|
'url' => configuratorUrl(),
|
||||||
|
'active' => '1',
|
||||||
|
])->assertRedirect(route('admin.inventory.suppliers.index'));
|
||||||
|
|
||||||
|
$supplier = Supplier::query()->where('name', 'Kartons-auf-Mass')->firstOrFail();
|
||||||
|
expect($supplier->url)->toBe(configuratorUrl());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supplier ohne URL bleibt speicherbar', function () {
|
||||||
|
$country = urlTestCountry();
|
||||||
|
$user = urlTestUser(7);
|
||||||
|
$this->actingAs($user, 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.suppliers.store'), [
|
||||||
|
'name' => 'Ohne-URL',
|
||||||
|
'country_id' => (string) $country->id,
|
||||||
|
'active' => '1',
|
||||||
|
])->assertRedirect(route('admin.inventory.suppliers.index'));
|
||||||
|
|
||||||
|
expect(Supplier::query()->where('name', 'Ohne-URL')->value('url'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('packaging item speichert URL mit Parametern (B2)', function () {
|
||||||
|
$material = PackagingMaterial::factory()->create();
|
||||||
|
$user = urlTestUser(7);
|
||||||
|
$this->actingAs($user, 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.packaging-items.store'), [
|
||||||
|
'packaging_material_id' => (string) $material->id,
|
||||||
|
'name' => 'Versandkarton Konfigurator',
|
||||||
|
'category' => 'shipping',
|
||||||
|
'url' => configuratorUrl(),
|
||||||
|
'active' => '1',
|
||||||
|
])->assertRedirect(route('admin.inventory.packaging-items.index', ['category' => 'shipping']));
|
||||||
|
|
||||||
|
$item = PackagingItem::query()->where('name', 'Versandkarton Konfigurator')->firstOrFail();
|
||||||
|
expect($item->url)->toBe(configuratorUrl());
|
||||||
|
});
|
||||||
263
tests/Feature/ProductPhase51Test.php
Normal file
263
tests/Feature/ProductPhase51Test.php
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Country;
|
||||||
|
use App\Models\Ingredient;
|
||||||
|
use App\Models\Location;
|
||||||
|
use App\Models\MaterialQuality;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\ProductIngredient;
|
||||||
|
use App\Models\StockEntry;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\Repositories\ProductRepository;
|
||||||
|
use App\Services\ProductionService;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function p51MakeUser(int $adminLevel = 1): User
|
||||||
|
{
|
||||||
|
$user = User::query()->create([
|
||||||
|
'email' => uniqid('p51_', true).'@test.example',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
$user->forceFill([
|
||||||
|
'admin' => $adminLevel,
|
||||||
|
'confirmed' => true,
|
||||||
|
'active' => true,
|
||||||
|
'wizard' => 100,
|
||||||
|
'blocked' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $user->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function p51EnsureCountry(): Country
|
||||||
|
{
|
||||||
|
return Country::query()->firstOrCreate(
|
||||||
|
['code' => 'DE'],
|
||||||
|
[
|
||||||
|
'phone' => '00',
|
||||||
|
'en' => 'Germany',
|
||||||
|
'de' => 'Deutschland',
|
||||||
|
'es' => 'Germany',
|
||||||
|
'fr' => 'Germany',
|
||||||
|
'it' => 'Germany',
|
||||||
|
'ru' => 'Germany',
|
||||||
|
'active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: Ingredient, 1: Ingredient}
|
||||||
|
*/
|
||||||
|
function p51MakeIngredients(): array
|
||||||
|
{
|
||||||
|
$base = [
|
||||||
|
'trans_name' => '',
|
||||||
|
'trans_inci' => '',
|
||||||
|
'effect' => '',
|
||||||
|
'trans_effect' => '',
|
||||||
|
'active' => true,
|
||||||
|
'pos' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$a = Ingredient::query()->create($base + ['name' => 'P51-Rohstoff-A', 'inci' => 'INCI-A', 'default_factor' => 1.10]);
|
||||||
|
$b = Ingredient::query()->create($base + ['name' => 'P51-Rohstoff-B', 'inci' => 'INCI-B', 'default_factor' => 1.10]);
|
||||||
|
|
||||||
|
return [$a, $b];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ingredient speichert und liefert rohstoffqualitaet', function () {
|
||||||
|
$quality = MaterialQuality::query()->create(['name' => 'bio kaltgepresst', 'pos' => 1]);
|
||||||
|
|
||||||
|
$ing = Ingredient::query()->create([
|
||||||
|
'name' => 'Sonnenblumenöl',
|
||||||
|
'trans_name' => '',
|
||||||
|
'inci' => 'Helianthus Annuus Seed Oil',
|
||||||
|
'trans_inci' => '',
|
||||||
|
'effect' => '',
|
||||||
|
'trans_effect' => '',
|
||||||
|
'active' => true,
|
||||||
|
'pos' => 0,
|
||||||
|
'material_quality_id' => $quality->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($ing->materialQuality)->not->toBeNull();
|
||||||
|
expect($ing->materialQuality->name)->toBe('bio kaltgepresst');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('produkt speichert hersteller-rezeptur getrennt von produkt-rezeptur', function () {
|
||||||
|
[$a, $b] = p51MakeIngredients();
|
||||||
|
|
||||||
|
$product = Product::query()->create([
|
||||||
|
'name' => 'P51-Produkt',
|
||||||
|
'title' => 'P51-Produkt',
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$repo = new ProductRepository($product);
|
||||||
|
$repo->update([
|
||||||
|
'id' => (string) $product->id,
|
||||||
|
'name' => $product->name,
|
||||||
|
'title' => $product->title,
|
||||||
|
'active' => '1',
|
||||||
|
// Produkt-Rezeptur
|
||||||
|
'product_inci_sync_sent' => '1',
|
||||||
|
'pi_ingredient_id' => [(string) $a->id],
|
||||||
|
'pi_gram' => ['80,000'],
|
||||||
|
'pi_factor' => ['1,10'],
|
||||||
|
// Hersteller-Rezeptur
|
||||||
|
'manufacturer_inci_sync_sent' => '1',
|
||||||
|
'mfg_ingredient_id' => [(string) $b->id],
|
||||||
|
'mfg_gram' => ['20,000'],
|
||||||
|
'mfg_factor' => ['1,20'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$product->refresh();
|
||||||
|
|
||||||
|
expect($product->p_ingredients)->toHaveCount(1);
|
||||||
|
expect($product->p_ingredients->first()->id)->toBe($a->id);
|
||||||
|
|
||||||
|
expect($product->manufacturer_ingredients)->toHaveCount(1);
|
||||||
|
$mfg = $product->manufacturer_ingredients->first();
|
||||||
|
expect($mfg->id)->toBe($b->id);
|
||||||
|
expect((float) $mfg->pivot->gram)->toBe(20.0);
|
||||||
|
expect((float) $mfg->pivot->factor)->toBe(1.20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('produkt-kopie uebernimmt beide rezepturen', function () {
|
||||||
|
[$a, $b] = p51MakeIngredients();
|
||||||
|
|
||||||
|
$source = Product::query()->create([
|
||||||
|
'name' => 'P51-Quelle',
|
||||||
|
'title' => 'P51-Quelle',
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProductIngredient::query()->create([
|
||||||
|
'product_id' => $source->id,
|
||||||
|
'ingredient_id' => $a->id,
|
||||||
|
'pos' => 0,
|
||||||
|
'gram' => 70,
|
||||||
|
'factor' => 1.10,
|
||||||
|
'recipe_type' => 'product',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProductIngredient::query()->create([
|
||||||
|
'product_id' => $source->id,
|
||||||
|
'ingredient_id' => $b->id,
|
||||||
|
'pos' => 0,
|
||||||
|
'gram' => 30,
|
||||||
|
'factor' => 1.15,
|
||||||
|
'recipe_type' => 'manufacturer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$repo = new ProductRepository(new Product);
|
||||||
|
$copy = $repo->copy($source->fresh());
|
||||||
|
|
||||||
|
expect($copy->p_ingredients()->count())->toBe(1);
|
||||||
|
expect($copy->manufacturer_ingredients()->count())->toBe(1);
|
||||||
|
|
||||||
|
$mfg = $copy->manufacturer_ingredients()->first();
|
||||||
|
expect($mfg->id)->toBe($b->id);
|
||||||
|
expect((float) $mfg->pivot->gram)->toBe(30.0);
|
||||||
|
expect((float) $mfg->pivot->factor)->toBe(1.15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('produktions-formular zeigt nur aktive produkte', function () {
|
||||||
|
p51EnsureCountry();
|
||||||
|
Location::factory()->create(['name' => 'Köln']);
|
||||||
|
$user = p51MakeUser(1);
|
||||||
|
|
||||||
|
Product::query()->create(['name' => 'P51-AKTIV-PROD', 'title' => 'P51-AKTIV-PROD', 'active' => true]);
|
||||||
|
Product::query()->create(['name' => 'P51-INAKTIV-PROD', 'title' => 'P51-INAKTIV-PROD', 'active' => false]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'user');
|
||||||
|
$response = $this->get(route('admin.inventory.productions.create'));
|
||||||
|
|
||||||
|
$response->assertSuccessful();
|
||||||
|
$response->assertSee('P51-AKTIV-PROD');
|
||||||
|
$response->assertDontSee('P51-INAKTIV-PROD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('produktion kann bearbeitet und kopiert werden (views rendern)', function () {
|
||||||
|
p51EnsureCountry();
|
||||||
|
$user = p51MakeUser(7);
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
|
||||||
|
$ing = Ingredient::query()->create([
|
||||||
|
'name' => 'P51-Prod-Roh',
|
||||||
|
'trans_name' => '',
|
||||||
|
'inci' => 'PR',
|
||||||
|
'trans_inci' => '',
|
||||||
|
'effect' => '',
|
||||||
|
'trans_effect' => '',
|
||||||
|
'active' => true,
|
||||||
|
'pos' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$product = Product::query()->create([
|
||||||
|
'name' => 'P51-Prod',
|
||||||
|
'title' => 'P51-Prod',
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProductIngredient::query()->create([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'ingredient_id' => $ing->id,
|
||||||
|
'pos' => 0,
|
||||||
|
'gram' => 10,
|
||||||
|
'factor' => 1.0,
|
||||||
|
'recipe_type' => 'product',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stock = StockEntry::query()->create([
|
||||||
|
'entry_type' => 'ingredient',
|
||||||
|
'ingredient_id' => $ing->id,
|
||||||
|
'packaging_item_id' => null,
|
||||||
|
'supplier_id' => $supplier->id,
|
||||||
|
'location_id' => $location->id,
|
||||||
|
'unit' => 'gram',
|
||||||
|
'ordered_by' => $user->id,
|
||||||
|
'ordered_at' => '2026-01-01',
|
||||||
|
'ordered_quantity' => 10000,
|
||||||
|
'price_per_kg' => 1,
|
||||||
|
'status' => 'received',
|
||||||
|
'received_by' => $user->id,
|
||||||
|
'received_at' => '2026-01-15',
|
||||||
|
'received_quantity' => 10000,
|
||||||
|
'batch_number' => 'P51-B',
|
||||||
|
'best_before' => '2030-12-31',
|
||||||
|
'quality_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$production = app(ProductionService::class)->store(
|
||||||
|
[
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'location_id' => $location->id,
|
||||||
|
'produced_at' => '2026-03-01',
|
||||||
|
'quantity' => 5,
|
||||||
|
'notes' => null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '50'],
|
||||||
|
],
|
||||||
|
$user->id
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.productions.edit', $production))->assertSuccessful();
|
||||||
|
$this->get(route('admin.inventory.productions.copy', $production))->assertSuccessful();
|
||||||
|
|
||||||
|
// Übersicht rendert mit Aktionsspalte (Ansicht, Bearbeiten, Kopieren)
|
||||||
|
$index = $this->get(route('admin.inventory.productions.index'));
|
||||||
|
$index->assertSuccessful();
|
||||||
|
$index->assertSee(route('admin.inventory.productions.show', $production), false);
|
||||||
|
$index->assertSee(route('admin.inventory.productions.edit', $production), false);
|
||||||
|
$index->assertSee(route('admin.inventory.productions.copy', $production), false);
|
||||||
|
});
|
||||||
203
tests/Feature/StockEntryPriceTest.php
Normal file
203
tests/Feature/StockEntryPriceTest.php
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Ingredient;
|
||||||
|
use App\Models\Location;
|
||||||
|
use App\Models\MaterialQuality;
|
||||||
|
use App\Models\StockEntry;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\Models\TaxRate;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function stockPriceAdmin(): User
|
||||||
|
{
|
||||||
|
$user = User::query()->create([
|
||||||
|
'email' => uniqid('sep_', true).'@test.example',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
$user->forceFill([
|
||||||
|
'admin' => 7,
|
||||||
|
'confirmed' => true,
|
||||||
|
'active' => true,
|
||||||
|
'wizard' => 100,
|
||||||
|
'blocked' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $user->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockPriceIngredient(): Ingredient
|
||||||
|
{
|
||||||
|
return Ingredient::query()->create(['name' => 'INCI-'.uniqid('', true), 'active' => true, 'pos' => 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('net price derives gross from tax rate on store', function () {
|
||||||
|
$admin = stockPriceAdmin();
|
||||||
|
$ingredient = stockPriceIngredient();
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$quality = MaterialQuality::factory()->create();
|
||||||
|
$tax = TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.stock-entries.store'), [
|
||||||
|
'entry_type' => 'ingredient',
|
||||||
|
'ingredient_id' => (string) $ingredient->id,
|
||||||
|
'supplier_id' => (string) $supplier->id,
|
||||||
|
'location_id' => (string) $location->id,
|
||||||
|
'quality_id' => (string) $quality->id,
|
||||||
|
'tax_rate_id' => (string) $tax->id,
|
||||||
|
'ordered_at' => '2026-06-01',
|
||||||
|
'ordered_quantity' => '1000',
|
||||||
|
'price_per_kg' => '10,00',
|
||||||
|
])->assertRedirect(route('admin.inventory.stock-entries.index'));
|
||||||
|
|
||||||
|
$entry = StockEntry::query()->latest('id')->firstOrFail();
|
||||||
|
|
||||||
|
expect((float) $entry->price_per_kg)->toBe(10.0)
|
||||||
|
->and((float) $entry->price_per_kg_gross)->toBe(11.9)
|
||||||
|
->and((float) $entry->tax_rate_percent)->toBe(19.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gross price derives net from tax rate on store', function () {
|
||||||
|
$admin = stockPriceAdmin();
|
||||||
|
$ingredient = stockPriceIngredient();
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$quality = MaterialQuality::factory()->create();
|
||||||
|
$tax = TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.stock-entries.store'), [
|
||||||
|
'entry_type' => 'ingredient',
|
||||||
|
'ingredient_id' => (string) $ingredient->id,
|
||||||
|
'supplier_id' => (string) $supplier->id,
|
||||||
|
'location_id' => (string) $location->id,
|
||||||
|
'quality_id' => (string) $quality->id,
|
||||||
|
'tax_rate_id' => (string) $tax->id,
|
||||||
|
'ordered_at' => '2026-06-01',
|
||||||
|
'ordered_quantity' => '1000',
|
||||||
|
'price_per_kg_gross' => '11,90',
|
||||||
|
])->assertRedirect(route('admin.inventory.stock-entries.index'));
|
||||||
|
|
||||||
|
$entry = StockEntry::query()->latest('id')->firstOrFail();
|
||||||
|
|
||||||
|
expect((float) $entry->price_per_kg)->toBe(10.0)
|
||||||
|
->and((float) $entry->price_per_kg_gross)->toBe(11.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without tax rate net equals gross', function () {
|
||||||
|
$admin = stockPriceAdmin();
|
||||||
|
$ingredient = stockPriceIngredient();
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$quality = MaterialQuality::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.stock-entries.store'), [
|
||||||
|
'entry_type' => 'ingredient',
|
||||||
|
'ingredient_id' => (string) $ingredient->id,
|
||||||
|
'supplier_id' => (string) $supplier->id,
|
||||||
|
'location_id' => (string) $location->id,
|
||||||
|
'quality_id' => (string) $quality->id,
|
||||||
|
'ordered_at' => '2026-06-01',
|
||||||
|
'ordered_quantity' => '500',
|
||||||
|
'price_per_kg' => '8,00',
|
||||||
|
])->assertRedirect(route('admin.inventory.stock-entries.index'));
|
||||||
|
|
||||||
|
$entry = StockEntry::query()->latest('id')->firstOrFail();
|
||||||
|
|
||||||
|
expect((float) $entry->price_per_kg)->toBe(8.0)
|
||||||
|
->and((float) $entry->price_per_kg_gross)->toBe(8.0)
|
||||||
|
->and($entry->tax_rate_percent)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingredient requires net or gross price', function () {
|
||||||
|
$admin = stockPriceAdmin();
|
||||||
|
$ingredient = stockPriceIngredient();
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$quality = MaterialQuality::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.stock-entries.store'), [
|
||||||
|
'entry_type' => 'ingredient',
|
||||||
|
'ingredient_id' => (string) $ingredient->id,
|
||||||
|
'supplier_id' => (string) $supplier->id,
|
||||||
|
'location_id' => (string) $location->id,
|
||||||
|
'quality_id' => (string) $quality->id,
|
||||||
|
'ordered_at' => '2026-06-01',
|
||||||
|
'ordered_quantity' => '500',
|
||||||
|
])->assertSessionHasErrors('price_per_kg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copy creates pending duplicate without receiving data', function () {
|
||||||
|
$admin = stockPriceAdmin();
|
||||||
|
$ingredient = stockPriceIngredient();
|
||||||
|
$location = Location::factory()->create();
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$quality = MaterialQuality::factory()->create();
|
||||||
|
$tax = TaxRate::query()->create(['name' => 'Standard', 'percent' => 19, 'active' => true, 'pos' => 0]);
|
||||||
|
|
||||||
|
$source = StockEntry::query()->create([
|
||||||
|
'entry_type' => 'ingredient',
|
||||||
|
'ingredient_id' => $ingredient->id,
|
||||||
|
'supplier_id' => $supplier->id,
|
||||||
|
'location_id' => $location->id,
|
||||||
|
'unit' => 'gram',
|
||||||
|
'ordered_by' => $admin->id,
|
||||||
|
'ordered_at' => '2026-01-01',
|
||||||
|
'ordered_quantity' => 1000,
|
||||||
|
'price_per_kg' => 10,
|
||||||
|
'price_per_kg_gross' => 11.9,
|
||||||
|
'tax_rate_id' => $tax->id,
|
||||||
|
'tax_rate_percent' => 19,
|
||||||
|
'quality_id' => $quality->id,
|
||||||
|
'received_at' => '2026-01-10',
|
||||||
|
'received_quantity' => 1000,
|
||||||
|
'batch_number' => 'CHARGE-1',
|
||||||
|
'best_before' => '2027-01-01',
|
||||||
|
'status' => 'received',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.stock-entries.copy', $source))
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$copy = StockEntry::query()->where('status', 'pending')->latest('id')->firstOrFail();
|
||||||
|
|
||||||
|
expect($copy->id)->not->toBe($source->id)
|
||||||
|
->and($copy->ingredient_id)->toBe($ingredient->id)
|
||||||
|
->and($copy->supplier_id)->toBe($supplier->id)
|
||||||
|
->and((float) $copy->price_per_kg)->toBe(10.0)
|
||||||
|
->and((float) $copy->price_per_kg_gross)->toBe(11.9)
|
||||||
|
->and($copy->quality_id)->toBe($quality->id)
|
||||||
|
->and($copy->batch_number)->toBeNull()
|
||||||
|
->and($copy->best_before)->toBeNull()
|
||||||
|
->and($copy->received_quantity)->toBeNull()
|
||||||
|
->and($copy->ordered_by)->toBe($admin->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copy is blocked for non admin', function () {
|
||||||
|
$admin = stockPriceAdmin();
|
||||||
|
$source = StockEntry::factory()->create(['ordered_by' => $admin->id]);
|
||||||
|
|
||||||
|
$copyreader = User::query()->create([
|
||||||
|
'email' => uniqid('cr_', true).'@test.example',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
$copyreader->forceFill(['admin' => 1, 'confirmed' => true, 'active' => true, 'wizard' => 100, 'blocked' => false])->save();
|
||||||
|
|
||||||
|
$this->actingAs($copyreader->fresh(), 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.stock-entries.copy', $source))
|
||||||
|
->assertRedirect(route('home'));
|
||||||
|
});
|
||||||
114
tests/Feature/SupplierDetailsTest.php
Normal file
114
tests/Feature/SupplierDetailsTest.php
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Ingredient;
|
||||||
|
use App\Models\PackagingItem;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function supplierDetailsUser(int $adminLevel): User
|
||||||
|
{
|
||||||
|
$user = User::query()->create([
|
||||||
|
'email' => uniqid('det_', true).'@test.example',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
$user->forceFill([
|
||||||
|
'admin' => $adminLevel,
|
||||||
|
'confirmed' => true,
|
||||||
|
'active' => true,
|
||||||
|
'wizard' => 100,
|
||||||
|
'blocked' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $user->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('supplier show displays assigned ingredients and packaging items', function () {
|
||||||
|
$supplier = Supplier::factory()->create(['name' => 'Detail-Lieferant']);
|
||||||
|
$ingredient = Ingredient::query()->create(['name' => 'Sheabutter', 'active' => true]);
|
||||||
|
$supplier->ingredients()->attach($ingredient->id);
|
||||||
|
$item = PackagingItem::factory()->create(['name' => 'Braunglas 50ml', 'supplier_id' => $supplier->id]);
|
||||||
|
|
||||||
|
$this->actingAs(supplierDetailsUser(7), 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.suppliers.show', $supplier))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Detail-Lieferant')
|
||||||
|
->assertSee('Sheabutter')
|
||||||
|
->assertSee('Braunglas 50ml')
|
||||||
|
->assertSee('Zugeordnete INCIs')
|
||||||
|
->assertSee('Zugeordnete Verpackungsartikel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingredient can be attached to supplier from details', function () {
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$ingredient = Ingredient::query()->create(['name' => 'Lavendelöl', 'active' => true]);
|
||||||
|
|
||||||
|
$this->actingAs(supplierDetailsUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.suppliers.ingredients.attach', $supplier), [
|
||||||
|
'ingredient_id' => $ingredient->id,
|
||||||
|
])->assertSuccessful()->assertSee('Lavendelöl');
|
||||||
|
|
||||||
|
expect($supplier->ingredients()->pluck('ingredients.id')->all())->toContain($ingredient->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ingredient can be detached from supplier', function () {
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$ingredient = Ingredient::query()->create(['name' => 'Jojobaöl', 'active' => true]);
|
||||||
|
$supplier->ingredients()->attach($ingredient->id);
|
||||||
|
|
||||||
|
$this->actingAs(supplierDetailsUser(7), 'user');
|
||||||
|
|
||||||
|
$this->delete(route('admin.inventory.suppliers.ingredients.detach', [$supplier, $ingredient]))
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect($supplier->ingredients()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('packaging item can be attached to supplier from details', function () {
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$item = PackagingItem::factory()->create(['name' => 'Pumpspender', 'supplier_id' => null]);
|
||||||
|
|
||||||
|
$this->actingAs(supplierDetailsUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.suppliers.packaging-items.attach', $supplier), [
|
||||||
|
'packaging_item_id' => $item->id,
|
||||||
|
])->assertSuccessful()->assertSee('Pumpspender');
|
||||||
|
|
||||||
|
expect($item->fresh()->supplier_id)->toBe($supplier->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('packaging item can be detached from supplier', function () {
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
$item = PackagingItem::factory()->create(['name' => 'Faltschachtel', 'supplier_id' => $supplier->id]);
|
||||||
|
|
||||||
|
$this->actingAs(supplierDetailsUser(7), 'user');
|
||||||
|
|
||||||
|
$this->delete(route('admin.inventory.suppliers.packaging-items.detach', [$supplier, $item]))
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect($item->fresh()->supplier_id)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attach ingredient validates ingredient exists', function () {
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs(supplierDetailsUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.suppliers.ingredients.attach', $supplier), [
|
||||||
|
'ingredient_id' => 999999,
|
||||||
|
])->assertSessionHasErrors('ingredient_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supplier details require admin level', function () {
|
||||||
|
$supplier = Supplier::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs(supplierDetailsUser(1), 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.suppliers.show', $supplier))
|
||||||
|
->assertRedirect();
|
||||||
|
});
|
||||||
176
tests/Feature/SupplierOrderFieldsTest.php
Normal file
176
tests/Feature/SupplierOrderFieldsTest.php
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Country;
|
||||||
|
use App\Models\DeliveryTime;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function supplierOrderUser(int $adminLevel): User
|
||||||
|
{
|
||||||
|
$user = User::query()->create([
|
||||||
|
'email' => uniqid('sup_', true).'@test.example',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
$user->forceFill([
|
||||||
|
'admin' => $adminLevel,
|
||||||
|
'confirmed' => true,
|
||||||
|
'active' => true,
|
||||||
|
'wizard' => 100,
|
||||||
|
'blocked' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $user->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function supplierOrderCountry(): Country
|
||||||
|
{
|
||||||
|
return Country::query()->firstOrCreate(
|
||||||
|
['code' => 'DE'],
|
||||||
|
[
|
||||||
|
'phone' => '00',
|
||||||
|
'en' => 'Germany',
|
||||||
|
'de' => 'Deutschland',
|
||||||
|
'es' => 'Germany',
|
||||||
|
'fr' => 'Germany',
|
||||||
|
'it' => 'Germany',
|
||||||
|
'ru' => 'Germany',
|
||||||
|
'active' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('supplier form shows order fields and active delivery time templates', function () {
|
||||||
|
supplierOrderCountry();
|
||||||
|
DeliveryTime::query()->create(['label' => '3–5 Werktage', 'active' => true, 'pos' => 0]);
|
||||||
|
DeliveryTime::query()->create(['label' => 'Inaktiv-Vorlage', 'active' => false, 'pos' => 1]);
|
||||||
|
|
||||||
|
$this->actingAs(supplierOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.suppliers.create'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('order_method')
|
||||||
|
->assertSee('delivery_time')
|
||||||
|
->assertSee('3–5 Werktage')
|
||||||
|
->assertDontSee('Inaktiv-Vorlage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supplier can be stored with email order method', function () {
|
||||||
|
$country = supplierOrderCountry();
|
||||||
|
$this->actingAs(supplierOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.suppliers.store'), [
|
||||||
|
'name' => 'Mail-Lieferant',
|
||||||
|
'country_id' => (string) $country->id,
|
||||||
|
'active' => '1',
|
||||||
|
'order_method' => 'email',
|
||||||
|
'order_email' => 'bestellung@example.test',
|
||||||
|
'delivery_time' => '3–5 Werktage',
|
||||||
|
'delivery_time_days' => '5',
|
||||||
|
])->assertRedirect(route('admin.inventory.suppliers.index'));
|
||||||
|
|
||||||
|
$supplier = Supplier::query()->where('name', 'Mail-Lieferant')->firstOrFail();
|
||||||
|
|
||||||
|
expect($supplier->order_method)->toBe('email')
|
||||||
|
->and($supplier->order_email)->toBe('bestellung@example.test')
|
||||||
|
->and($supplier->delivery_time)->toBe('3–5 Werktage')
|
||||||
|
->and($supplier->delivery_time_days)->toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supplier delivery time days must be an integer', function () {
|
||||||
|
$country = supplierOrderCountry();
|
||||||
|
$this->actingAs(supplierOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.suppliers.store'), [
|
||||||
|
'name' => 'Falsche-Tage',
|
||||||
|
'country_id' => (string) $country->id,
|
||||||
|
'active' => '1',
|
||||||
|
'delivery_time_days' => 'viele',
|
||||||
|
])->assertSessionHasErrors('delivery_time_days');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supplier form exposes delivery time template days for autofill', function () {
|
||||||
|
supplierOrderCountry();
|
||||||
|
DeliveryTime::query()->create(['label' => '3–5 Werktage', 'days' => 5, 'active' => true, 'pos' => 0]);
|
||||||
|
|
||||||
|
$this->actingAs(supplierOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.suppliers.create'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('data-days="5"', false)
|
||||||
|
->assertSee('delivery_time_days');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supplier can be stored with online shop order method', function () {
|
||||||
|
$country = supplierOrderCountry();
|
||||||
|
$this->actingAs(supplierOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.suppliers.store'), [
|
||||||
|
'name' => 'Shop-Lieferant',
|
||||||
|
'country_id' => (string) $country->id,
|
||||||
|
'active' => '1',
|
||||||
|
'order_method' => 'online_shop',
|
||||||
|
'order_url' => 'https://shop.example.test/order?id=5&ref=abc',
|
||||||
|
])->assertRedirect(route('admin.inventory.suppliers.index'));
|
||||||
|
|
||||||
|
$supplier = Supplier::query()->where('name', 'Shop-Lieferant')->firstOrFail();
|
||||||
|
|
||||||
|
expect($supplier->order_method)->toBe('online_shop')
|
||||||
|
->and($supplier->order_url)->toBe('https://shop.example.test/order?id=5&ref=abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supplier order fields can be updated', function () {
|
||||||
|
$country = supplierOrderCountry();
|
||||||
|
$supplier = Supplier::query()->create([
|
||||||
|
'name' => 'Update-Lieferant',
|
||||||
|
'country_id' => $country->id,
|
||||||
|
'active' => true,
|
||||||
|
'order_method' => 'email',
|
||||||
|
'order_email' => 'alt@example.test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs(supplierOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->put(route('admin.inventory.suppliers.update', $supplier), [
|
||||||
|
'name' => 'Update-Lieferant',
|
||||||
|
'country_id' => (string) $country->id,
|
||||||
|
'active' => '1',
|
||||||
|
'order_method' => 'online_shop',
|
||||||
|
'order_url' => 'https://neu.example.test',
|
||||||
|
'delivery_time' => '1–2 Wochen',
|
||||||
|
])->assertRedirect(route('admin.inventory.suppliers.index'));
|
||||||
|
|
||||||
|
$supplier->refresh();
|
||||||
|
|
||||||
|
expect($supplier->order_method)->toBe('online_shop')
|
||||||
|
->and($supplier->order_url)->toBe('https://neu.example.test')
|
||||||
|
->and($supplier->delivery_time)->toBe('1–2 Wochen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supplier order method rejects invalid value', function () {
|
||||||
|
$country = supplierOrderCountry();
|
||||||
|
$this->actingAs(supplierOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.suppliers.store'), [
|
||||||
|
'name' => 'Falscher-Bestellweg',
|
||||||
|
'country_id' => (string) $country->id,
|
||||||
|
'active' => '1',
|
||||||
|
'order_method' => 'fax',
|
||||||
|
])->assertSessionHasErrors('order_method');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supplier order email must be valid email', function () {
|
||||||
|
$country = supplierOrderCountry();
|
||||||
|
$this->actingAs(supplierOrderUser(7), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.suppliers.store'), [
|
||||||
|
'name' => 'Falsche-Mail',
|
||||||
|
'country_id' => (string) $country->id,
|
||||||
|
'active' => '1',
|
||||||
|
'order_method' => 'email',
|
||||||
|
'order_email' => 'keine-mail',
|
||||||
|
])->assertSessionHasErrors('order_email');
|
||||||
|
});
|
||||||
111
tests/Feature/TaxRateSettingsTest.php
Normal file
111
tests/Feature/TaxRateSettingsTest.php
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\TaxRate;
|
||||||
|
use App\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function taxRateUser(int $adminLevel = 8): User
|
||||||
|
{
|
||||||
|
$user = User::query()->create([
|
||||||
|
'email' => uniqid('tax_', true).'@test.example',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
$user->forceFill([
|
||||||
|
'admin' => $adminLevel,
|
||||||
|
'confirmed' => true,
|
||||||
|
'active' => true,
|
||||||
|
'wizard' => 100,
|
||||||
|
'blocked' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $user->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('allgemein-seite rendert und zeigt umsatzsteuersaetze', function () {
|
||||||
|
$this->actingAs(taxRateUser(8), 'user');
|
||||||
|
|
||||||
|
TaxRate::create(['name' => 'Standard', 'percent' => 19.00, 'active' => true, 'pos' => 0]);
|
||||||
|
|
||||||
|
$response = $this->get(route('admin.inventory.general'));
|
||||||
|
|
||||||
|
$response->assertSuccessful();
|
||||||
|
$response->assertSee('Umsatzsteuersätze', false);
|
||||||
|
$response->assertSee('Standard');
|
||||||
|
$response->assertSee(route('admin.inventory.tax-rates.create'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('superadmin legt umsatzsteuersatz an', function () {
|
||||||
|
$this->actingAs(taxRateUser(8), 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.tax-rates.create'))->assertSuccessful();
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.tax-rates.store'), [
|
||||||
|
'name' => 'Standard',
|
||||||
|
'percent' => '19',
|
||||||
|
'active' => '1',
|
||||||
|
'pos' => 0,
|
||||||
|
])->assertRedirect(route('admin.inventory.general'));
|
||||||
|
|
||||||
|
$taxRate = TaxRate::query()->where('name', 'Standard')->firstOrFail();
|
||||||
|
expect((float) $taxRate->percent)->toBe(19.00);
|
||||||
|
expect($taxRate->active)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('umsatzsteuersatz kann bearbeitet und deaktiviert werden', function () {
|
||||||
|
$this->actingAs(taxRateUser(8), 'user');
|
||||||
|
|
||||||
|
$taxRate = TaxRate::create(['name' => 'Ermäßigt', 'percent' => 7.00, 'active' => true, 'pos' => 1]);
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.tax-rates.edit', $taxRate))->assertSuccessful();
|
||||||
|
|
||||||
|
$this->put(route('admin.inventory.tax-rates.update', $taxRate), [
|
||||||
|
'name' => 'Ermäßigt (alt)',
|
||||||
|
'percent' => '7',
|
||||||
|
'pos' => 1,
|
||||||
|
])->assertRedirect(route('admin.inventory.general'));
|
||||||
|
|
||||||
|
$taxRate->refresh();
|
||||||
|
expect($taxRate->name)->toBe('Ermäßigt (alt)');
|
||||||
|
expect($taxRate->active)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('umsatzsteuersatz kann geloescht werden', function () {
|
||||||
|
$this->actingAs(taxRateUser(8), 'user');
|
||||||
|
|
||||||
|
$taxRate = TaxRate::create(['name' => 'Steuerfrei', 'percent' => 0.00, 'active' => true, 'pos' => 2]);
|
||||||
|
|
||||||
|
$this->delete(route('admin.inventory.tax-rates.destroy', $taxRate))
|
||||||
|
->assertRedirect(route('admin.inventory.general'));
|
||||||
|
|
||||||
|
expect(TaxRate::query()->whereKey($taxRate->id)->exists())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prozentsatz ist pflicht und muss numerisch sein', function () {
|
||||||
|
$this->actingAs(taxRateUser(8), 'user');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.tax-rates.store'), [
|
||||||
|
'name' => 'Ohne Satz',
|
||||||
|
'percent' => '',
|
||||||
|
])->assertSessionHasErrors('percent');
|
||||||
|
|
||||||
|
$this->post(route('admin.inventory.tax-rates.store'), [
|
||||||
|
'name' => 'Zu hoch',
|
||||||
|
'percent' => '150',
|
||||||
|
])->assertSessionHasErrors('percent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nur aktive saetze ueber active-scope', function () {
|
||||||
|
TaxRate::create(['name' => 'Aktiv', 'percent' => 19.00, 'active' => true, 'pos' => 0]);
|
||||||
|
TaxRate::create(['name' => 'Inaktiv', 'percent' => 16.00, 'active' => false, 'pos' => 1]);
|
||||||
|
|
||||||
|
expect(TaxRate::query()->active()->pluck('name')->all())->toBe(['Aktiv']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nicht-superadmin hat keinen zugriff auf allgemein-seite', function () {
|
||||||
|
$this->actingAs(taxRateUser(7), 'user');
|
||||||
|
|
||||||
|
$this->get(route('admin.inventory.general'))->assertRedirect('/home');
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue