Warenwirtschaft: F-16 — fachliche Vorbedingungen innerhalb der Sperre prüfen

- Statusservice: Vorbedingung (Ausgangsstatus, Zahlungsfreigabe) wird nach
  lockForUpdate erneut geprüft (Board übergibt sie als Closure).
- Wareneingang: Transaktion + Sperre, „offen“ wird innerhalb der Sperre geprüft.
- Ausschuss: betroffene Wareneingänge sperren und Restbestand in der
  Transaktion erneut prüfen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-08 11:09:53 +00:00
parent c2a17835ef
commit 731b2461e2
7 changed files with 170 additions and 36 deletions

View file

@ -12,6 +12,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/**
* AP-33a: Abwicklungs-Board („Bestellungen → Abwicklung") — kompakte Tagesübersicht
@ -85,29 +86,40 @@ class OrderFulfillmentController extends Controller
'shipped' => ['required', 'integer', Rule::in([1, 2])],
]);
$order = ShoppingOrder::query()->findOrFail($id);
if (! in_array((int) $order->shipped, [0, 1], true)) {
session()->flash('alert-error', __('Bestellung ist nicht mehr in der Abwicklung (Status: :status).', ['status' => $order->getShippedType()]));
$shipped = (int) $request->input('shipped');
$backUrl = route('admin.inventory.orders.index', array_filter(['filter' => $request->input('back_filter')]));
return redirect()->route('admin.inventory.orders.index');
// Fachliche Vorbedingungen — Prüfung 08.09.2026 F-16: werden vom Statusservice
// innerhalb der Zeilensperre erneut geprüft, nicht nur hier vorab.
$precondition = function (ShoppingOrder $order) use ($shipped): ?string {
if (! in_array((int) $order->shipped, [0, 1], true)) {
return __('Bestellung ist nicht mehr in der Abwicklung (Status: :status).', ['status' => $order->getShippedType()]);
}
// Prüfung 08.09.2026 F-10: „versendet“ (Bestandsabzug) nur für versandfertige
// Bestellungen — Zahlung eingegangen bzw. Rechnung/API (Kundenentscheid 16.07.2026,
// isReadyToShip). Konfektionieren bleibt auch vor Zahlungseingang möglich.
if ($shipped === 2 && ! $order->isReadyToShip()) {
return __('Bestellung #:id wartet auf Zahlung und kann noch nicht als versendet gebucht werden.', ['id' => $order->id]);
}
return null;
};
ShoppingOrder::query()->findOrFail($id);
try {
app(OrderStatusService::class)->setShipped($id, $shipped, auth()->id(), lockStatus: true, precondition: $precondition);
} catch (ValidationException $e) {
session()->flash('alert-error', $e->errors()['shipped'][0] ?? __('Statuswechsel nicht möglich.'));
return redirect()->to($backUrl);
}
// Prüfung 08.09.2026 F-10: „versendet“ (Bestandsabzug) nur für versandfertige
// Bestellungen — Zahlung eingegangen bzw. Rechnung/API (Kundenentscheid 16.07.2026,
// isReadyToShip). Konfektionieren bleibt auch vor Zahlungseingang möglich.
if ((int) $request->input('shipped') === 2 && ! $order->isReadyToShip()) {
session()->flash('alert-error', __('Bestellung #:id wartet auf Zahlung und kann noch nicht als versendet gebucht werden.', ['id' => $id]));
return redirect()->route('admin.inventory.orders.index', array_filter(['filter' => $request->input('back_filter')]));
}
app(OrderStatusService::class)->setShipped($id, (int) $request->input('shipped'), auth()->id(), lockStatus: true);
session()->flash('alert-success', (int) $request->input('shipped') === 2
? __('Bestellung #:id als versendet gebucht — Produktbestand wurde abgezogen.', ['id' => $id])
: __('Bestellung #:id als konfektioniert markiert.', ['id' => $id]));
return redirect()->route('admin.inventory.orders.index', array_filter(['filter' => $request->input('back_filter')]));
return redirect()->to($backUrl);
}
/**

View file

@ -15,6 +15,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class StockDisposalController extends Controller
@ -66,6 +67,8 @@ class StockDisposalController extends Controller
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'reasons' => $this->reasons(),
'prefill' => $prefill,
// AP-50/F-17: Köln auch beim Ausschuss vorbelegen.
'defaultLocationId' => Location::defaultId(),
]);
}
@ -74,17 +77,63 @@ class StockDisposalController extends Controller
$data = $request->validatedPayload();
$data['user_id'] = (int) $request->user()->id;
DB::transaction(function () use ($data, $productionService): void {
foreach ($this->splitAcrossBatches($data, $productionService) as $row) {
StockDisposal::query()->create($row);
}
});
try {
DB::transaction(function () use ($data, $productionService): void {
$this->assertStillAvailable($data, $productionService);
foreach ($this->splitAcrossBatches($data, $productionService) as $row) {
StockDisposal::query()->create($row);
}
});
} catch (ValidationException $e) {
return redirect()->back()->withInput()->withErrors($e->errors());
}
\Session::flash('alert-save', '1');
return redirect()->route('admin.inventory.stock-disposals.index');
}
/**
* Prüfung 08.09.2026 F-16: Bestandsprüfung innerhalb der Transaktion wiederholen,
* nachdem die betroffenen Wareneingänge gesperrt wurden — die FormRequest-Prüfung
* allein lässt zwei parallele Entnahmen denselben Rest bestehen.
*
* @param array<string, mixed> $data
*
* @throws ValidationException
*/
private function assertStillAvailable(array $data, ProductionService $productionService): void
{
if (($data['disposal_type'] ?? null) !== 'ingredient') {
return;
}
$ingredientId = (int) $data['ingredient_id'];
$quantity = round((float) $data['quantity'], 2);
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);
} else {
// Alle Eingänge des Rohstoffs am Standort sperren, dann Standortrest neu berechnen.
StockEntry::query()->lockForUpdate()
->where('entry_type', 'ingredient')->where('ingredient_id', $ingredientId)
->where('location_id', (int) $data['location_id'])
->get();
$remaining = round((float) ($this->inventoryService->remainingByLocationForIngredient($ingredientId)[(int) $data['location_id']] ?? 0.0), 2);
}
if ($quantity > $remaining) {
throw ValidationException::withMessages([
'quantity' => __('Die Menge (:qty g) übersteigt den Restbestand (:rest g) — Bestand hat sich zwischenzeitlich geändert.', [
'qty' => number_format($quantity, 2, ',', '.'),
'rest' => number_format(max(0, $remaining), 2, ',', '.'),
]),
]);
}
}
/**
* 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

View file

@ -19,6 +19,7 @@ use App\Services\InventoryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class StockEntryController extends Controller
@ -46,9 +47,8 @@ class StockEntryController extends Controller
$model = new StockEntry([
'ordered_at' => now()->toDateString(),
'entry_type' => 'ingredient',
// AP-50: Köln als Standard-Lagerort vorbelegen (wie in der Produktion).
'location_id' => Location::query()->where('name', 'like', '%öln%')->value('id')
?? Location::query()->where('active', true)->orderBy('name')->value('id'),
// AP-50/F-17: Köln (aktiv) als Standard-Lagerort — zentral in Location::defaultId().
'location_id' => Location::defaultId(),
]);
$ingredientId = (int) $request->query('ingredient_id');
@ -213,7 +213,13 @@ class StockEntryController extends Controller
$data['best_before'] = null;
}
$this->stockEntryRepository->receive($stockEntry, $data);
try {
$this->stockEntryRepository->receive($stockEntry, $data);
} catch (ValidationException $e) {
\Session::flash('alert-error', $e->errors()['status'][0] ?? __('Eintrag nicht mehr offen.'));
return redirect()->route('admin.inventory.stock-entries.show', $stockEntry);
}
\Session::flash('alert-save', '1');