Warenwirtschaft: AP-21 INCI-Erweiterungen

- Lagerort-Stammdaten Raum/Regal/Fach (storage_rooms/shelves/slots) mit CRUD
  auf Einstellungen -> Allgemein (SettingCrudController-Kinder, ein gemeinsamer
  StoreStorageSegmentRequest, generische Partials setting-list-card/-segment-form)
- Ingredient: alt_name (Handelsname), Lagerort-FKs + storageLabel(),
  scopeUsedInActiveRecipes()
- INCI-Formular: Lieferanten-Repeater mit Shop-URL je Lieferant (leer = Mail),
  Alternativname, drei Lagerort-Dropdowns
- INCI-Liste: Filter "nur aktive (in Rezeptur aktiver Produkte)" + Spalte
  Alternativer Name
- Rohstoffbestand-Detail: Bestellweg pro INCI-Lieferant (Pivot-URL statt
  order_method-Fallback), Kopf zeigt Alternativname + Lagerort
- Hinweise-Doku fortgeschrieben inkl. Chargen-Format-Entscheid ttmmjj (AP-24)

Tests: StorageSettingsTest, IngredientActiveRecipeFilterTest,
IngredientOrderFieldsTest (Repeater-Format), RawMaterialStockTest erweitert
(volle Suite 170 gruen, 613 Assertions)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-06 11:36:28 +00:00
parent 8c90d95c69
commit d05df498d3
34 changed files with 1091 additions and 45 deletions

View file

@ -107,6 +107,19 @@
Korrekturen erfolgen ausschließlich über Gegenbuchungen (`+`/`−`) – Einträge
der Historie werden nicht gelöscht. Verkaufsabgänge folgen mit der
Shop-Anbindung.
- **Bestellweg je INCI-Lieferant:** Am Inhaltsstoff wird pro Lieferant eine
Shop-URL gepflegt („+ weiteren Lieferanten anlegen"). Ist die URL **leer**,
gilt für diesen Lieferanten **Bestellung per Mail** – der Rohstoffbestand
zeigt entsprechend „Zum Shop" (mit der hinterlegten URL) oder „Per Mail".
- **Alternativer Name am INCI:** Handelsname der Packung (z. B. LECITHIN →
Phospholipon 80 H), sichtbar in der INCI-Liste und im Rohstoffbestand-Detail,
damit Mitarbeiter den Rohstoff finden.
- **Lagerorte** folgen dem Schema **Raum | Regal | Fach/Buchstabe**. Die
Bausteine werden zentral unter **Einstellungen → Allgemein** gepflegt und am
INCI ausgewählt; Änderungen an einem Baustein wirken übergreifend.
- **INCI-Liste „nur aktive":** Der Filter zeigt nur Inhaltsstoffe, die in der
**Hersteller-Rezeptur mindestens eines aktiven Produkts** vorkommen – nicht zu
verwechseln mit dem Aktiv-Häkchen am INCI selbst.
---
@ -123,6 +136,11 @@ später erweitert werden:
- **Blockbasierte Rechte** gelten zunächst **nur für Warenwirtschaft und
Produktmanagement**. Bei Bedarf können sie später auf weitere Bereiche
ausgebaut werden.
- **Chargen-Nr.-Format (Kundenentscheid 06.07.2026):** Präfix auf Produktebene
(Kategorie-Kürzel + Produkt-Nr., z. B. `TP1`) + Produktionsdatum als
`ttmmjj` – Beispiel `TP1150626` = Charge vom 15.06.2026. Die Chargen-Nr. wird
in der Produktion vorbefüllt, bleibt aber editierbar (alte Etiketten).
Umsetzung folgt mit dem Chargen-Präfix-Paket (AP-24).
---

View file

@ -39,13 +39,24 @@
@section('scripts')
<script>
$(document).ready(function() {
$('#supplier_ids').select2({
theme: 'default',
placeholder: '{{ __('Lieferanten wählen') }}',
width: '100%',
closeOnSelect: false
var supplierRepeater = $('#supplier-repeater');
var supplierRowIndex = supplierRepeater.find('.supplier-row').length;
function addSupplierRow() {
var html = $('#supplier-row-template').html().replace(/__INDEX__/g, supplierRowIndex++);
supplierRepeater.append(html);
}
$('#add-supplier-row').on('click', addSupplierRow);
supplierRepeater.on('click', '.js-remove-supplier-row', function() {
$(this).closest('.supplier-row').remove();
});
if (supplierRepeater.find('.supplier-row').length === 0) {
addSupplierRow();
}
$('#delivery_time').on('input change', function() {
var value = $(this).val();
var option = $('#ingredient_delivery_time_options option').filter(function() {

View file

@ -19,6 +19,14 @@
</div>
</div>
<div class="form-row">
<div class="form-group col-sm-6">
<label class="form-label" for="alt_name">{{ __('Alternativer Name') }}</label>
{{ Form::text('alt_name', $model->alt_name, array('placeholder'=>__('z. B. Phospholipon 80 H'), 'class'=>'form-control', 'id'=>'alt_name', 'maxlength'=>255)) }}
<small class="text-muted">{{ __('Handelsname auf der Packung, damit Mitarbeiter den Rohstoff finden (z. B. LECITHIN → Phospholipon 80 H).') }}</small>
</div>
</div>
<div class="form-row">
<div class="form-group col">
<label class="form-label" for="headline">{{ __('INCI') }}</label>
@ -54,22 +62,89 @@
</div>
@php
$selectedSupplierIds = old(
'supplier_ids',
$model->exists ? $model->suppliers->pluck('id')->all() : [],
);
$supplierRows = collect(old(
'suppliers',
$model->exists
? $model->suppliers->map(fn ($s) => ['id' => $s->id, 'url' => $s->pivot->url])->values()->all()
: [],
))->values();
@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') }}">
<div class="form-group">
<label class="form-label">{{ __('Lieferanten') }}</label>
<div id="supplier-repeater">
@foreach($supplierRows as $index => $row)
<div class="form-row supplier-row mb-2">
<div class="col-sm-5">
<select name="suppliers[{{ $index }}][id]" class="form-control">
<option value="">{{ __('— Lieferant wählen —') }}</option>
@foreach($suppliers as $supplier)
<option value="{{ $supplier->id }}" @selected((string) ($row['id'] ?? '') === (string) $supplier->id)>{{ $supplier->name }}</option>
@endforeach
</select>
</div>
<div class="col-sm-6">
<input type="text" name="suppliers[{{ $index }}][url]" class="form-control"
value="{{ $row['url'] ?? '' }}" maxlength="2048"
placeholder="{{ __('Shop-URL (leer = Bestellung per Mail)') }}">
</div>
<div class="col-sm-1 d-flex align-items-center">
<button type="button" class="btn btn-sm btn-outline-danger js-remove-supplier-row" title="{{ __('Entfernen') }}">&times;</button>
</div>
</div>
@endforeach
</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="add-supplier-row">+ {{ __('weiteren Lieferanten anlegen') }}</button>
<small class="text-muted d-block mt-1">{{ __('Bleibt die URL leer, wird bei diesem Lieferanten per Mail bestellt.') }}</small>
</div>
<script type="text/template" id="supplier-row-template">
<div class="form-row supplier-row mb-2">
<div class="col-sm-5">
<select name="suppliers[__INDEX__][id]" class="form-control">
<option value="">{{ __('— Lieferant wählen —') }}</option>
@foreach($suppliers as $supplier)
<option value="{{ $supplier->id }}" @selected(in_array($supplier->id, $selectedSupplierIds, false))>{{ $supplier->name }}</option>
<option value="{{ $supplier->id }}">{{ $supplier->name }}</option>
@endforeach
</select>
</div>
<div class="col-sm-6">
<input type="text" name="suppliers[__INDEX__][url]" class="form-control" maxlength="2048"
placeholder="{{ __('Shop-URL (leer = Bestellung per Mail)') }}">
</div>
<div class="col-sm-1 d-flex align-items-center">
<button type="button" class="btn btn-sm btn-outline-danger js-remove-supplier-row" title="{{ __('Entfernen') }}">&times;</button>
</div>
</div>
</script>
<div class="form-row">
<div class="form-group col-sm-4">
<label class="form-label" for="storage_room_id">{{ __('Lagerort: Raum') }}</label>
<select name="storage_room_id" id="storage_room_id" class="form-control">
<option value="">{{ __('— keine Angabe —') }}</option>
@foreach($storageRooms as $room)
<option value="{{ $room->id }}" @selected((string) old('storage_room_id', $model->storage_room_id) === (string) $room->id)>{{ $room->label }}</option>
@endforeach
</select>
</div>
<div class="form-group col-sm-4">
<label class="form-label" for="storage_shelf_id">{{ __('Lagerort: Regal') }}</label>
<select name="storage_shelf_id" id="storage_shelf_id" class="form-control">
<option value="">{{ __('— keine Angabe —') }}</option>
@foreach($storageShelves as $shelf)
<option value="{{ $shelf->id }}" @selected((string) old('storage_shelf_id', $model->storage_shelf_id) === (string) $shelf->id)>{{ $shelf->label }}</option>
@endforeach
</select>
</div>
<div class="form-group col-sm-4">
<label class="form-label" for="storage_slot_id">{{ __('Lagerort: Fach / Buchstabe') }}</label>
<select name="storage_slot_id" id="storage_slot_id" class="form-control">
<option value="">{{ __('— keine Angabe —') }}</option>
@foreach($storageSlots as $slot)
<option value="{{ $slot->id }}" @selected((string) old('storage_slot_id', $model->storage_slot_id) === (string) $slot->id)>{{ $slot->label }}</option>
@endforeach
</select>
<small class="text-muted">{{ __('Bausteine zentral pflegbar unter Einstellungen → Allgemein.') }}</small>
</div>
</div>

View file

@ -2,8 +2,15 @@
@section('content')
<div class="card">
<h6 class="card-header">
<h6 class="card-header d-flex justify-content-between align-items-center">
{{__('Inhaltsstoffe')}}
<form method="get" action="{{ route('admin_product_ingredients') }}" class="mb-0">
<label class="custom-control custom-checkbox mb-0">
<input type="checkbox" name="only_active" value="1" class="custom-control-input"
onchange="this.form.submit()" @checked($onlyActive)>
<span class="custom-control-label">{{ __('nur aktive (in Rezeptur aktiver Produkte)') }}</span>
</label>
</form>
</h6>
<div class="card-datatable table-responsive">
<table class="datatables-style table table-striped table-bordered">
@ -12,6 +19,7 @@
<th style="max-width: 60px;">&nbsp;</th>
<th>{{__('Pos')}}</th>
<th>{{__('Name')}}</th>
<th>{{__('Alternativer Name')}}</th>
<th>{{__('INCI')}}</th>
<th>{{__('Wirkung') }}</th>
<th>{{__('Status')}}</th>
@ -28,6 +36,7 @@
</td>
<td>{{ $value->pos }}</td>
<td>{{ $value->name }}</td>
<td>{{ $value->alt_name }}</td>
<td>{{ $value->inci }}</td>
<td>{{ $value->effect }}</td>
<td data-sort="{{ $value->active }}">@if($value->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>
@ -55,13 +64,14 @@
"iDisplayLength": 100,
"order": [[ 1, "asc" ]],
"aoColumns": [
{ "sWidth": "6%" },
{ "sWidth": "6%" },
{ "sWidth": "16%" },
{ "sWidth": "14%" },
{ "sWidth": "17%" },
{ "sWidth": "25%" },
{ "sWidth": "8%" },
{ "sWidth": "8%" },
{ "sWidth": "19%" },
{ "sWidth": "19%" },
{ "sWidth": "30%" },
{ "sWidth": "10%" },
{ "sWidth": "8%" },
],
"language": {
"url": "/js/German.json"

View file

@ -7,7 +7,7 @@
<div class="wawi-page-head">
<div>
<h1 class="wawi-page-head__title">{{ __('Einstellungen') }}</h1>
<p class="wawi-page-head__subtitle">{{ __('Umsatzsteuersätze, Lieferzeit-Vorlagen und Ausschuss-Gründe') }}</p>
<p class="wawi-page-head__subtitle">{{ __('Umsatzsteuersätze, Lieferzeit-Vorlagen, Ausschuss-Gründe und Lagerorte') }}</p>
</div>
</div>
@ -172,5 +172,26 @@
</table>
</div>
</div>
@include('admin.inventory.partials.setting-list-card', [
'title' => __('Lagerorte: Räume'),
'routePrefix' => 'admin.inventory.storage-rooms',
'values' => $storageRooms,
'emptyText' => __('Noch keine Räume angelegt.'),
])
@include('admin.inventory.partials.setting-list-card', [
'title' => __('Lagerorte: Regale'),
'routePrefix' => 'admin.inventory.storage-shelves',
'values' => $storageShelves,
'emptyText' => __('Noch keine Regale angelegt.'),
])
@include('admin.inventory.partials.setting-list-card', [
'title' => __('Lagerorte: Fächer / Buchstaben'),
'routePrefix' => 'admin.inventory.storage-slots',
'values' => $storageSlots,
'emptyText' => __('Noch keine Fächer angelegt.'),
])
</div>
@endsection

View file

@ -0,0 +1,52 @@
{{-- Generische Stammdaten-Karte (Bezeichnung/Status) für Einstellungen → Allgemein:
$title, $routePrefix, $values, $emptyText --}}
<div class="wawi-card">
<div class="wawi-card__header">
<span>{{ $title }}</span>
<a href="{{ route($routePrefix.'.create') }}" class="btn btn-sm btn-primary">{{ __('Neu anlegen') }}</a>
</div>
<div class="table-responsive">
<table class="table wawi-table">
<thead>
<tr>
<th style="max-width: 60px;">&nbsp;</th>
<th>{{ __('Bezeichnung') }}</th>
<th>{{ __('Status') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@forelse($values as $value)
<tr>
<td>
<a href="{{ route($routePrefix.'.edit', $value) }}" class="btn icon-btn btn-sm btn-primary">
<span class="far fa-edit"></span>
</a>
</td>
<td>{{ $value->label }}</td>
<td>
@if ($value->active)
<span class="wawi-pill wawi-pill--ok">{{ __('Aktiv') }}</span>
@else
<span class="wawi-pill wawi-pill--danger">{{ __('Inaktiv') }}</span>
@endif
</td>
<td>
<form action="{{ route($routePrefix.'.destroy', $value) }}" 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="4"><div class="wawi-empty">{{ $emptyText }}</div></td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,43 @@
{{-- Gemeinsames Formular der Lagerort-Bausteine (AP-21): $title, $routePrefix, $placeholder, $model --}}
<div class="card">
<h6 class="card-header">{{ $model->exists ? __(':title bearbeiten', ['title' => $title]) : __(':title anlegen', ['title' => $title]) }}</h6>
<div class="card-body">
<form method="post" action="{{ $model->exists ? route($routePrefix.'.update', $model) : route($routePrefix.'.store') }}">
@csrf
@if($model->exists)
@method('PUT')
@endif
<div class="form-group">
<label for="label">{{ __('Bezeichnung') }}</label>
<input type="text" name="label" id="label" maxlength="100"
class="form-control @error('label') is-invalid @enderror"
value="{{ old('label', $model->label) }}" placeholder="{{ $placeholder }}" required>
@error('label')
<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>

View file

@ -19,7 +19,12 @@
<h1 class="wawi-page-head__title">
{{ $ingredient->name }} {!! $statusPill !!}
</h1>
<p class="wawi-page-head__subtitle">{{ __('Rohstoffbestellung & Bestand') }}</p>
<p class="wawi-page-head__subtitle">
{{ __('Rohstoffbestellung & Bestand') }}
@if($ingredient->alt_name)
· {{ __('Alternativer Name') }}: {{ $ingredient->alt_name }}
@endif
</p>
</div>
<div class="wawi-page-head__actions">
<a href="{{ route('admin.inventory.raw-material-stock.index') }}" class="btn btn-outline-secondary btn-sm">
@ -70,10 +75,15 @@
{{ __('Qualität') }}: <strong>{{ $ingredient->materialQuality?->name ?? '—' }}</strong>
</span>
@if($ingredient->min_stock_alert !== null)
<span class="text-muted small">
<span class="text-muted small mr-3">
{{ __('Meldebestand') }}: <strong>{{ \App\Services\Util::formatNumber($ingredient->min_stock_alert, 0) }} g</strong>
</span>
@endif
@if($ingredient->storageLabel())
<span class="text-muted small">
{{ __('Lagerort') }}: <strong>{{ $ingredient->storageLabel() }}</strong>
</span>
@endif
</div>
</div>
@ -128,7 +138,8 @@
@php
$deliveryTime = $ingredient->delivery_time ?: $supplier->delivery_time;
$lastPrice = $lastPriceBySupplier[$supplier->id] ?? null;
$orderUrl = $supplier->order_url ?: $supplier->url;
// AP-21: Bestellweg pro INCI-Lieferant — Pivot-URL gesetzt ⇒ Shop, leer ⇒ Mail.
$orderUrl = $supplier->pivot->url;
$orderEmail = $supplier->order_email ?: $supplier->email;
@endphp
<tr>
@ -150,12 +161,10 @@
@endif
</td>
<td class="text-right">
@if($supplier->order_method === 'online_shop' && $orderUrl)
@if($orderUrl)
<a href="{{ $orderUrl }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-secondary">{{ __('Zum Shop') }}</a>
@elseif($orderEmail)
<a href="mailto:{{ $orderEmail }}?subject={{ rawurlencode(__('Bestellung') . ': ' . $ingredient->name) }}" class="btn btn-sm btn-outline-secondary">{{ __('Per Mail') }}</a>
@elseif($orderUrl)
<a href="{{ $orderUrl }}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-secondary">{{ __('Zum Shop') }}</a>
@else
<span class="text-muted">—</span>
@endif

View file

@ -0,0 +1,9 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.setting-segment-form', [
'title' => __('Raum'),
'routePrefix' => 'admin.inventory.storage-rooms',
'placeholder' => __('z. B. Raum 1'),
])
@endsection

View file

@ -0,0 +1,9 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.setting-segment-form', [
'title' => __('Regal'),
'routePrefix' => 'admin.inventory.storage-shelves',
'placeholder' => __('z. B. Regal 3'),
])
@endsection

View file

@ -0,0 +1,9 @@
@extends('layouts.layout-2')
@section('content')
@include('admin.inventory.partials.setting-segment-form', [
'title' => __('Fach / Buchstabe'),
'routePrefix' => 'admin.inventory.storage-slots',
'placeholder' => __('z. B. A'),
])
@endsection