Warenwirtschaft: AP-27 Wareneingang gleiche Charge zusammenführen

- Chargen-Existenz-Check beim Buchen (JSON-Endpoint batch-check): Hinweis
  „Charge bereits im Haus" + Vorbelegung MHD/Qualität aus dem letzten Eingang
- Rohstoffbestand-Detail: Chargen je batch_number summiert
  (InventoryService::chargeGroupsForIngredient), Einkäufe als Unterzeilen
- Produktion: Charge-Dropdown je Charge mit Summen-Rest (batches im
  Recipe-Payload), Verbrauch wird per expandBatchLines FEFO auf die
  Einzel-Einkäufe der Charge verteilt; Service-API bleibt je stock_entry
- Jeder Einkauf bleibt eigener stock_entry (Preis/Datum/Audit)
- Hinweise-Doku + Plan V6.0 §6 fortgeschrieben

Tests: StockEntryChargeMergeTest (7 neu); Suite 193 grün, 698 Assertions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-06 13:36:19 +00:00
parent e47d848d3d
commit 7a5b5e0f7b
16 changed files with 673 additions and 34 deletions

View file

@ -30,16 +30,21 @@
return Number(value).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function buildStockSelect(idx, stockEntries, preselectedEntryId) {
var select = $('<select class="form-control form-control-sm" name="ingredient_lines[' + idx + '][stock_entry_id]" required></select>');
// AP-27: Dropdown je Charge (batch_number) zusammengeführt — Restbestand ist die Summe
// über alle Einkäufe der Charge; die FEFO-Verteilung übernimmt der Server.
function buildStockSelect(idx, batches, preselectedBatchKey) {
var select = $('<select class="form-control form-control-sm" name="ingredient_lines[' + idx + '][batch_key]" required></select>');
select.append('<option value="">{{ __('Charge wählen') }}</option>');
(stockEntries || []).forEach(function (se) {
var label = se.label;
if (se.remaining != null) {
label += ' — {{ __('Rest') }} ' + fmt(se.remaining) + ' g';
(batches || []).forEach(function (batch) {
var label = batch.label;
if (batch.remaining != null) {
label += ' — {{ __('Rest') }} ' + fmt(batch.remaining) + ' g';
}
var opt = $('<option></option>').attr('value', se.id).text(label);
if (preselectedEntryId && String(se.id) === String(preselectedEntryId)) {
if (batch.purchases > 1) {
label += ' (' + batch.purchases + ' {{ __('Einkäufe') }})';
}
var opt = $('<option></option>').attr('value', batch.key).text(label);
if (preselectedBatchKey && String(batch.key) === String(preselectedBatchKey)) {
opt.prop('selected', true);
}
select.append(opt);
@ -47,13 +52,13 @@
return select;
}
function addIngredientRow($wrap, ing, preselectedEntryId, preselectedQty) {
function addIngredientRow($wrap, ing, preselectedBatchKey, preselectedQty) {
var idx = lineIndex++;
var $row = $('<div class="form-row align-items-center mb-1 recipe-row"></div>');
var $colSelect = $('<div class="col-12 col-sm-7 mb-1"></div>');
$colSelect.append($('<input type="hidden" name="ingredient_lines[' + idx + '][ingredient_id]">').val(ing.id));
$colSelect.append(buildStockSelect(idx, ing.stock_entries, preselectedEntryId));
$colSelect.append(buildStockSelect(idx, ing.batches, preselectedBatchKey));
$row.append($colSelect);
var $colQty = $('<div class="col-12 col-sm-5 mb-1"></div>');
@ -137,7 +142,7 @@
var existing = existingLines[String(ing.id)] || [];
if (existing.length > 0) {
existing.forEach(function (ex) {
addIngredientRow($wrap, ing, ex.stock_entry_id, ex.quantity_used);
addIngredientRow($wrap, ing, ex.batch_key, ex.quantity_used);
});
} else {
addIngredientRow($wrap, ing);

View file

@ -18,9 +18,13 @@
@section('scripts')
@php
// AP-27: Zeilen je Charge (batch_key) zusammenfassen — mehrere Einkäufe derselben Charge = eine Formularzeile.
$existingLines = $model
? $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) {
return $lines->map(fn ($l) => ['stock_entry_id' => $l->stock_entry_id, 'quantity_used' => (float) $l->quantity_used])->values();
return $lines
->groupBy(fn ($l) => $l->stockEntry?->batchGroupKey() ?? 'e:'.$l->stock_entry_id)
->map(fn ($group, $key) => ['batch_key' => $key, 'quantity_used' => (float) $group->sum('quantity_used')])
->values();
})
: [];
@endphp

View file

@ -19,8 +19,12 @@
@section('scripts')
@php
// AP-27: Zeilen je Charge (batch_key) zusammenfassen — mehrere Einkäufe derselben Charge = eine Formularzeile.
$existingLines = $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) {
return $lines->map(fn ($l) => ['stock_entry_id' => $l->stock_entry_id, 'quantity_used' => (float) $l->quantity_used])->values();
return $lines
->groupBy(fn ($l) => $l->stockEntry?->batchGroupKey() ?? 'e:'.$l->stock_entry_id)
->map(fn ($group, $key) => ['batch_key' => $key, 'quantity_used' => (float) $group->sum('quantity_used')])
->values();
});
@endphp
@include('admin.inventory.productions._scripts', ['existingLines' => $existingLines, 'excludeProductionId' => $model->id])

View file

@ -241,21 +241,38 @@
</tr>
</thead>
<tbody>
@forelse($charges as $charge)
{{-- AP-27: Anzeige je Charge (batch_number) summiert; Einzel-Einkäufe als Unterzeilen --}}
@forelse($chargeGroups as $group)
<tr>
<td>{{ $charge->batch_number ?: '#' . $charge->id }}</td>
<td>{{ $charge->supplier?->name ?? '—' }}</td>
<td>{{ $charge->location?->name ?? '—' }}</td>
<td>{{ $charge->best_before?->format('d.m.Y') ?? '—' }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($charge->getAttribute('remaining_quantity'), 0) }} g</td>
<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">{{ \App\Services\Util::formatNumber($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">{{ \App\Services\Util::formatNumber($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($charges->isNotEmpty() && count($remainingByLocation) > 0)
@if($chargeGroups->isNotEmpty() && count($remainingByLocation) > 0)
<tfoot>
@foreach($locations as $location)
@if(isset($remainingByLocation[$location->id]) && $remainingByLocation[$location->id] > 0)

View file

@ -207,6 +207,8 @@
@error('batch_number')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- AP-27: Hinweis, wenn die Charge für diesen Rohstoff bereits im Haus ist --}}
<div id="batch-merge-hint" class="alert alert-info py-2 px-3 mt-2 mb-0 small" style="display:none;"></div>
</div>
<div class="form-group">
@ -240,3 +242,79 @@
@endif
</div>
@endsection
@if($model->isPending() && $model->entry_type === 'ingredient')
@section('scripts')
<script>
(function ($) {
// AP-27: Beim Eintippen der Chargennummer prüfen, ob die Charge schon im Haus ist.
var checkUrl = @json(route('admin.inventory.api.stock-entries.batch-check'));
var ingredientId = @json($model->ingredient_id);
var excludeId = @json($model->id);
var $batch = $('#batch_number');
var $hint = $('#batch-merge-hint');
var timer = null;
var lastChecked = null;
function fmt(value) {
return Number(value).toLocaleString('de-DE', {maximumFractionDigits: 0});
}
function runCheck() {
var batch = ($batch.val() || '').trim();
if (!batch || !ingredientId) {
$hint.hide();
lastChecked = null;
return;
}
if (batch === lastChecked) {
return;
}
lastChecked = batch;
$.getJSON(checkUrl, {ingredient_id: ingredientId, batch_number: batch, exclude: excludeId})
.done(function (data) {
if (!data.exists) {
$hint.hide();
return;
}
var parts = ['<strong>{{ __('Charge bereits im Haus') }}</strong> — {{ __('die Menge wird dieser Charge zugerechnet, der Einkauf bleibt separat erfasst.') }}'];
var facts = [];
facts.push('{{ __('Restbestand') }}: ' + fmt(data.remaining) + ' g');
if (data.purchases > 1) {
facts.push(data.purchases + ' {{ __('Einkäufe') }}');
}
if (data.location) {
facts.push('{{ __('Lagerort') }}: ' + $('<span>').text(data.location).html());
}
if (data.best_before) {
facts.push('{{ __('MHD') }}: ' + data.best_before);
}
parts.push('<br>' + facts.join(' · '));
// Vorbelegung (überschreibbar): MHD + Qualität aus dem letzten Eingang der Charge.
if (data.best_before && !$('#best_before').val()) {
$('#best_before').val(data.best_before);
parts.push('<br>{{ __('Mindesthaltbarkeit wurde vorbelegt.') }}');
}
var $quality = $('#quality_id');
if (data.quality_id && $quality.length && !$quality.val()) {
$quality.val(String(data.quality_id));
}
$hint.html(parts.join('')).show();
})
.fail(function () {
$hint.hide();
});
}
$batch.on('input', function () {
clearTimeout(timer);
timer = setTimeout(runCheck, 400);
});
$batch.on('blur', runCheck);
})(jQuery);
</script>
@endsection
@endif