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:
parent
e47d848d3d
commit
7a5b5e0f7b
16 changed files with 673 additions and 34 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue