Warenwirtschaft: F-09 — Ausschuss ohne Charge standort- und chargenkonsistent
Ohne Charge wird gegen den Restbestand des gewählten Lagerorts geprüft und die Menge FEFO auf die dort verfügbaren Chargen verteilt (je Charge gebucht, in einer Transaktion) — wie der Produktionsverbrauch. Gesamt-, Standort- und Chargenbestand stimmen damit überein. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
2796b1103e
commit
db27f040e6
3 changed files with 107 additions and 5 deletions
|
|
@ -10,9 +10,11 @@ use App\Models\Location;
|
|||
use App\Models\StockDisposal;
|
||||
use App\Models\StockEntry;
|
||||
use App\Services\InventoryService;
|
||||
use App\Services\ProductionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class StockDisposalController extends Controller
|
||||
|
|
@ -67,18 +69,61 @@ class StockDisposalController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
public function store(StoreStockDisposalRequest $request): RedirectResponse
|
||||
public function store(StoreStockDisposalRequest $request, ProductionService $productionService): RedirectResponse
|
||||
{
|
||||
$data = $request->validatedPayload();
|
||||
$data['user_id'] = (int) $request->user()->id;
|
||||
|
||||
StockDisposal::query()->create($data);
|
||||
DB::transaction(function () use ($data, $productionService): void {
|
||||
foreach ($this->splitAcrossBatches($data, $productionService) as $row) {
|
||||
StockDisposal::query()->create($row);
|
||||
}
|
||||
});
|
||||
|
||||
\Session::flash('alert-save', '1');
|
||||
|
||||
return redirect()->route('admin.inventory.stock-disposals.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüfung 08.09.2026 F-09: Rohstoff-Ausschuss ohne bestimmte Charge wird — wie der
|
||||
* Produktionsverbrauch (AP-27, FEFO) — auf die am Lagerort verfügbaren Chargen
|
||||
* verteilt und je Charge gebucht. Sonst sinkt nur der Gesamtbestand, während die
|
||||
* Chargen in Produktion/Ausschuss weiter als voll verfügbar angeboten würden.
|
||||
* Ohne verfügbare Charge (Altbestand ohne Chargenbezug) bleibt die Buchung ungebunden.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function splitAcrossBatches(array $data, ProductionService $productionService): array
|
||||
{
|
||||
if (($data['disposal_type'] ?? null) !== 'ingredient' || ! empty($data['stock_entry_id'])) {
|
||||
return [$data];
|
||||
}
|
||||
|
||||
$entries = $productionService->availableStockEntriesForIngredient((int) $data['ingredient_id'], (int) $data['location_id']);
|
||||
if ($entries->isEmpty()) {
|
||||
return [$data];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$needed = round((float) $data['quantity'], 2);
|
||||
$lastIndex = $entries->count() - 1;
|
||||
foreach ($entries as $index => $entry) {
|
||||
$capacity = (float) $entry->getAttribute('remaining_quantity');
|
||||
$take = $index === $lastIndex ? $needed : round(min($needed, $capacity), 2);
|
||||
if ($take > 0) {
|
||||
$rows[] = array_merge($data, ['stock_entry_id' => (int) $entry->id, 'quantity' => $take]);
|
||||
}
|
||||
$needed = round($needed - $take, 2);
|
||||
if ($needed <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anforderung 15.07.2026: Ausschuss-Buchungen löschbar (Soft-Delete).
|
||||
* Die Bestandsaggregationen schließen gelöschte Buchungen automatisch
|
||||
|
|
|
|||
|
|
@ -88,11 +88,14 @@ class StoreStockDisposalRequest extends FormRequest
|
|||
$ingredientId = (int) $this->input('ingredient_id');
|
||||
|
||||
if (! $this->filled('stock_entry_id')) {
|
||||
if ($ingredientId > 0 && $quantity > 0) {
|
||||
// Prüfung 08.09.2026 F-09: ohne Charge gegen den gewählten Lagerort prüfen,
|
||||
// nicht gegen den globalen Bestand (sonst geht ein leerer Standort ins Minus).
|
||||
$locationId = (int) $this->input('location_id');
|
||||
if ($ingredientId > 0 && $quantity > 0 && $locationId > 0) {
|
||||
$remaining = app(InventoryService::class)
|
||||
->remainingByIngredient([$ingredientId])[$ingredientId] ?? 0.0;
|
||||
->remainingByLocationForIngredient($ingredientId)[$locationId] ?? 0.0;
|
||||
if ($quantity > $remaining) {
|
||||
$validator->errors()->add('quantity', __('Die Menge (:qty g) übersteigt den Restbestand (:rest g).', [
|
||||
$validator->errors()->add('quantity', __('Die Menge (:qty g) übersteigt den Restbestand am gewählten Lagerort (:rest g).', [
|
||||
'qty' => number_format($quantity, 2, ',', '.'),
|
||||
'rest' => number_format(max(0, $remaining), 2, ',', '.'),
|
||||
]));
|
||||
|
|
|
|||
|
|
@ -136,3 +136,57 @@ test('standort-aggregation rechnet chargen-ausschuss am standort der charge ab',
|
|||
expect($byLocation[$locationA->id])->toBe(900.0);
|
||||
expect($byLocation)->not->toHaveKey($locationB->id);
|
||||
});
|
||||
|
||||
test('F-09: ausschuss ohne charge prüft den gewählten lagerort, nicht den globalen bestand', function () {
|
||||
$user = cdcUser();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$locA = Location::factory()->create(['name' => 'F09-A']);
|
||||
$locB = Location::factory()->create(['name' => 'F09-B']);
|
||||
$ing = cdcIngredient('F09-Standort');
|
||||
cdcStock($ing, $locA, $supplier, $user, ['received_quantity' => 1000, 'batch_number' => 'F09-1']);
|
||||
|
||||
$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, 'location_id' => $locB->id,
|
||||
'quantity' => '500', 'reason' => 'Verfall / MHD überschritten', 'disposed_at' => '2026-09-01',
|
||||
])->assertSessionHasErrors('quantity');
|
||||
|
||||
expect(app(InventoryService::class)->remainingByLocationForIngredient($ing->id)[$locB->id] ?? 0.0)->toBe(0.0);
|
||||
});
|
||||
|
||||
test('F-09: ausschuss ohne charge wird FEFO auf die chargen des lagerorts verteilt und reduziert deren verfügbarkeit', function () {
|
||||
$user = cdcUser();
|
||||
$supplier = Supplier::factory()->create();
|
||||
$loc = Location::factory()->create();
|
||||
$ing = cdcIngredient('F09-FEFO');
|
||||
$old = cdcStock($ing, $loc, $supplier, $user, ['received_quantity' => 300, 'batch_number' => 'F09-ALT', 'best_before' => '2027-01-01']);
|
||||
$new = cdcStock($ing, $loc, $supplier, $user, ['received_quantity' => 700, 'batch_number' => 'F09-NEU', 'best_before' => '2029-01-01']);
|
||||
|
||||
$this->actingAs($user, 'user');
|
||||
$this->post(route('admin.inventory.stock-disposals.store'), [
|
||||
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $loc->id,
|
||||
'quantity' => '400', 'reason' => 'Verfall / MHD überschritten', 'disposed_at' => '2026-09-01',
|
||||
])->assertRedirect()->assertSessionHasNoErrors();
|
||||
|
||||
// 300 g aus der älteren Charge (FEFO), 100 g aus der neueren — beides chargenbezogen.
|
||||
$this->assertDatabaseHas('stock_disposals', ['stock_entry_id' => $old->id, 'quantity' => 300.00]);
|
||||
$this->assertDatabaseHas('stock_disposals', ['stock_entry_id' => $new->id, 'quantity' => 100.00]);
|
||||
$this->assertDatabaseMissing('stock_disposals', ['ingredient_id' => $ing->id, 'stock_entry_id' => null]);
|
||||
|
||||
$inventory = app(InventoryService::class);
|
||||
$charges = app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $loc->id);
|
||||
expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(600.0)
|
||||
->and($charges)->toHaveCount(1)
|
||||
->and($charges->first()->id)->toBe($new->id)
|
||||
->and((float) $charges->first()->getAttribute('remaining_quantity'))->toBe(600.0)
|
||||
->and($inventory->chargesWithRemainingForIngredient($ing->id)->sum('remaining_quantity'))->toEqual(600);
|
||||
|
||||
// Gesamten Rest ohne Charge entsorgen: global 0 und keine Charge mehr verfügbar (D-07).
|
||||
$this->post(route('admin.inventory.stock-disposals.store'), [
|
||||
'disposal_type' => 'ingredient', 'ingredient_id' => $ing->id, 'location_id' => $loc->id,
|
||||
'quantity' => '600', 'reason' => 'Verfall / MHD überschritten', 'disposed_at' => '2026-09-02',
|
||||
])->assertSessionHasNoErrors();
|
||||
expect($inventory->remainingByIngredient([$ing->id])[$ing->id])->toBe(0.0)
|
||||
->and(app(ProductionService::class)->availableStockEntriesForIngredient($ing->id, $loc->id))->toHaveCount(0);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue