Warenwirtschaft: AP-09 bis AP-13 (Produktbestand, Set-Produkte, Ausschuss, Konzepte)

- AP-09 Produktbestand inkl. Bewegungshistorie (product_stock_movements, ProductStockService)
- AP-10 Rohstoffbestand-Ansicht je Lager (RawMaterialStockController)
- AP-11 Bestandsschwellen / Out-of-Stock-Handling fuer Produkte und Shop
- AP-12 Ausgang/Ausschuss (stock_disposals, StockDisposalController, InventoryService)
- Set-Produkte (product_set_items) inkl. Aufloesung
- Produktentwicklung & Hinweise-Verwaltung (Notices)
- AP-13 Entwicklungskonzept Shop-Bestandsabzug im Plan dokumentiert
- Feature-Tests fuer neue Module + aktualisierter Entwicklungsplan

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

View file

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