Warenwirtschaft: AP-52 — Verpackungsvorschau mit Bestand, Differenz und Lagerort (Kundennachtrag 09.09.2026)

Je Verpackungsartikel Bestand (Stück), Differenz zu Bedarf (rot/grün wie bei
Rohstoffen, AP-40) und Lagerplatz; Spalte einheitlich „Lagerort“ für Rohstoffe
(INCI-Tabelle, vorher „Ablage“) und Verpackung.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-09 08:48:30 +00:00
parent 22e218d570
commit 3abb9e644e
5 changed files with 103 additions and 16 deletions

View file

@ -8,6 +8,7 @@ use App\Models\Location;
use App\Models\Product;
use App\Models\Production;
use App\Repositories\ProductionRepository;
use App\Services\InventoryService;
use App\Services\ProductionService;
use App\Services\ProductStockService;
use Illuminate\Database\Eloquent\Collection;
@ -160,7 +161,7 @@ class ProductionController extends Controller
// (dieselbe Regel wie ProductionService::updateProduction, F-05).
$production->loadMissing('productionPackagings.packagingItem');
$packagingSnapshot = $production->isDraft() ? [] : $production->productionPackagings
->map(fn ($row) => ['name' => $row->packagingItem?->name ?? ('#'.$row->packaging_item_id), 'pieces' => (int) $row->quantity_used])
->map(fn ($row) => ['id' => (int) $row->packaging_item_id, 'name' => $row->packagingItem?->name ?? ('#'.$row->packaging_item_id), 'pieces' => (int) $row->quantity_used])
->values()->all();
$initialOutputTotals = [];
foreach ($production->outputs as $output) {
@ -300,7 +301,7 @@ class ProductionController extends Controller
$existingOutputIds = $production?->outputs?->pluck('product_id')->map(fn ($id) => (int) $id)->all() ?? [];
$outputProducts = Product::query()
->with('packagings:id,name')
->with('packagings.storageRoom', 'packagings.storageShelf', 'packagings.storageSlot')
->where(fn ($q) => $q->where('active', 1)->where('is_set', false))
->when($existingOutputIds !== [], fn ($q) => $q->orWhereIn('id', $existingOutputIds))
->orderBy('name')
@ -316,7 +317,21 @@ class ProductionController extends Controller
])->values()->all()])
->all();
return ['products' => $products, 'outputProducts' => $outputProducts, 'packagingBom' => $packagingBom];
// AP-52 (Kundennachtrag 09.09.2026): Bestand, Differenz und Lagerort je
// Verpackungsartikel in der Vorschau — wie bei den Rohstoffen in der INCI-Tabelle.
$packagingItems = $outputProducts->flatMap->packagings->unique('id');
if ($production !== null) {
$production->loadMissing('productionPackagings.packagingItem.storageRoom', 'productionPackagings.packagingItem.storageShelf', 'productionPackagings.packagingItem.storageSlot');
$packagingItems = $packagingItems->merge($production->productionPackagings->map->packagingItem->filter())->unique('id');
}
$stockByItem = $packagingItems->isEmpty() ? [] : app(InventoryService::class)->remainingByPackagingItem($packagingItems->pluck('id')->map(fn ($id) => (int) $id)->all());
$packagingInfo = $packagingItems->mapWithKeys(fn ($pk) => [(int) $pk->id => [
'name' => $pk->name,
'stock' => round((float) ($stockByItem[(int) $pk->id] ?? 0.0), 0),
'storage_label' => $pk->storageLabel(),
]])->all();
return ['products' => $products, 'outputProducts' => $outputProducts, 'packagingBom' => $packagingBom, 'packagingInfo' => $packagingInfo];
}
/**

View file

@ -141,7 +141,8 @@
<th>{{ __('Qualität') }}</th>
<th class="text-right">{{ __('Anteil %') }}</th>
<th class="text-right">{{ __('Gramm (gesamt)') }}</th>
<th>{{ __('Ablage') }}</th>
{{-- AP-52: „Lagerort" einheitlich für Rohstoffe und Verpackung (Kundenwunsch 09.09.2026) --}}
<th>{{ __('Lagerort') }}</th>
<th class="text-right">{{ __('Bestand') }}</th>
<th class="text-right">{{ __('Differenz') }}</th>
</tr>
@ -157,9 +158,18 @@
<h6>{{ __('Verpackung (Vorschau)') }} <small class="text-muted font-weight-normal">{{ __('aus dem Produktionsergebnis') }}</small></h6>
<div class="table-responsive border rounded mb-3">
<table class="table table-sm mb-0">
<thead><tr><th>{{ __('Artikel') }}</th><th>{{ __('Stück gesamt') }}</th></tr></thead>
{{-- AP-52: Bestand/Differenz/Lagerort je Verpackungsartikel — direkt bei der Planung sehen, ob alles da ist --}}
<thead>
<tr>
<th>{{ __('Artikel') }}</th>
<th class="text-right">{{ __('Stück gesamt') }}</th>
<th>{{ __('Lagerort') }}</th>
<th class="text-right">{{ __('Bestand') }}</th>
<th class="text-right">{{ __('Differenz') }}</th>
</tr>
</thead>
<tbody id="recipe-packaging-preview"></tbody>
<tfoot><tr><td colspan="2" class="text-muted small" id="recipe-packaging-hint"></td></tr></tfoot>
<tfoot><tr><td colspan="5" class="text-muted small" id="recipe-packaging-hint"></td></tr></tfoot>
</table>
</div>
@ -198,7 +208,7 @@
<div class="col-sm-3">{{ __('Chargen-Nr.') }}</div>
<div class="col-sm-1"></div>
</div>
<div id="production-outputs" data-initial='@json($initialOutputs)' data-packaging-bom='@json(($packagingBom ?? []) === [] ? (object) [] : $packagingBom)'
<div id="production-outputs" data-initial='@json($initialOutputs)' data-packaging-bom='@json(($packagingBom ?? []) === [] ? (object) [] : $packagingBom)' data-packaging-info='@json(($packagingInfo ?? []) === [] ? (object) [] : $packagingInfo)'
data-packaging-snapshot='@json($packagingSnapshot ?? [])' data-packaging-snapshot-applies='{{ ! empty($packagingSnapshotApplies) ? 1 : 0 }}' data-initial-output-totals='@json(($initialOutputTotals ?? []) === [] ? (object) [] : $initialOutputTotals)'></div>
<button type="button" class="btn btn-sm btn-outline-secondary mb-3" id="btn-add-output">{{ __('+ weitere Ergebniszeile') }}</button>

View file

@ -33,6 +33,11 @@
return Number(value).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
// AP-52: Stückzahlen (Verpackung) ohne Nachkommastellen.
function fmtPieces(value) {
return Number(value).toLocaleString('de-DE', {maximumFractionDigits: 0});
}
function parseGerman(value) {
value = String(value || '').trim();
if (!value) {
@ -395,17 +400,37 @@
return true;
}
// AP-52: Bestand + Lagerort je Verpackungsartikel (vom Server), Differenz = Bestand − Bedarf.
var packagingInfo = $('#production-outputs').data('packaging-info') || {};
function packagingRow(id, name, pieces, withDiff) {
var info = packagingInfo[String(id)] || {};
var stock = info.stock != null ? Number(info.stock) : null;
var $row = $('<tr></tr>')
.append($('<td></td>').text(name))
.append($('<td class="text-right"></td>').text(pieces))
.append($('<td></td>').text(info.storage_label || '—'))
.append($('<td class="text-right"></td>').text(stock != null ? fmtPieces(stock) : '—'));
var $diff = $('<td class="text-right"></td>');
if (withDiff && stock != null) {
// Wie AP-40 bei den Rohstoffen: negativ rot = Fehlmenge, sonst grün.
var d = stock - pieces;
$diff.text((d >= 0 ? '+' : '') + fmtPieces(d)).css('color', d < 0 ? '#dc3545' : '#28a745');
} else {
$diff.text('—').attr('title', withDiff ? '' : '{{ __('Bereits gebucht — Bestand enthält den Abzug') }}');
}
return $row.append($diff);
}
function renderPackagingPreview() {
var $hint = $('#recipe-packaging-hint');
if (packagingSnapshotApplies && sameTotals(currentOutputTotals(), initialOutputTotals)) {
var $snap = $('#recipe-packaging-preview').empty();
packagingSnapshot.forEach(function (pk) {
$snap.append($('<tr></tr>')
.append($('<td></td>').text(pk.name))
.append($('<td></td>').text(pk.pieces)));
$snap.append(packagingRow(pk.id, pk.name, pk.pieces, false));
});
if (!packagingSnapshot.length) {
$snap.append('<tr><td colspan="2" class="text-muted">{{ __('Kein Verpackungsverbrauch gebucht.') }}</td></tr>');
$snap.append('<tr><td colspan="5" class="text-muted">{{ __('Kein Verpackungsverbrauch gebucht.') }}</td></tr>');
}
$hint.text('{{ __('Gespeicherter Verpackungsverbrauch dieser Produktion — bleibt erhalten, solange Ergebnisprodukte und Stückzahlen unverändert sind.') }}');
return;
@ -429,12 +454,10 @@
});
var $pk = $('#recipe-packaging-preview').empty();
order.forEach(function (id) {
$pk.append($('<tr></tr>')
.append($('<td></td>').text(totals[id].name))
.append($('<td></td>').text(Math.round(totals[id].pieces))));
$pk.append(packagingRow(id, totals[id].name, Math.round(totals[id].pieces), true));
});
if (!order.length) {
$pk.append('<tr><td colspan="2" class="text-muted">{{ __('Keine Verpackung hinterlegt.') }}</td></tr>');
$pk.append('<tr><td colspan="5" class="text-muted">{{ __('Keine Verpackung hinterlegt.') }}</td></tr>');
}
}

View file

@ -4,6 +4,9 @@ use App\Models\Location;
use App\Models\PackagingItem;
use App\Models\PackagingMaterial;
use App\Models\Product;
use App\Models\StockEntry;
use App\Models\StorageRoom;
use App\Models\StorageShelf;
use App\Models\Supplier;
use App\Services\ProductionService;
use App\User;
@ -72,3 +75,39 @@ test('F-07: produktionsformular liefert die verpackungs-stückliste je ergebnisp
->and($bom[$small->id][0]['per_unit'])->toEqual(2)
->and($html)->toContain('renderPackagingPreview');
});
test('AP-52: verpackungsvorschau liefert bestand und lagerort je verpackungsartikel; spalten heißen einheitlich Lagerort', function () {
$user = fwUser();
$this->actingAs($user, 'user');
$supplier = Supplier::factory()->create();
$material = PackagingMaterial::factory()->create();
$room = StorageRoom::query()->create(['label' => 'Lager 1', 'pos' => 0, 'active' => true]);
$shelf = StorageShelf::query()->create(['label' => 'Regal B', 'pos' => 0, 'active' => true]);
$pk = PackagingItem::factory()->create([
'packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'AP52-Tiegel', 'category' => 'packaging',
'storage_room_id' => $room->id, 'storage_shelf_id' => $shelf->id,
]);
$location = Location::factory()->create();
// 120 Stück Wareneingang, 20 bereits verbraucht → Bestand 100.
StockEntry::query()->create([
'entry_type' => 'packaging', 'packaging_item_id' => $pk->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id, 'unit' => 'piece',
'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', 'ordered_quantity' => 120, 'price_per_kg' => 1, 'status' => 'received',
'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 120,
]);
$product = Product::query()->create(['name' => 'AP52-Produkt', 'title' => 'x', 'active' => true, 'no_recipe_required' => true]);
$product->packagings()->sync([$pk->id => ['quantity' => 1, 'pos' => 0]]);
app(ProductionService::class)->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 20],
[], $user->id, [['product_id' => $product->id, 'quantity' => 20, 'batch_number' => null]]
);
$html = $this->get(route('admin.inventory.productions.create'))->assertOk()->getContent();
$start = strpos($html, "data-packaging-info='") + strlen("data-packaging-info='");
$info = json_decode(substr($html, $start, strpos($html, "'", $start) - $start), true);
expect($info[$pk->id]['stock'])->toEqual(100)
->and($info[$pk->id]['storage_label'])->toBe('Lager 1 | Regal B')
->and($html)->toContain('packagingRow(')
->and(substr_count($html, '<th>Lagerort</th>'))->toBe(2)
->and($html)->not->toContain('<th>Ablage</th>');
});

View file

@ -563,7 +563,7 @@ test('N-04: bearbeitungsformular einer gebuchten produktion liefert den gespeich
$start = strpos($html, "data-initial-output-totals='") + strlen("data-initial-output-totals='");
$totals = json_decode(substr($html, $start, strpos($html, "'", $start) - $start), true);
expect($snapshot)->toBe([['name' => 'N04-Tiegel', 'pieces' => 1]])
expect($snapshot)->toBe([['id' => $pk->id, 'name' => 'N04-Tiegel', 'pieces' => 1]])
->and($totals)->toBe([(string) $product->id => 1])
->and($html)->toContain('Gespeicherter Verpackungsverbrauch');
});