gruene-seele/resources/views/admin/inventory/raw-material-stock/show.blade.php
Kevin Adametz 761ea35047 Warenwirtschaft: Bestandsrechnung auf der Rohstoff-Detailseite nachvollziehbar
Neuer Klappbereich „Bestandsrechnung nachvollziehen" zeigt alle gebuchten
Eingänge – auch aufgebrauchte mit Rest 0 – mit Produktionsverbrauch,
Ausschuss mit Chargenbezug und dem nach FEFO zugerechneten Ausschuss ohne
Chargenbezug; Summenzeile bis zum Gesamtbestand identisch zur Kopfzahl.
Alt-Ausschuss ohne Chargenbezug wird einzeln mit Grund/Notiz/Bearbeiter
gelistet. Anlass: Live-Fall Sonnenblumenöl (doppelte Korrektur vom 28.07.).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:34:04 +00:00

465 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('layouts.layout-2')
@php
$statusPill = match ($status) {
'critical' => '<span class="wawi-pill wawi-pill--danger">' . __('Kritisch') . '</span>',
'critical_ordered' => '<span class="wawi-pill wawi-pill--warning">' . __('Kritisch · bereits bestellt') . '</span>',
'warning' => '<span class="wawi-pill wawi-pill--warning">' . __('Bald nachbestellen') . '</span>',
default => '<span class="wawi-pill wawi-pill--ok">' . __('Ausreichend') . '</span>',
};
$stockCritical = in_array($status, ['critical', 'critical_ordered'], true);
@endphp
@section('content')
@include('admin.inventory.partials.wawi-ui')
<div class="wawi-page">
<div class="wawi-page-head">
<div>
<h1 class="wawi-page-head__title">
{{ $ingredient->name }} {!! $statusPill !!}
</h1>
<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">
<span class="fas fa-arrow-left mr-1"></span>{{ __('Zurück') }}
</a>
</div>
</div>
<div class="wawi-stats">
<div class="wawi-stat {{ $stockCritical ? 'wawi-stat--danger' : 'wawi-stat--ok' }}">
<div class="wawi-stat__icon"><span class="fas fa-warehouse"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ \App\Services\Util::formatNumber($remaining, 0) }} g</div>
<div class="wawi-stat__label">{{ __('Gesamtbestand') }}</div>
</div>
</div>
<div class="wawi-stat">
<div class="wawi-stat__icon"><span class="fas fa-truck"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ $openTotal > 0 ? \App\Services\Util::formatNumber($openTotal, 0) . ' g' : '—' }}</div>
<div class="wawi-stat__label">{{ __('Offen bestellt') }}</div>
</div>
</div>
<div class="wawi-stat">
<div class="wawi-stat__icon"><span class="fas fa-gauge-high"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ ($daily !== null && $daily > 0) ? \App\Services\Util::formatNumber($daily, 0) . ' g' : '—' }}</div>
<div class="wawi-stat__label">{{ __('Verbrauch / Tag') }}</div>
</div>
</div>
<div class="wawi-stat {{ ($expectedEmpty !== null && $daysUntilEmpty <= 30) ? 'wawi-stat--warning' : '' }}">
<div class="wawi-stat__icon"><span class="fas fa-hourglass-half"></span></div>
<div class="wawi-stat__body">
<div class="wawi-stat__value">{{ $expectedEmpty !== null ? $expectedEmpty->format('d.m.Y') : '—' }}</div>
<div class="wawi-stat__label">
{{ __('Voraussichtlich auf Null') }}
@if($expectedEmpty !== null)
({{ $daysUntilEmpty }} {{ trans_choice('Tag|Tagen', $daysUntilEmpty) }})
@endif
</div>
</div>
</div>
</div>
<div class="wawi-card" style="margin-bottom:1.25rem;">
<div class="wawi-card__footer" style="border-top:0; background:transparent;">
<span class="text-muted small mr-3">
{{ __('Qualität') }}: <strong>{{ $ingredient->materialQuality?->name ?? '—' }}</strong>
</span>
@if($ingredient->min_stock_alert !== null)
<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 mr-3">
{{ __('Lagerort') }}: <strong>{{ $ingredient->storageLabel() }}</strong>
</span>
@endif
@if($ingredient->density !== null)
<span class="text-muted small" title="{{ __('Nur für den Einkauf in ml — der Bestand wird immer in Gramm geführt (1 ml × Dichte = g).') }}">
{{ __('Dichte') }}: <strong>{{ \App\Services\Util::formatNumber($ingredient->density) }} g/ml</strong>
</span>
@endif
</div>
</div>
<div class="wawi-card">
<div class="wawi-card__header">{{ __('Enthalten in') }}</div>
<div class="table-responsive">
<table class="table wawi-table">
<thead>
<tr>
<th>{{ __('Produkt') }}</th>
<th class="text-right">{{ __('Produktbestand') }}</th>
<th class="text-right">{{ __('Anteil (%)') }}</th>
<th class="text-right" title="{{ __('Anteil × Füllgewicht × Überschuss-Faktor — wie in der Produktion gerechnet') }}">{{ __('Verbrauch (g/Stück)') }}</th>
</tr>
</thead>
<tbody>
@forelse($ingredient->products as $product)
@php $usage = $recipeUsage[$product->id] ?? null; @endphp
<tr>
<td>{{ $product->name }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($productStock[$product->id] ?? 0, 0) }} {{ __('Stück') }}</td>
<td class="text-right">
@if($usage)
{{ \App\Services\Util::formatNumber($usage['percent'], 2) }} %
@else
<span class="text-muted">—</span>
@endif
</td>
<td class="text-right">
@if($usage && $usage['consumption_per_piece'] !== null)
{{ \App\Services\Util::formatNumber($usage['consumption_per_piece'], 2) }} g
<span class="text-muted small">({{ __('netto') }} {{ \App\Services\Util::formatNumber($usage['grams_per_piece'], 2) }} g)</span>
@else
<span class="text-muted" title="{{ __('Kein Füllgewicht hinterlegt') }}">—</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="4"><div class="wawi-empty">{{ __('Dieser Rohstoff wird in keiner aktiven Rezeptur verwendet.') }}</div></td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="wawi-card">
<div class="wawi-card__header">{{ __('Lieferanten & Bestellung') }}</div>
<div class="table-responsive">
<table class="table wawi-table">
<thead>
<tr>
<th>{{ __('Lieferant') }}</th>
<th>{{ __('Lieferzeit') }}</th>
@if(Auth::user()->isAdmin())
<th class="text-right">{{ __('Letzter Einkauf (Netto/kg)') }}</th>
@endif
<th class="text-right" style="width: 11rem;"></th>
</tr>
</thead>
<tbody>
@forelse($ingredient->suppliers as $supplier)
@php
$deliveryTime = $ingredient->delivery_time ?: $supplier->delivery_time;
$lastPrice = $lastPriceBySupplier[$supplier->id] ?? null;
// AP-21: Bestellweg pro INCI-Lieferant — Pivot-URL gesetzt ⇒ Shop, leer ⇒ Mail.
$orderUrl = $supplier->pivot->url;
$orderEmail = $supplier->order_email ?: $supplier->email;
@endphp
<tr>
<td>
<span class="wawi-item-name">{{ $supplier->name }}</span>
@if($supplier->pivot->preferred)
<span class="wawi-pill wawi-pill--ok ml-1">{{ __('bevorzugt') }}</span>
@endif
@if($supplier->pivot->supplier_sku)
<div class="wawi-item-sub">{{ __('Art.-Nr.') }}: {{ $supplier->pivot->supplier_sku }}</div>
@endif
</td>
<td>{{ $deliveryTime ?: '—' }}</td>
@if(Auth::user()->isAdmin())
<td class="text-right">
@if($lastPrice !== null)
{{ \App\Services\Util::formatNumber($lastPrice, 2) }} €
@else
<span class="text-muted">—</span>
@endif
</td>
@endif
<td class="text-right">
@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>
@else
<span class="text-muted">—</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="4"><div class="wawi-empty">{{ __('Diesem Rohstoff ist kein Lieferant zugeordnet.') }}</div></td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if(Auth::user()->isAdmin())
<div class="wawi-card__footer">
<a href="{{ route('admin.inventory.stock-entries.create', ['ingredient_id' => $ingredient->id]) }}" class="btn btn-sm btn-primary">{{ __('Einkauf erfassen') }}</a>
<a href="{{ route('admin.inventory.stock-disposals.create', ['ingredient_id' => $ingredient->id]) }}" class="btn btn-sm btn-outline-danger">{{ __('Ausschuss erfassen') }}</a>
</div>
@endif
</div>
<div class="wawi-card">
<div class="wawi-card__header">{{ __('Offene Bestellungen / unterwegs') }}</div>
<div class="table-responsive">
<table class="table wawi-table">
<thead>
<tr>
<th>{{ __('Bestellt am') }}</th>
<th>{{ __('Lieferant') }}</th>
<th>{{ __('Lagerort') }}</th>
<th class="text-right">{{ __('Bestellte Menge') }}</th>
<th class="text-right" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
@forelse($openOrders as $order)
<tr>
<td>{{ $order->ordered_at?->format('d.m.Y') ?? '—' }}</td>
<td>{{ $order->supplier?->name ?? '—' }}</td>
<td>{{ $order->location?->name ?? '—' }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($order->ordered_quantity, 0) }} g</td>
<td class="text-right">
<a href="{{ route('admin.inventory.stock-entries.show', $order) }}" class="btn icon-btn btn-sm btn-primary" title="{{ __('Wareneingang buchen') }}">
<span class="far fa-eye"></span>
</a>
</td>
</tr>
@empty
<tr>
<td colspan="5"><div class="wawi-empty">{{ __('Keine offenen Bestellungen.') }}</div></td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($openOrders->isNotEmpty())
<div class="wawi-card__footer text-muted small">
{{ __('Offene Bestellungen zählen erst nach gebuchtem Wareneingang zum Bestand.') }}
</div>
@endif
</div>
<div class="wawi-card">
<div class="wawi-card__header">{{ __('Verfügbare Chargen') }}</div>
<div class="table-responsive">
<table class="table wawi-table">
<thead>
<tr>
<th>{{ __('Charge') }}</th>
<th>{{ __('Lieferant') }}</th>
<th>{{ __('Lagerort') }}</th>
<th>{{ __('MHD') }}</th>
<th class="text-right">{{ __('Restbestand') }}</th>
</tr>
</thead>
<tbody>
{{-- AP-27: Anzeige je Charge (batch_number) summiert; Einzel-Einkäufe als Unterzeilen --}}
@forelse($chargeGroups as $group)
<tr>
<td>
{{ $group['batch_number'] ?: '#' . $group['entries']->first()->id }}
@if($group['entries']->count() > 1)
<span class="badge badge-light border ml-1">{{ $group['entries']->count() }} {{ __('Einkäufe') }}</span>
@endif
</td>
<td>{{ $group['suppliers']->isNotEmpty() ? $group['suppliers']->implode(', ') : '—' }}</td>
<td>{{ $group['locations']->isNotEmpty() ? $group['locations']->implode(', ') : '—' }}</td>
<td>{{ $group['best_before']?->format('d.m.Y') ?? '—' }}</td>
<td class="text-right">{{ formatWawiQuantity($group['remaining'], 0) }} g</td>
</tr>
@if($group['entries']->count() > 1)
@foreach($group['entries'] as $charge)
<tr class="text-muted small">
<td class="pl-4">↳ {{ __('Einkauf vom') }} {{ $charge->received_at?->format('d.m.Y') ?? '—' }}</td>
<td>{{ $charge->supplier?->name ?? '—' }}</td>
<td>{{ $charge->location?->name ?? '—' }}</td>
<td>{{ $charge->best_before?->format('d.m.Y') ?? '—' }}</td>
<td class="text-right">{{ formatWawiQuantity($charge->getAttribute('remaining_quantity'), 0) }} g</td>
</tr>
@endforeach
@endif
@empty
<tr>
<td colspan="5"><div class="wawi-empty">{{ __('Kein Restbestand vorhanden.') }}</div></td>
</tr>
@endforelse
</tbody>
@if($chargeGroups->isNotEmpty() && count($remainingByLocation) > 0)
<tfoot>
@foreach($locations as $location)
@if(isset($remainingByLocation[$location->id]) && $remainingByLocation[$location->id] > 0)
<tr>
<td colspan="4" class="text-right text-muted">{{ __('Bestand') }} {{ $location->name }}</td>
<td class="text-right">{{ formatWawiQuantity($remainingByLocation[$location->id], 0) }} g</td>
</tr>
@endif
@endforeach
</tfoot>
@endif
</table>
</div>
</div>
{{-- 17.09.2026: Bestandsrechnung zum Nachvollziehen — alle Eingänge (auch Rest 0), Verbrauch, Ausschuss --}}
@php
$bd = $breakdown;
$unboundCount = $bd['unbound_disposals']->count();
$zeroCount = $bd['entries']->filter(fn ($e) => (float) $e->getAttribute('remaining_quantity') <= 0)->count();
$summaryParts = [trans_choice(':count Eingang|:count Eingänge', $bd['entries']->count(), ['count' => $bd['entries']->count()])];
if ($zeroCount > 0) {
$summaryParts[] = $zeroCount . ' ' . __('davon aufgebraucht');
}
if ($unboundCount > 0) {
$summaryParts[] = $unboundCount . ' ' . __('Ausschuss ohne Chargenbezug');
}
@endphp
<div class="wawi-card">
<a class="wawi-card__header d-flex justify-content-between align-items-center text-reset text-decoration-none collapsed"
data-toggle="collapse" href="#stock-breakdown" role="button" aria-expanded="false" aria-controls="stock-breakdown">
<span>
{{ __('Bestandsrechnung nachvollziehen') }}
<span class="text-muted small font-weight-normal ml-2">
{{ implode(', ', $summaryParts) }}
</span>
</span>
<span class="fas fa-chevron-down small text-muted"></span>
</a>
<div class="collapse" id="stock-breakdown">
<div class="table-responsive">
<table class="table wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Eingang') }}</th>
<th>{{ __('Charge') }}</th>
<th>{{ __('Lagerort') }}</th>
<th>{{ __('MHD') }}</th>
<th class="text-right">{{ __('Eingang') }}</th>
<th class="text-right">{{ __('Produktion') }}</th>
<th class="text-right" title="{{ __('Ausschuss, der beim Erfassen dieser Charge zugeordnet wurde') }}">{{ __('Ausschuss (Charge)') }}</th>
<th class="text-right" title="{{ __('Ausschuss ohne Chargenbezug — wird rechnerisch nach MHD (FEFO) auf die Eingänge verteilt') }}">{{ __('Ausschuss (o. Charge)') }}</th>
<th class="text-right">{{ __('Rest') }}</th>
</tr>
</thead>
<tbody>
@forelse($bd['entries'] as $entry)
@php $isEmpty = (float) $entry->getAttribute('remaining_quantity') <= 0; @endphp
<tr class="{{ $isEmpty ? 'text-muted' : '' }}">
<td>
<a href="{{ route('admin.inventory.stock-entries.show', $entry) }}" class="text-reset">
{{ $entry->received_at?->format('d.m.Y') ?? '—' }}
</a>
<span class="text-muted small">#{{ $entry->id }}</span>
@if($entry->supplier)
<div class="wawi-item-sub">{{ $entry->supplier->name }}</div>
@endif
</td>
<td>{{ $entry->batch_number ?: '—' }}</td>
<td>{{ $entry->location?->name ?? '—' }}</td>
<td>{{ $entry->best_before?->format('d.m.Y') ?? '—' }}</td>
<td class="text-right">{{ formatWawiQuantity($entry->received_quantity) }} g</td>
<td class="text-right">{{ (float) $entry->getAttribute('production_quantity') > 0 ? '− ' . formatWawiQuantity($entry->getAttribute('production_quantity')) . ' g' : '—' }}</td>
<td class="text-right">{{ (float) $entry->getAttribute('bound_disposal_quantity') > 0 ? '− ' . formatWawiQuantity($entry->getAttribute('bound_disposal_quantity')) . ' g' : '—' }}</td>
<td class="text-right">{{ (float) $entry->getAttribute('unbound_disposal_quantity') > 0 ? '− ' . formatWawiQuantity($entry->getAttribute('unbound_disposal_quantity')) . ' g' : '—' }}</td>
<td class="text-right">
<strong>{{ formatWawiQuantity($entry->getAttribute('remaining_quantity')) }} g</strong>
@if($isEmpty)
<span class="badge badge-light border ml-1">{{ __('aufgebraucht') }}</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="9"><div class="wawi-empty">{{ __('Noch kein gebuchter Wareneingang.') }}</div></td>
</tr>
@endforelse
</tbody>
<tfoot>
<tr>
<td colspan="4" class="text-right text-muted">{{ __('Summe Wareneingänge') }}</td>
<td class="text-right">{{ formatWawiQuantity($bd['received_total']) }} g</td>
<td colspan="4"></td>
</tr>
<tr>
<td colspan="4" class="text-right text-muted">{{ __('− Produktionsverbrauch') }}</td>
<td class="text-right">− {{ formatWawiQuantity($bd['production_total']) }} g</td>
<td colspan="4" class="text-muted small">
@if($bd['production_other'] > 0)
{{ __('davon :qty g ohne zuordenbaren Eingang (z. B. Eingang nachträglich gelöscht)', ['qty' => formatWawiQuantity($bd['production_other'])]) }}
@endif
</td>
</tr>
<tr>
<td colspan="4" class="text-right text-muted">{{ __('− Ausschuss mit Chargenbezug') }}</td>
<td class="text-right">− {{ formatWawiQuantity($bd['disposed_bound_total'] + $bd['disposed_other']) }} g</td>
<td colspan="4" class="text-muted small">
@if($bd['disposed_other'] > 0)
{{ __('davon :qty g ohne zuordenbaren Eingang (Eingang nachträglich gelöscht)', ['qty' => formatWawiQuantity($bd['disposed_other'])]) }}
@endif
</td>
</tr>
<tr>
<td colspan="4" class="text-right text-muted">{{ __('− Ausschuss ohne Chargenbezug') }}</td>
<td class="text-right">− {{ formatWawiQuantity($bd['disposed_unbound_total']) }} g</td>
<td colspan="4" class="text-muted small">
@if($bd['disposed_unbound_unallocated'] > 0)
{{ __('davon :qty g keinem Eingang zurechenbar (mehr Ausschuss als Eingang am Lagerort)', ['qty' => formatWawiQuantity($bd['disposed_unbound_unallocated'])]) }}
@endif
</td>
</tr>
<tr>
<td colspan="4" class="text-right"><strong>{{ __('= Gesamtbestand') }}</strong></td>
<td class="text-right"><strong>{{ formatWawiQuantity($bd['remaining_total']) }} g</strong></td>
<td colspan="4"></td>
</tr>
</tfoot>
</table>
</div>
@if($bd['unbound_disposals']->isNotEmpty())
<div class="wawi-card__footer" style="display:block;">
<div class="font-weight-bold mb-2">{{ __('Ausschuss ohne Chargenbezug') }}</div>
<p class="text-muted small mb-2">
{{ __('Diese Buchungen wurden ohne Zuordnung zu einem Eingang erfasst (Altbestand vor Einführung der Chargenwahl). Sie mindern den Gesamtbestand und werden in der Chargenansicht rechnerisch nach MHD (ältestes zuerst) von den Eingängen abgezogen — deshalb können auch neuere Eingänge als „aufgebraucht" erscheinen. Ist so eine Buchung überholt (z. B. doppelte Korrektur), lässt sie sich in der Ausschuss-Liste löschen.') }}
</p>
<div class="table-responsive">
<table class="table wawi-table table-sm mb-0">
<thead>
<tr>
<th>{{ __('Datum') }}</th>
<th>{{ __('Lagerort') }}</th>
<th>{{ __('Grund') }}</th>
<th>{{ __('Notiz') }}</th>
<th>{{ __('Erfasst von') }}</th>
<th class="text-right">{{ __('Menge') }}</th>
</tr>
</thead>
<tbody>
@foreach($bd['unbound_disposals'] as $disposal)
<tr>
<td>{{ $disposal->disposed_at?->format('d.m.Y') ?? $disposal->created_at?->format('d.m.Y') }} <span class="text-muted small">#{{ $disposal->id }}</span></td>
<td>{{ $disposal->location?->name ?? '—' }}</td>
<td>{{ $disposal->reason ?: '—' }}</td>
<td>{{ $disposal->note ?: '—' }}</td>
<td>{{ $disposal->user?->name ?? '—' }}</td>
<td class="text-right">− {{ formatWawiQuantity($disposal->quantity) }} g</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if(Auth::user()->isAdmin())
<a href="{{ route('admin.inventory.stock-disposals.index') }}" class="btn btn-sm btn-outline-secondary mt-2">{{ __('Zur Ausschuss-Liste') }}</a>
@endif
</div>
@endif
</div>
</div>
</div>
@endsection