Warenwirtschaft: Anforderungsrunde 12.06. — Plan V5.0 + AP-26/AP-25/AP-22

Neue Anforderungen (docs/) interpretiert und als Entwicklungsplan V5.0
(AP-20 bis AP-28) aufgenommen; erste drei Pakete umgesetzt:

AP-26 Ausschuss-Gründe konfigurierbar:
- Stammdaten-Tabelle disposal_reasons + CRUD unter Einstellungen → Allgemein
- StockDisposalController liest aktive DB-Gründe statt hartkodierter Liste
- Seeder übernimmt die bisherigen 6 Gründe idempotent

AP-25 Lieferbestand — Datum statt Tage:
- "Nicht vorrätig" wird über Datepicker "Wieder lieferbar ab" gepflegt;
  Resttage-Hinweis zählt täglich automatisch herunter
- Interne Bestellliste wieder kaufbar: Hinweis erscheint zusätzlich zu
  den Mengen-Buttons (VP entscheidet selbst)

AP-22 Produktbestand-Erweiterungen:
- Default-Sortierung nach Dringlichkeit, Status-Kopf toggelt
- Alle vier Status-Kacheln als Filter klickbar
- Neue Spalte "Verbrauch/Monat" (Ø Abgänge der letzten 6 Monate)
- Produkt-Flag "Im Produktbestand anzeigen" (products.show_in_product_stock)

Tests: 77 grün (DisposalReasonSettings 8, ProductOutOfStock 8,
ProductStock 13 + Regression). Hinweise-Doku + Plan-Protokoll fortgeschrieben;
nächster Schritt laut Plan: AP-21 (INCI-Erweiterungen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 16:28:45 +00:00
parent a8f6fef38e
commit e53201f229
32 changed files with 1377 additions and 94 deletions

View file

@ -4,7 +4,7 @@
> wichtige Hinweise für die Nutzung sowie festgehaltene Entscheidungen, die
> später noch ausgebaut werden können.
>
> **Stand:** 03.06.2026
> **Stand:** 12.06.2026
---
@ -12,8 +12,9 @@
### Bereits nutzbar
- **Einstellungen → Allgemein:** Umsatzsteuersätze und Lieferzeit-Vorlagen
(inkl. Tageswert) pflegbar.
- **Einstellungen → Allgemein:** Umsatzsteuersätze, Lieferzeit-Vorlagen
(inkl. Tageswert) und **Ausschuss-Gründe** pflegbar. Die Ausschuss-Gründe
erscheinen im Ausschuss-Formular (nur aktive, in gepflegter Reihenfolge).
- **Stammdaten:** Lagerorte, Rohstoffqualität, Verpackungsmaterial,
Lieferanten-Kategorien.
- **Lieferanten:** Bestellweg (E-Mail / Online-Shop), Bestell-Adresse,
@ -28,11 +29,12 @@
- **Produkt-Klassen:** Einzelprodukt vs. **Set** (Bündel mehrerer Einzelprodukte
mit Menge). Sets werden nicht produziert; optional kann ein Einzelprodukt einem
Hauptprodukt zugeordnet werden.
- **„Nicht vorrätig":** Produkt zeitlich begrenzt (mit Tagesangabe →
„In ca. X Tagen wieder da!") oder auf unbestimmte Zeit als nicht vorrätig
markierbar. Im öffentlichen Shop erscheint nur ein Hinweis, der Kauf bleibt
möglich. In der **internen Bestellliste** ersetzt der rote Hinweis die
Mengen-Buttons dort ist das Produkt also vorübergehend nicht bestellbar.
- **„Nicht vorrätig":** Produkt zeitlich begrenzt (mit **Datum** „Wieder
lieferbar ab" → der Hinweis „In ca. X Tagen wieder da!" zählt täglich
automatisch herunter) oder auf unbestimmte Zeit als nicht vorrätig
markierbar. Es erscheint **überall nur ein Hinweis** im öffentlichen Shop
**und** in der internen Bestellliste bleibt der Kauf möglich; der
Vertriebspartner entscheidet selbst, ob er die Ware später bekommt.
- **Rohstoffbestand:** Übersicht aller aktiven Rohstoffe mit echtem Restbestand
(Wareneingang abzüglich Produktionsverbrauch), durchschnittlichem Verbrauch
pro Tag, voraussichtlichem „auf Null"-Datum und Hochrechnung für 1/3/6/12
@ -53,14 +55,21 @@
ausgewähltem Produkt. Suche und Filter „nur kritische anzeigen"; bei
unterschrittenem kritischem Bestand ist die Zeile rot, beim Meldebestand gelb
ein Badge in der Navigation zeigt die Anzahl. Schwellwerte werden je Produkt im
Produktformular (Warenwirtschaft) gepflegt.
Produktformular (Warenwirtschaft) gepflegt. Die Übersicht ist standardmäßig
nach **Dringlichkeit** sortiert (kritische Produkte oben, Klick auf „Status"
dreht die Reihenfolge), alle vier Status-Kacheln oben sind als Filter
anklickbar, und die Spalte **„Verbrauch/Monat"** zeigt den Durchschnitt der
Abgänge der letzten 6 Monate. Produkte ohne Lagerführung (z. B. Abrechnung
Druckkosten, Logo-Etiketten) lassen sich über die Produkt-Option
**„Im Produktbestand anzeigen"** aus der Übersicht ausblenden.
- **Produktbestand-Historie:** Revisionssichere Liste aller Bewegungen
(Eingang/Ausgang, Stückzahl, Datum, Grund, Hinweis, Mitarbeiter), filterbar
nach Produkt, Richtung, Grund und Zeitraum (Monat/Jahr).
- **Ausgang / Ausschuss:** Erfassung von Rohstoff- und Verpackungs-Abgängen
(z. B. Bruch, Verfall/MHD, Qualitätsmangel, Schwund, Testverbrauch). Pflichtfeld
**Grund**, optionale **Charge** (setzt den Lagerort automatisch), Menge in
(z. B. Bruch, Verfall/MHD, Qualitätsmangel, Schwund, Testverbrauch). Die
Gründe sind selbst pflegbar unter **Einstellungen → Allgemein →
Ausschuss-Gründe**. Pflichtfeld **Grund**, optionale **Charge** (setzt den Lagerort automatisch), Menge in
Gramm (Rohstoff) bzw. Stück (Verpackung) und Datum. Jeder Ausgang reduziert
sofort den Bestand beim Rohstoffbestand also auch die „auf Null"-Prognose und
den Kritisch-Status.

View file

@ -0,0 +1,46 @@
@extends('layouts.layout-2')
@section('content')
<div class="card">
<h6 class="card-header">{{ $model->exists ? __('Ausschuss-Grund bearbeiten') : __('Ausschuss-Grund anlegen') }}</h6>
<div class="card-body">
<form method="post" action="{{ $model->exists ? route('admin.inventory.disposal-reasons.update', $model) : route('admin.inventory.disposal-reasons.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="{{ __('z. B. Bruch / Beschädigung') }}" 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>
@endsection

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 und Lieferzeit-Vorlagen') }}</p>
<p class="wawi-page-head__subtitle">{{ __('Umsatzsteuersätze, Lieferzeit-Vorlagen und Ausschuss-Gründe') }}</p>
</div>
</div>
@ -119,5 +119,58 @@
</table>
</div>
</div>
<div class="wawi-card">
<div class="wawi-card__header">
<span>{{ __('Ausschuss-Gründe') }}</span>
<a href="{{ route('admin.inventory.disposal-reasons.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($disposalReasons as $disposalReason)
<tr>
<td>
<a href="{{ route('admin.inventory.disposal-reasons.edit', $disposalReason) }}"
class="btn icon-btn btn-sm btn-primary">
<span class="far fa-edit"></span>
</a>
</td>
<td>{{ $disposalReason->label }}</td>
<td>
@if ($disposalReason->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('admin.inventory.disposal-reasons.destroy', $disposalReason) }}"
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">{{ __('Noch keine Ausschuss-Gründe angelegt.') }}</div></td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
@endsection

View file

@ -1,35 +1,6 @@
@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;
}

View file

@ -24,14 +24,14 @@
</div>
<div class="wawi-stats">
<div class="wawi-stat" data-filter="all">
<div class="wawi-stat is-clickable" data-filter="all">
<div class="wawi-stat__icon"><span class="fas fa-boxes-stacked"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ $total }}</div>
<div class="wawi-stat__label">{{ __('Produkte') }}</div>
</div>
</div>
<div class="wawi-stat wawi-stat--ok" data-filter="all">
<div class="wawi-stat wawi-stat--ok is-clickable" data-filter="ok">
<div class="wawi-stat__icon"><span class="fas fa-circle-check"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ $okCount }}</div>
@ -75,7 +75,10 @@
<th style="width:3.5rem"></th>
<th>{{ __('Name') }}</th>
<th class="text-right">{{ __('Bestand') }}</th>
<th>{{ __('Status') }}</th>
<th class="text-right" title="{{ __('Durchschnitt der letzten 6 Monate') }}">{{ __('Verbrauch/Monat') }}</th>
<th id="ps-sort-status" style="cursor:pointer" title="{{ __('Nach Dringlichkeit sortieren') }}">
{{ __('Status') }} <span class="fas fa-sort text-muted small"></span>
</th>
<th class="text-right" style="width:16rem">{{ __('Aktion') }}</th>
</tr>
</thead>
@ -86,7 +89,7 @@
$rowClass = $row['status'] === 'critical' ? 'is-danger' : ($row['status'] === 'warning' ? 'is-warning' : '');
$stockClass = $row['status'] === 'critical' ? 'text-danger font-weight-bold' : ($row['status'] === 'warning' ? 'text-warning font-weight-bold' : '');
@endphp
<tr class="ps-row {{ $rowClass }}" data-name="{{ Str::lower($product->name) }} {{ Str::lower($product->number ?? '') }}" data-status="{{ $row['status'] }}">
<tr class="ps-row {{ $rowClass }}" data-name="{{ Str::lower($product->name) }} {{ Str::lower($product->number ?? '') }}" data-status="{{ $row['status'] }}" data-rank="{{ ['critical' => 0, 'warning' => 1, 'ok' => 2][$row['status']] }}">
<td>
@if ($product->images->count())
<img class="wawi-thumb" alt=""
@ -104,6 +107,13 @@
<td class="text-right {{ $stockClass }}">
{{ \App\Services\Util::formatNumber($row['stock'], 0) }} <span class="text-muted">{{ __('Stück') }}</span>
</td>
<td class="text-right">
@if ($row['monthly_consumption'] > 0)
{{ \App\Services\Util::formatNumber($row['monthly_consumption'], 1) }} <span class="text-muted">{{ __('Stück') }}</span>
@else
<span class="text-muted"></span>
@endif
</td>
<td>
@if ($row['status'] === 'critical')
<span class="wawi-pill wawi-pill--danger">{{ __('Kritisch') }}</span>
@ -131,7 +141,7 @@
</tr>
@empty
<tr>
<td colspan="5">
<td colspan="6">
<div class="wawi-empty">
<div><span class="fas fa-boxes-stacked"></span></div>
{{ __('Keine Produkte vorhanden.') }}
@ -188,6 +198,7 @@
<script>
$(function () {
var $rows = $('#ps-table tbody tr.ps-row');
var statusFilter = null; // null = alle; sonst 'ok' | 'warning' | 'critical'
function applyFilter() {
var term = ($('#ps-search').val() || '').toLowerCase().trim();
@ -197,23 +208,39 @@
var matchesTerm = term === '' || ($row.data('name') || '').toString().indexOf(term) !== -1;
var status = $row.data('status');
var matchesCritical = !onlyCritical || (status === 'critical' || status === 'warning');
$row.toggle(matchesTerm && matchesCritical);
var matchesStatus = statusFilter === null || status === statusFilter;
$row.toggle(matchesTerm && matchesCritical && matchesStatus);
});
}
$('#ps-search').on('keyup', applyFilter);
$('#ps-only-critical').on('change', applyFilter);
// AP-22: alle vier Kacheln filtern (Produkte = Filter aufheben, OK/Niedrig/Kritisch = nur dieser Status).
$('.wawi-stat.is-clickable').on('click', function () {
var filter = $(this).data('filter');
var $this = $(this);
var wasActive = $this.hasClass('is-active');
$('.wawi-stat').removeClass('is-active');
if (filter === 'critical' || filter === 'warning') {
$('#ps-only-critical').prop('checked', !wasActive);
if (!wasActive) { $this.addClass('is-active'); }
applyFilter();
$('#ps-only-critical').prop('checked', false);
if (filter === 'all' || wasActive) {
statusFilter = null;
} else {
statusFilter = filter;
$this.addClass('is-active');
}
applyFilter();
});
// AP-22: Klick auf „Status" sortiert nach Dringlichkeit (Default bereits dringlichste oben; Klick toggelt).
var statusSortAsc = true; // Server liefert bereits kritisch → ok
$('#ps-sort-status').on('click', function () {
statusSortAsc = !statusSortAsc;
var sorted = $rows.get().sort(function (a, b) {
var diff = ($(a).data('rank') - $(b).data('rank'));
return statusSortAsc ? diff : -diff;
});
$('#ps-table tbody').append(sorted);
});
var moveTemplate = "{{ route('admin.inventory.product-stock.movement', ['product' => '__ID__']) }}";

View file

@ -572,7 +572,7 @@
var indefinite = $('#out_of_stock_indefinite').is(':checked');
var active = $('#out_of_stock_active').is(':checked');
$('#out_of_stock_active').prop('disabled', indefinite);
$('.js-out-of-stock-days').toggle(active && !indefinite);
$('.js-out-of-stock-date').toggle(active && !indefinite);
}
$(document).on('change', '#out_of_stock_active, #out_of_stock_indefinite', toggleOutOfStock);
toggleOutOfStock();

