Warenwirtschaft: N-01/N-03/N-05 — Entwurfsmenge ohne Charge, Snapshot-Signatur je Produkt, Alt-Ausschuss in Chargenverfügbarkeit
- N-01: Ist-Menge ohne gewählte Charge bleibt im Entwurf erhalten (Filter in Request und Service, formBatchKey liefert '' für chargenlose Zeilen). - N-03: outputSignature summiert Stückzahlen je Produkt — ein Chargensplit ersetzt den historischen Verpackungs-Snapshot nicht mehr. - N-05: Ausschuss ohne Chargenbezug (Altbestand) wird für die Verfügbarkeit je Standort FEFO auf die Chargen angerechnet (applyUnboundDisposals, lesend); Chargen-Ausschussprüfung nutzt dieselbe Verfügbarkeit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
520d4e2deb
commit
313c6277ce
8 changed files with 138 additions and 23 deletions
|
|
@ -113,8 +113,8 @@ class StockDisposalController extends Controller
|
|||
|
||||
if (! empty($data['stock_entry_id'])) {
|
||||
$entry = StockEntry::query()->lockForUpdate()->findOrFail((int) $data['stock_entry_id']);
|
||||
$consumed = $productionService->consumedByStockEntry([$entry->id])[$entry->id] ?? 0.0;
|
||||
$remaining = round((float) $entry->received_quantity - $consumed, 2);
|
||||
$available = $productionService->availableStockEntriesForIngredient($ingredientId, (int) $entry->location_id)->firstWhere('id', $entry->id);
|
||||
$remaining = $available !== null ? round((float) $available->getAttribute('remaining_quantity'), 2) : 0.0;
|
||||
} else {
|
||||
// Alle Eingänge des Rohstoffs am Standort sperren, dann Standortrest neu berechnen.
|
||||
StockEntry::query()->lockForUpdate()
|
||||
|
|
|
|||
|
|
@ -98,14 +98,17 @@ class StoreProductionRequest extends FormRequest
|
|||
|
||||
// AP-43: Im Entwurf dürfen Zeilen unvollständig sein — nur solche mit
|
||||
// Rohstoff + (Charge oder Wareneingang) bzw. Ergebnis-Produkt übernehmen.
|
||||
$ingredientLines = array_values(array_filter($data['ingredient_lines'] ?? [], function ($line): bool {
|
||||
// N-01 (Nachprüfung 08.09.2026): Im Entwurf zählt auch eine Ist-Menge ohne
|
||||
// gewählte Charge als Arbeitsstand; beim Abschluss verlangen die Regeln die Charge.
|
||||
$isDraft = $this->isDraft();
|
||||
$ingredientLines = array_values(array_filter($data['ingredient_lines'] ?? [], function ($line) use ($isDraft): bool {
|
||||
return is_array($line)
|
||||
&& (int) ($line['ingredient_id'] ?? 0) > 0
|
||||
&& (! empty($line['batch_key']) || (int) ($line['stock_entry_id'] ?? 0) > 0);
|
||||
&& (! empty($line['batch_key']) || (int) ($line['stock_entry_id'] ?? 0) > 0
|
||||
|| ($isDraft && trim((string) ($line['quantity_used'] ?? '')) !== ''));
|
||||
}));
|
||||
|
||||
// F-03: Im Entwurf zählt ein gewähltes Ergebnisprodukt auch ohne Stückzahl als Arbeitsstand.
|
||||
$isDraft = $this->isDraft();
|
||||
$outputs = array_values(array_filter($data['outputs'] ?? [], function ($output) use ($isDraft): bool {
|
||||
return is_array($output)
|
||||
&& (int) ($output['product_id'] ?? 0) > 0
|
||||
|
|
@ -128,7 +131,8 @@ class StoreProductionRequest extends FormRequest
|
|||
if (! empty($line['batch_key'])) {
|
||||
$out['batch_key'] = (string) $line['batch_key'];
|
||||
} else {
|
||||
$out['stock_entry_id'] = (int) $line['stock_entry_id'];
|
||||
// N-01: im Entwurf darf beides fehlen (Menge ohne Charge).
|
||||
$out['stock_entry_id'] = (int) ($line['stock_entry_id'] ?? 0);
|
||||
}
|
||||
|
||||
return $out;
|
||||
|
|
|
|||
|
|
@ -113,8 +113,10 @@ class StoreStockDisposalRequest extends FormRequest
|
|||
return;
|
||||
}
|
||||
|
||||
$consumed = app(ProductionService::class)->consumedByStockEntry([$entry->id])[$entry->id] ?? 0.0;
|
||||
$remaining = round((float) $entry->received_quantity - $consumed, 2);
|
||||
// N-05: Verfügbarkeit der Charge wie in der Produktion (inkl. FEFO-Anrechnung von Alt-Ausschuss ohne Charge).
|
||||
$available = app(ProductionService::class)->availableStockEntriesForIngredient($ingredientId, (int) $entry->location_id)
|
||||
->firstWhere('id', $entry->id);
|
||||
$remaining = $available !== null ? round((float) $available->getAttribute('remaining_quantity'), 2) : 0.0;
|
||||
if ($quantity > $remaining) {
|
||||
$validator->errors()->add('quantity', __('Die Menge (:qty g) übersteigt den Restbestand der Charge (:rest g).', [
|
||||
'qty' => number_format($quantity, 2, ',', '.'),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ class ProductionIngredient extends Model
|
|||
return (string) $this->batch_key;
|
||||
}
|
||||
|
||||
if ($this->stock_entry_id === null) {
|
||||
return ''; // N-01: Entwurfszeile ohne Charge — Menge bleibt, Charge wird später gewählt.
|
||||
}
|
||||
|
||||
return $this->stockEntry?->batchGroupKey() ?? 'e:'.$this->stock_entry_id;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -343,12 +343,16 @@ class InventoryService
|
|||
|
||||
$consumed = $this->productionService->consumedByStockEntry($entries->pluck('id')->all());
|
||||
|
||||
return $entries->map(function (StockEntry $entry) use ($consumed) {
|
||||
$entries = $entries->map(function (StockEntry $entry) use ($consumed) {
|
||||
$received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0;
|
||||
$entry->setAttribute('remaining_quantity', round($received - ($consumed[(int) $entry->id] ?? 0.0), 2));
|
||||
|
||||
return $entry;
|
||||
})->filter(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity') > 0.0)
|
||||
});
|
||||
|
||||
// N-05: Alt-Ausschuss ohne Chargenbezug je Standort FEFO anrechnen (nur Anzeige/Verfügbarkeit).
|
||||
return $this->productionService->applyUnboundDisposals($entries, $ingredientId)
|
||||
->filter(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity') > 0.0)
|
||||
->values();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -350,13 +350,15 @@ class ProductionService
|
|||
*/
|
||||
private function outputSignature(Production $production): array
|
||||
{
|
||||
$signature = $production->outputs()
|
||||
->get(['product_id', 'quantity'])
|
||||
->map(fn ($o) => (int) $o->product_id.':'.(int) $o->quantity)
|
||||
->all();
|
||||
sort($signature);
|
||||
// N-03 (Nachprüfung 08.09.2026): Stückzahlen je Produkt summieren — ein reiner
|
||||
// Chargensplit (6 + 4 statt 10) ist keine Mengenänderung.
|
||||
$totals = [];
|
||||
foreach ($production->outputs()->get(['product_id', 'quantity']) as $o) {
|
||||
$totals[(int) $o->product_id] = ($totals[(int) $o->product_id] ?? 0) + (int) $o->quantity;
|
||||
}
|
||||
ksort($totals);
|
||||
|
||||
return $signature;
|
||||
return array_map(fn ($pid, $qty) => $pid.':'.$qty, array_keys($totals), $totals);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -537,17 +539,65 @@ class ProductionService
|
|||
|
||||
$consumed = $this->consumedByStockEntry($entries->pluck('id')->all(), $excludeProductionId);
|
||||
|
||||
return $entries
|
||||
->map(function (StockEntry $entry) use ($consumed) {
|
||||
$received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0;
|
||||
$entry->setAttribute('remaining_quantity', round($received - ($consumed[(int) $entry->id] ?? 0.0), 2));
|
||||
$entries = $entries->map(function (StockEntry $entry) use ($consumed) {
|
||||
$received = $entry->received_quantity !== null ? (float) $entry->received_quantity : 0.0;
|
||||
$entry->setAttribute('remaining_quantity', round($received - ($consumed[(int) $entry->id] ?? 0.0), 2));
|
||||
|
||||
return $entry;
|
||||
})
|
||||
return $entry;
|
||||
});
|
||||
|
||||
return $this->applyUnboundDisposals($entries, $ingredientId)
|
||||
->filter(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity') > 0.0)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* N-05 (Nachprüfung 08.09.2026): Ausschuss **ohne** Chargenbezug (Altbestand vor F-09)
|
||||
* mindert Gesamt- und Standortbestand, aber keine einzelne Charge. Für die
|
||||
* Verfügbarkeit wird er hier je Standort FEFO auf die Chargen angerechnet — dieselbe
|
||||
* Regel, nach der neuer ungebundener Ausschuss gebucht wird. Es wird nichts
|
||||
* geschrieben; die Altbuchungen bleiben unverändert.
|
||||
*
|
||||
* @param Collection<int, StockEntry> $entries FEFO-sortiert, mit Attribut remaining_quantity
|
||||
* @return Collection<int, StockEntry>
|
||||
*/
|
||||
public function applyUnboundDisposals(Collection $entries, int $ingredientId): Collection
|
||||
{
|
||||
if ($entries->isEmpty()) {
|
||||
return $entries;
|
||||
}
|
||||
|
||||
$unbound = StockDisposal::query()
|
||||
->where('disposal_type', 'ingredient')
|
||||
->where('ingredient_id', $ingredientId)
|
||||
->whereNull('stock_entry_id')
|
||||
->whereIn('location_id', $entries->pluck('location_id')->unique()->all())
|
||||
->selectRaw('location_id, SUM(quantity) as qty')
|
||||
->groupBy('location_id')
|
||||
->pluck('qty', 'location_id');
|
||||
|
||||
foreach ($unbound as $locationId => $qty) {
|
||||
$needed = round((float) $qty, 2);
|
||||
foreach ($entries as $entry) {
|
||||
if ($needed <= 0) {
|
||||
break;
|
||||
}
|
||||
if ((int) $entry->location_id !== (int) $locationId) {
|
||||
continue;
|
||||
}
|
||||
$remaining = (float) $entry->getAttribute('remaining_quantity');
|
||||
if ($remaining <= 0) {
|
||||
continue;
|
||||
}
|
||||
$take = round(min($remaining, $needed), 2);
|
||||
$entry->setAttribute('remaining_quantity', round($remaining - $take, 2));
|
||||
$needed = round($needed - $take, 2);
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public function stockEntryLabel(StockEntry $entry): string
|
||||
{
|
||||
$parts = [];
|
||||
|
|
@ -788,7 +838,10 @@ class ProductionService
|
|||
{
|
||||
return array_values(array_filter($ingredientLines, function (array $line): bool {
|
||||
return (int) ($line['ingredient_id'] ?? 0) > 0
|
||||
&& ((int) ($line['stock_entry_id'] ?? 0) > 0 || trim((string) ($line['batch_key'] ?? '')) !== '');
|
||||
&& ((int) ($line['stock_entry_id'] ?? 0) > 0
|
||||
|| trim((string) ($line['batch_key'] ?? '')) !== ''
|
||||
// N-01: Ist-Menge ohne Charge ebenfalls sichern.
|
||||
|| $this->parseQuantity($line['quantity_used'] ?? null) > 0);
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -190,3 +190,41 @@ test('F-09: ausschuss ohne charge wird FEFO auf die chargen des lagerorts vertei
|
|||
expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(0.0)
|
||||
->and(app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $loc->id))->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('N-05: alt-ausschuss ohne chargenbezug wird in der chargenverfügbarkeit FEFO je standort angerechnet', function () {
|
||||
$user = cdcUser();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$loc = Location::factory()->create();
|
||||
$other = Location::factory()->create();
|
||||
$ing = cdcIngredient('N05-Alt');
|
||||
$old = cdcStock($ing, $loc, $supplier, $user, ['received_quantity' => 300, 'batch_number' => 'N05-ALT', 'best_before' => '2027-01-01']);
|
||||
$new = cdcStock($ing, $loc, $supplier, $user, ['received_quantity' => 700, 'batch_number' => 'N05-NEU', 'best_before' => '2029-01-01']);
|
||||
$elsewhere = cdcStock($ing, $other, $supplier, $user, ['received_quantity' => 500, 'batch_number' => 'N05-ANDERER']);
|
||||
|
||||
// Altbuchung direkt (ohne Controller/FEFO-Verteilung): 400 g ohne Charge am Standort loc.
|
||||
cdcDisposal($ing, $loc, 400);
|
||||
|
||||
$inventory = app(InventoryService::class);
|
||||
$service = app(ProductionService::class);
|
||||
$available = $service->availableStockEntriesForIngredient($ing->id, $loc->id);
|
||||
|
||||
expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(1100.0)
|
||||
->and($available)->toHaveCount(1)
|
||||
->and($available->first()->id)->toBe($new->id)
|
||||
->and((float) $available->first()->getAttribute('remaining_quantity'))->toBe(600.0)
|
||||
->and($inventory->chargesWithRemainingForIngredient($ing->id)->sum('remaining_quantity'))->toEqual(1100)
|
||||
->and((float) $service->availableStockEntriesForIngredient($ing->id, $other->id)->first()->getAttribute('remaining_quantity'))->toBe(500.0);
|
||||
|
||||
// D-07 des Erstberichts: gesamter Eingang ohne Charge entsorgt → keine Charge mehr verfügbar.
|
||||
cdcDisposal($ing, $loc, 600);
|
||||
expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(500.0)
|
||||
->and($service->availableStockEntriesForIngredient($ing->id, $loc->id))->toHaveCount(0);
|
||||
|
||||
// Chargen-Ausschuss über den Rest der Charge hinaus wird abgelehnt (nutzt dieselbe Verfügbarkeit).
|
||||
$this->actingAs($user, 'user');
|
||||
$this->from(route('admin.inventory.stock-disposals.create'))
|
||||
->post(route('admin.inventory.stock-disposals.store'), [
|
||||
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'stock_entry_id' => $new->id, 'location_id' => $loc->id,
|
||||
'quantity' => '1', 'reason' => 'Verfall / MHD überschritten', 'disposed_at' => '2026-09-01',
|
||||
])->assertSessionHasErrors('quantity');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -215,6 +215,16 @@ test('F-05: notizkorrektur behält den historischen verpackungs-snapshot, mengen
|
|||
$production = $service->updateProduction($production->fresh(), $head + ['quantity' => 3], [], $user->id,
|
||||
[['product_id' => $product->id, 'quantity' => 3, 'batch_number' => 'F05']]);
|
||||
expect($production->productionPackagings->sum('quantity_used'))->toBe(6);
|
||||
|
||||
// N-03: Chargensplit bei gleicher Gesamtstückzahl (3 → 2 + 1) ist keine Mengenänderung — Snapshot bleibt,
|
||||
// auch wenn die Stückliste inzwischen wieder anders ist.
|
||||
$product->packagings()->sync([$pk->id => ['quantity' => 5, 'pos' => 0]]);
|
||||
$production = $service->updateProduction($production->fresh(), $head + ['quantity' => 3], [], $user->id, [
|
||||
['product_id' => $product->id, 'quantity' => 2, 'batch_number' => 'F05-A'],
|
||||
['product_id' => $product->id, 'quantity' => 1, 'batch_number' => 'F05-B'],
|
||||
]);
|
||||
expect($production->productionPackagings->sum('quantity_used'))->toBe(6)
|
||||
->and($production->outputs)->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('AP-47: produktions-auswahl zeigt nur produktionsrelevante produkte', function () {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue