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:
parent
22e218d570
commit
3abb9e644e
5 changed files with 103 additions and 16 deletions
|
|
@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue