Warenwirtschaft: AP-27 Wareneingang gleiche Charge zusammenführen

- Chargen-Existenz-Check beim Buchen (JSON-Endpoint batch-check): Hinweis
  „Charge bereits im Haus" + Vorbelegung MHD/Qualität aus dem letzten Eingang
- Rohstoffbestand-Detail: Chargen je batch_number summiert
  (InventoryService::chargeGroupsForIngredient), Einkäufe als Unterzeilen
- Produktion: Charge-Dropdown je Charge mit Summen-Rest (batches im
  Recipe-Payload), Verbrauch wird per expandBatchLines FEFO auf die
  Einzel-Einkäufe der Charge verteilt; Service-API bleibt je stock_entry
- Jeder Einkauf bleibt eigener stock_entry (Preis/Datum/Audit)
- Hinweise-Doku + Plan V6.0 §6 fortgeschrieben

Tests: StockEntryChargeMergeTest (7 neu); Suite 193 grün, 698 Assertions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-06 13:36:19 +00:00
parent e47d848d3d
commit 7a5b5e0f7b
16 changed files with 673 additions and 34 deletions

View file

@ -14,6 +14,7 @@ use App\Models\StockEntry;
use App\Models\Supplier;
use App\Models\TaxRate;
use App\Repositories\StockEntryRepository;
use App\Services\InventoryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -201,6 +202,51 @@ class StockEntryController extends Controller
return redirect()->route('admin.inventory.stock-entries.show', $stockEntry->fresh());
}
/**
* AP-27: Existenz-Check beim Buchen des Wareneingangs — ist die Charge
* (batch_number + ingredient_id) schon im Haus? Liefert Hinweis-Daten
* und Vorbelegungswerte (MHD/Qualität) aus dem letzten Eingang der Charge.
*/
public function batchCheck(Request $request, InventoryService $inventoryService): JsonResponse
{
$ingredientId = (int) $request->query('ingredient_id');
$batchNumber = trim((string) $request->query('batch_number', ''));
$excludeId = (int) $request->query('exclude', 0);
if ($ingredientId < 1 || $batchNumber === '') {
return response()->json(['exists' => false]);
}
$entries = StockEntry::query()
->with('location')
->where('entry_type', 'ingredient')
->where('status', 'received')
->where('ingredient_id', $ingredientId)
->where('batch_number', $batchNumber)
->when($excludeId > 0, fn ($q) => $q->where('id', '!=', $excludeId))
->orderByDesc('received_at')
->orderByDesc('id')
->get();
if ($entries->isEmpty()) {
return response()->json(['exists' => false]);
}
$latest = $entries->first();
$remaining = $inventoryService->chargesWithRemainingForIngredient($ingredientId)
->filter(fn (StockEntry $e) => trim((string) $e->batch_number) === $batchNumber && $e->id !== $excludeId)
->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity'));
return response()->json([
'exists' => true,
'purchases' => $entries->count(),
'remaining' => round((float) $remaining, 2),
'best_before' => $latest->best_before?->format('d.m.Y'),
'location' => $latest->location?->name,
'quality_id' => $latest->quality_id,
]);
}
public function searchIngredients(Request $request): JsonResponse
{
$term = trim((string) $request->query('q', ''));