View file

@ -103,18 +103,18 @@
<div class="card mb-2" id="product-section-verfuegbarkeit">
<h5 class="card-header">{{ __('Verfügbarkeit') }}</h5>
<div class="card-body">
@php($outOfStockDays = $product->outOfStockRemainingDays())
@php($outOfStockActive = $product->out_of_stock_until !== null && $product->out_of_stock_until->endOfDay()->isFuture())
<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>
{!! Form::checkbox('out_of_stock_active', 1, old('out_of_stock_active', $outOfStockActive), ['class' => 'custom-control-input', 'id' => 'out_of_stock_active']) !!}
<span class="custom-control-label">{{ __('Vorübergehend nicht vorrätig (mit Datum)') }}</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>
<p class="text-muted small mb-0">{{ __('Zeigt im Shop den Hinweis „In ca. X Tagen wieder da!" die Tage zählen täglich automatisch herunter. Der Kauf bleibt weiterhin möglich.') }}</p>
</div>
<div class="form-row js-out-of-stock-days" style="display:none;">
<div class="form-row js-out-of-stock-date" 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]) }}
<label class="form-label" for="out_of_stock_date">{{ __('Wieder lieferbar ab') }}</label>
{{ Form::text('out_of_stock_date', old('out_of_stock_date', optional($product->out_of_stock_until)->format('d.m.Y')), ['placeholder' => __('tt.mm.jjjj'), 'class' => 'form-control datepicker-base', 'id' => 'out_of_stock_date', 'autocomplete' => 'off']) }}
</div>
</div>
@ -1029,6 +1029,14 @@
</div>
<hr>
<div class="form-group">
<label class="custom-control custom-checkbox">
{!! Form::checkbox('show_in_product_stock', 1, old('show_in_product_stock', $product->exists ? $product->show_in_product_stock : true), ['class' => 'custom-control-input', 'id' => 'show_in_product_stock']) !!}
<span class="custom-control-label">{{ __('Im Produktbestand anzeigen') }}</span>
</label>
<p class="text-muted small mb-0">{{ __('Abwählen für Positionen ohne Lagerführung (z. B. Abrechnung Druckkosten, Logo-Etiketten).') }}</p>
</div>
<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">