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:
parent
c2a17835ef
commit
731b2461e2
7 changed files with 170 additions and 36 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use App\Models\Ingredient;
|
|||
use App\Models\StockEntry;
|
||||
use App\Models\TaxRate;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StockEntryRepository
|
||||
{
|
||||
|
|
@ -133,18 +135,28 @@ class StockEntryRepository
|
|||
*/
|
||||
public function receive(StockEntry $stockEntry, array $data): StockEntry
|
||||
{
|
||||
$data['status'] = 'received';
|
||||
$data['received_by'] = auth()->id();
|
||||
return DB::transaction(function () use ($stockEntry, $data) {
|
||||
// Prüfung 08.09.2026 F-16: Zeile sperren und „offen“ innerhalb der Sperre erneut
|
||||
// prüfen — zwei parallele Wareneingänge schreiben sonst nacheinander verschiedene
|
||||
// Mengen auf denselben Eintrag.
|
||||
$locked = StockEntry::query()->lockForUpdate()->findOrFail($stockEntry->id);
|
||||
if (! $locked->isPending()) {
|
||||
throw ValidationException::withMessages(['status' => __('Eintrag nicht mehr offen.')]);
|
||||
}
|
||||
|
||||
// AP-42: Wurde in ml eingekauft, wird auch die gelieferte Menge in ml erfasst
|
||||
// und über die (beim Bestellen gespeicherte) Dichte in Gramm umgerechnet.
|
||||
if ($stockEntry->purchase_unit === 'ml' && $stockEntry->density_used !== null && isset($data['received_quantity'])) {
|
||||
$data['received_quantity'] = round((float) $data['received_quantity'] * (float) $stockEntry->density_used, 2);
|
||||
}
|
||||
$data['status'] = 'received';
|
||||
$data['received_by'] = auth()->id();
|
||||
|
||||
$stockEntry->update($data);
|
||||
// AP-42: Wurde in ml eingekauft, wird auch die gelieferte Menge in ml erfasst
|
||||
// und über die (beim Bestellen gespeicherte) Dichte in Gramm umgerechnet.
|
||||
if ($locked->purchase_unit === 'ml' && $locked->density_used !== null && isset($data['received_quantity'])) {
|
||||
$data['received_quantity'] = round((float) $data['received_quantity'] * (float) $locked->density_used, 2);
|
||||
}
|
||||
|
||||
return $stockEntry->fresh();
|
||||
$locked->update($data);
|
||||
|
||||
return $locked->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Services;
|
|||
use App\Models\Setting;
|
||||
use App\Models\ShoppingOrder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* AP-33a: Zentraler Versand-Statuswechsel einer Bestellung — extrahiert aus
|
||||
|
|
@ -14,10 +15,23 @@ use Illuminate\Support\Facades\DB;
|
|||
*/
|
||||
class OrderStatusService
|
||||
{
|
||||
public function setShipped(int $orderId, int $shipped, ?int $userId = null, bool $lockStatus = false): ShoppingOrder
|
||||
/**
|
||||
* @param (\Closure(ShoppingOrder): ?string)|null $precondition Prüfung 08.09.2026 F-16: fachliche
|
||||
* Vorbedingung, die innerhalb der Sperre
|
||||
* erneut geprüft wird (liefert Fehlertext
|
||||
* oder null). Sonst können zwei Requests
|
||||
* denselben Ausgangsstatus lesen und sich
|
||||
* gegenseitig überschreiben.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function setShipped(int $orderId, int $shipped, ?int $userId = null, bool $lockStatus = false, ?\Closure $precondition = null): ShoppingOrder
|
||||
{
|
||||
return DB::transaction(function () use ($orderId, $shipped, $userId, $lockStatus) {
|
||||
return DB::transaction(function () use ($orderId, $shipped, $userId, $lockStatus, $precondition) {
|
||||
$shopping_order = ShoppingOrder::query()->lockForUpdate()->findOrFail($orderId);
|
||||
if ($precondition !== null && ($error = $precondition($shopping_order)) !== null) {
|
||||
throw ValidationException::withMessages(['shipped' => $error]);
|
||||
}
|
||||
$shopping_order->shipped = $shipped;
|
||||
if ($lockStatus) {
|
||||
// Kundenentscheid 16.07.2026 (AP-33): Status aus der WaWi gesetzt ⇒
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ use App\Models\Location;
|
|||
use App\Models\MaterialQuality;
|
||||
use App\Models\StockEntry;
|
||||
use App\Models\Supplier;
|
||||
use App\Repositories\StockEntryRepository;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
|
@ -172,3 +174,25 @@ test('F-15: kopie nutzt den dichte-snapshot, wenn die rohstoff-dichte inzwischen
|
|||
->and((float) $copy->density_used)->toBe(0.80)
|
||||
->and((float) $copy->ordered_quantity)->toBe(200.0);
|
||||
});
|
||||
|
||||
test('F-16: wareneingang prueft „offen“ innerhalb der sperre — zweite buchung wird abgelehnt', function () {
|
||||
[$admin, $ingredient, $location, $supplier, $quality] = densSetup(null);
|
||||
$this->actingAs($admin, 'user');
|
||||
|
||||
$this->post(route('admin.inventory.stock-entries.store'), [
|
||||
'entry_type' => 'ingredient', 'ingredient_id' => (string) $ingredient->id, 'supplier_id' => (string) $supplier->id,
|
||||
'location_id' => (string) $location->id, 'quality_id' => (string) $quality->id, 'ordered_at' => '2026-08-01',
|
||||
'ordered_quantity' => '500', 'ordered_unit' => 'gram', 'price_per_kg' => '10,00',
|
||||
])->assertRedirect();
|
||||
$entry = StockEntry::query()->latest('id')->firstOrFail();
|
||||
|
||||
$repo = app(StockEntryRepository::class);
|
||||
$repo->receive($entry, ['received_at' => '2026-08-05', 'received_quantity' => 500, 'batch_number' => 'F16-1']);
|
||||
|
||||
// Zweiter Request mit veraltetem Modell (noch „pending“ im Speicher) muss scheitern.
|
||||
expect(fn () => $repo->receive($entry, ['received_at' => '2026-08-06', 'received_quantity' => 999, 'batch_number' => 'F16-2']))
|
||||
->toThrow(ValidationException::class);
|
||||
|
||||
expect((float) $entry->fresh()->received_quantity)->toBe(500.0)
|
||||
->and($entry->fresh()->batch_number)->toBe('F16-1');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@ use App\Models\ShoppingPayment;
|
|||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAccount;
|
||||
use App\Services\Invoice;
|
||||
use App\Services\OrderStatusService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
|
@ -455,3 +457,18 @@ test('F-10: ohne zahlungsfreigabe kein versandabschluss ueber das board, konfekt
|
|||
expect((int) $order->fresh()->shipped)->toBe(2)
|
||||
->and(ProductStockMovement::query()->where('product_id', $product->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('F-16: statusservice prueft die vorbedingung innerhalb der sperre erneut', function () {
|
||||
$order = ofMakeOrder([], ['shipped' => 0]);
|
||||
|
||||
expect(fn () => app(OrderStatusService::class)->setShipped(
|
||||
$order->id, 2, null, true, fn (ShoppingOrder $o) => __('abgelehnt')
|
||||
))->toThrow(ValidationException::class);
|
||||
|
||||
expect((int) $order->fresh()->shipped)->toBe(0)
|
||||
->and($order->fresh()->shipped_status_locked)->toBeFalsy();
|
||||
|
||||
// Ohne Fehler aus der Vorbedingung läuft der Wechsel wie gehabt.
|
||||
app(OrderStatusService::class)->setShipped($order->id, 1, null, true, fn (ShoppingOrder $o) => null);
|
||||
expect((int) $order->fresh()->shipped)->toBe(1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue