Warenwirtschaft: R-02 — leerer historischer Verpackungs-Snapshot gilt in der Vorschau

Ob der Snapshot gilt, entscheidet der Buchungsstatus (nicht die Zeilenzahl);
ohne Verpackung gebuchte Produktionen zeigen „Kein Verpackungsverbrauch gebucht“.
Tests R-01/R-02.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-09 07:28:06 +00:00
parent fda37d5599
commit bdaac3b8fb
4 changed files with 110 additions and 3 deletions

View file

@ -170,6 +170,8 @@ class ProductionController extends Controller
return view('admin.inventory.productions.edit', [
'model' => $production,
'packagingSnapshot' => $packagingSnapshot,
// R-02: gebucht = historischer Snapshot gilt (auch wenn er leer ist: „kein Verbrauch“).
'packagingSnapshotApplies' => ! $production->isDraft(),
'initialOutputTotals' => $initialOutputTotals,
...$this->productCatalogs($production),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),

View file

@ -199,7 +199,7 @@
<div class="col-sm-1"></div>
</div>
<div id="production-outputs" data-initial='@json($initialOutputs)' data-packaging-bom='@json(($packagingBom ?? []) === [] ? (object) [] : $packagingBom)'
data-packaging-snapshot='@json($packagingSnapshot ?? [])' data-initial-output-totals='@json(($initialOutputTotals ?? []) === [] ? (object) [] : $initialOutputTotals)'></div>
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>
<script type="text/template" id="output-row-template">

View file

@ -368,6 +368,9 @@
var packagingBom = $('#production-outputs').data('packaging-bom') || {};
// N-04: gespeicherter Snapshot einer gebuchten Produktion + Ergebnissummen beim Öffnen.
var packagingSnapshot = $('#production-outputs').data('packaging-snapshot') || [];
// R-02: Ob ein historischer Snapshot gilt, entscheidet der Buchungsstatus — nicht die Zeilenzahl
// (eine ohne Verpackung gebuchte Produktion hat einen gültigen leeren Snapshot).
var packagingSnapshotApplies = String($('#production-outputs').data('packaging-snapshot-applies')) === '1';
var initialOutputTotals = $('#production-outputs').data('initial-output-totals') || {};
function currentOutputTotals() {
@ -394,17 +397,20 @@
function renderPackagingPreview() {
var $hint = $('#recipe-packaging-hint');
if (packagingSnapshot.length > 0 && sameTotals(currentOutputTotals(), initialOutputTotals)) {
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)));
});
if (!packagingSnapshot.length) {
$snap.append('<tr><td colspan="2" 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;
}
$hint.text(packagingSnapshot.length > 0
$hint.text(packagingSnapshotApplies
? '{{ __('Ergebnis geändert — Verpackungsverbrauch wird beim Speichern mit der aktuellen Stückliste neu berechnet.') }}'
: '');

View file

@ -567,3 +567,102 @@ test('N-04: bearbeitungsformular einer gebuchten produktion liefert den gespeich
->and($totals)->toBe([(string) $product->id => 1])
->and($html)->toContain('Gespeicherter Verpackungsverbrauch');
});
test('R-01: neue textphase voranstellen und bestehende phase umbenennen erhält die phasenzuordnung (mit und ohne formular-IDs)', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$x = draftIngredient();
$y = Ingredient::query()->create(['name' => 'Draft-Roh-Y', 'trans_name' => '', 'inci' => 'DRY', 'trans_inci' => '', 'effect' => '', 'trans_effect' => '', 'active' => true, 'pos' => 0]);
$stockX = draftStock($x, $location, $supplier, $user);
$stockY = StockEntry::query()->create([
'entry_type' => 'ingredient', 'ingredient_id' => $y->id, 'supplier_id' => $supplier->id, 'location_id' => $location->id, 'unit' => 'gram',
'ordered_by' => $user->id, 'ordered_at' => '2026-01-01', 'ordered_quantity' => 1000, 'price_per_kg' => 1, 'status' => 'received',
'received_by' => $user->id, 'received_at' => '2026-01-15', 'received_quantity' => 1000, 'batch_number' => 'DRAFT-Y', 'best_before' => '2030-12-31',
]);
$product = Product::query()->create(['name' => 'Draft-R01', 'title' => 'Draft-R01', 'active' => true]);
$base = ['id' => (string) $product->id, 'name' => $product->name, 'title' => $product->title, 'active' => '1', 'manufacturer_inci_sync_sent' => '1'];
(new ProductRepository($product))->update($base + [
'mfg_phase_name' => ['Phase A', 'Phase B'], 'mfg_phase_note' => ['', ''],
'mfg_ingredient_id' => [$x->id, $y->id], 'mfg_gram' => ['6', '4'], 'mfg_factor' => ['1', '1'], 'mfg_row_phase' => ['0', '1'],
]);
[$phaseA, $phaseB] = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get()->all();
$draft = app(ProductionService::class)->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null],
[
['ingredient_id' => $x->id, 'stock_entry_id' => $stockX->id, 'recipe_phase_id' => $phaseA->id, 'quantity_used' => '6'],
['ingredient_id' => $y->id, 'stock_entry_id' => $stockY->id, 'recipe_phase_id' => $phaseB->id, 'quantity_used' => '4'],
],
$user->id, [], true
);
// Formular-Weg: Textphase „Vorbereitung“ voranstellen, B umbenennen — mit stabilen IDs.
(new ProductRepository($product->fresh()))->update($base + [
'mfg_phase_id' => ['', (string) $phaseA->id, (string) $phaseB->id],
'mfg_phase_name' => ['Vorbereitung', 'Phase A', 'Phase B neu'], 'mfg_phase_note' => ['Alles bereitlegen', '', ''],
'mfg_ingredient_id' => [$x->id, $y->id], 'mfg_gram' => ['6', '4'], 'mfg_factor' => ['1', '1'], 'mfg_row_phase' => ['1', '2'],
]);
$phases = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get();
expect($phases->pluck('name')->all())->toBe(['Vorbereitung', 'Phase A', 'Phase B neu'])
->and($phases[1]->id)->toBe($phaseA->id)
->and($phases[2]->id)->toBe($phaseB->id)
->and($phases[0]->id)->not->toBe($phaseB->id)
->and($draft->productionIngredients()->where('recipe_phase_id', $phaseA->id)->sum('quantity_used'))->toEqual(6)
->and($draft->productionIngredients()->where('recipe_phase_id', $phaseB->id)->sum('quantity_used'))->toEqual(4);
// Ohne IDs (Direktaufruf): Umordnen + erneutes Umbenennen → Zuordnung über gemeinsame Rohstoffe.
(new ProductRepository($product->fresh()))->update($base + [
'mfg_phase_name' => ['Phase B final', 'Vorbereitung', 'Phase A'], 'mfg_phase_note' => ['', 'Alles bereitlegen', ''],
'mfg_ingredient_id' => [$y->id, $x->id], 'mfg_gram' => ['4', '6'], 'mfg_factor' => ['1', '1'], 'mfg_row_phase' => ['0', '2'],
]);
$phases = RecipePhase::query()->where('product_id', $product->id)->orderBy('pos')->get();
expect($phases->pluck('name')->all())->toBe(['Phase B final', 'Vorbereitung', 'Phase A'])
->and($phases[0]->id)->toBe($phaseB->id)
->and($phases[2]->id)->toBe($phaseA->id)
->and($draft->productionIngredients()->whereNull('recipe_phase_id')->count())->toBe(0);
// Wiederöffnen: beide Ist-Mengen stehen in ihrer Phase.
$this->actingAs($user, 'user');
$html = $this->get(route('admin.inventory.productions.edit', $draft))->assertOk()->getContent();
$start = strpos($html, 'var existingLines = ') + strlen('var existingLines = ');
$existing = json_decode(substr($html, $start, strpos($html, ';', $start) - $start), true);
expect($existing[$x->id.'|'.$phaseA->id][0]['quantity_used'])->toEqual(6)
->and($existing[$y->id.'|'.$phaseB->id][0]['quantity_used'])->toEqual(4);
// Produktformular trägt die Phasen-IDs.
$form = $this->get(route('admin_product_edit', $product->id))->assertOk()->getContent();
expect($form)->toContain('name="mfg_phase_id[]" class="mfg-phase-id" value="'.$phaseA->id.'"')
->toContain('name="mfg_phase_id[]" class="mfg-phase-id" value="'.$phaseB->id.'"');
});
test('R-02: ohne verpackung gebuchte produktion zeigt nach stücklistenänderung den leeren snapshot statt der neuen stückliste', function () {
$user = draftUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$material = PackagingMaterial::factory()->create();
$pk = PackagingItem::factory()->create(['packaging_material_id' => $material->id, 'supplier_id' => $supplier->id, 'name' => 'R02-Tiegel', 'category' => 'packaging']);
$product = Product::query()->create(['name' => 'Draft-R02', 'title' => 'Draft-R02', 'active' => true, 'no_recipe_required' => true]);
$head = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-08-01', 'quantity' => 1, 'notes' => null];
$done = app(ProductionService::class)->store($head, [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]]);
expect($done->productionPackagings)->toHaveCount(0);
$product->packagings()->sync([$pk->id => ['quantity' => 2, 'pos' => 0]]);
$this->actingAs($user, 'user');
$html = $this->get(route('admin.inventory.productions.edit', $done))->assertOk()->getContent();
expect($html)->toContain("data-packaging-snapshot='[]'")
->toContain("data-packaging-snapshot-applies='1'")
->toContain('Kein Verpackungsverbrauch gebucht.');
// Entwurf: kein gültiger Snapshot → Vorschau rechnet mit der Stückliste.
$draft = app(ProductionService::class)->store($head, [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]], true);
expect($this->get(route('admin.inventory.productions.edit', $draft))->getContent())->toContain("data-packaging-snapshot-applies='0'");
// Notiz speichern → Verbrauch bleibt 0; Mengenänderung → Neuberechnung 2 × 3.
$done = app(ProductionService::class)->updateProduction($done->fresh(), $head + ['notes' => 'x'], [], $user->id, [['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]]);
expect($done->productionPackagings)->toHaveCount(0);
$done = app(ProductionService::class)->updateProduction($done->fresh(), $head + ['quantity' => 3], [], $user->id, [['product_id' => $product->id, 'quantity' => 3, 'batch_number' => null]]);
expect($done->productionPackagings->sum('quantity_used'))->toBe(6);
});