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:
parent
fda37d5599
commit
bdaac3b8fb
4 changed files with 110 additions and 3 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.') }}'
|
||||
: '');
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue