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

@ -53,6 +53,12 @@ class ProductionController extends Controller
$payload = $request->validatedPayload();
try {
// AP-27: Chargen-Zeilen (batch_key) FEFO auf die Einzel-Wareneingänge der Charge auflösen.
$ingredientLines = $this->productionService->expandBatchLines(
$payload['ingredient_lines'],
$payload['location_id']
);
$production = $this->productionService->store(
[
'product_id' => $payload['product_id'],
@ -61,7 +67,7 @@ class ProductionController extends Controller
'quantity' => $payload['quantity'],
'notes' => $payload['notes'],
],
$payload['ingredient_lines'],
$ingredientLines,
(int) $request->user()->id
);
} catch (ValidationException $e) {
@ -119,6 +125,13 @@ class ProductionController extends Controller
$payload = $request->validatedPayload();
try {
// AP-27: Beim Bearbeiten die eigene Produktion aus der Rest-Berechnung ausnehmen.
$ingredientLines = $this->productionService->expandBatchLines(
$payload['ingredient_lines'],
$payload['location_id'],
$production->id
);
$production = $this->productionService->updateProduction(
$production,
[
@ -128,7 +141,7 @@ class ProductionController extends Controller
'quantity' => $payload['quantity'],
'notes' => $payload['notes'],
],
$payload['ingredient_lines'],
$ingredientLines,
(int) $request->user()->id
);
} catch (ValidationException $e) {

View file

@ -5,7 +5,6 @@ namespace App\Http\Controllers\Admin\Inventory;
use App\Http\Controllers\Controller;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\StockEntry;
use App\Models\TaxRate;
use App\Repositories\StockEntryRepository;
use App\Services\InventoryService;
@ -76,8 +75,9 @@ class RawMaterialStockController extends Controller
'products' => fn ($q) => $q->where('active', true),
]);
$charges = $this->inventoryService->chargesWithRemainingForIngredient($ingredient->id);
$remaining = round((float) $charges->sum(fn (StockEntry $entry) => (float) $entry->getAttribute('remaining_quantity')), 2);
// AP-27: Chargen je batch_number zusammengeführt (jeder Einkauf bleibt eigener stock_entry).
$chargeGroups = $this->inventoryService->chargeGroupsForIngredient($ingredient->id);
$remaining = round((float) $chargeGroups->sum('remaining'), 2);
$remainingByLocation = $this->inventoryService->remainingByLocationForIngredient($ingredient->id);
@ -95,7 +95,7 @@ class RawMaterialStockController extends Controller
return view('admin.inventory.raw-material-stock.show', [
'ingredient' => $ingredient,
'productStock' => $productStock,
'charges' => $charges,
'chargeGroups' => $chargeGroups,
'remaining' => round($remaining, 2),
'remainingByLocation' => $remainingByLocation,
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),

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', ''));

View file

@ -27,7 +27,9 @@ class StoreProductionRequest extends FormRequest
'notes' => ['nullable', 'string', 'max:2000'],
'ingredient_lines' => [$recipeRequired ? 'required' : 'nullable', 'array', $recipeRequired ? 'min:1' : 'min:0'],
'ingredient_lines.*.ingredient_id' => ['required', 'integer', 'exists:ingredients,id'],
'ingredient_lines.*.stock_entry_id' => ['required', 'integer', 'exists:stock_entries,id'],
// AP-27: Das Formular übergibt Chargen (batch_key); Einzel-Einträge (stock_entry_id) bleiben unterstützt.
'ingredient_lines.*.batch_key' => ['required_without:ingredient_lines.*.stock_entry_id', 'nullable', 'string', 'max:120'],
'ingredient_lines.*.stock_entry_id' => ['required_without:ingredient_lines.*.batch_key', 'nullable', 'integer', 'exists:stock_entries,id'],
'ingredient_lines.*.quantity_used' => ['required', 'string'],
];
}
@ -53,11 +55,17 @@ class StoreProductionRequest extends FormRequest
'quantity' => (int) $data['quantity'],
'notes' => $data['notes'] ?? null,
'ingredient_lines' => array_map(function (array $line): array {
return [
$out = [
'ingredient_id' => (int) $line['ingredient_id'],
'stock_entry_id' => (int) $line['stock_entry_id'],
'quantity_used' => $line['quantity_used'],
];
if (! empty($line['batch_key'])) {
$out['batch_key'] = (string) $line['batch_key'];
} else {
$out['stock_entry_id'] = (int) $line['stock_entry_id'];
}
return $out;
}, $data['ingredient_lines'] ?? []),
];
}

View file

@ -124,6 +124,18 @@ class StockEntry extends Model
return $this->status === 'pending';
}
/**
* AP-27: Gruppierungsschlüssel für die Chargen-Zusammenführung.
* Einträge mit Chargennummer werden je Charge zusammengefasst,
* Einträge ohne Chargennummer bleiben einzeln.
*/
public function batchGroupKey(): string
{
$batch = trim((string) $this->batch_number);
return $batch !== '' ? 'b:'.$batch : 'e:'.$this->id;
}
public function isReceived(): bool
{
return $this->status === 'received';

View file

@ -310,6 +310,34 @@ class InventoryService
->values();
}
/**
* AP-27: Verfügbare Chargen eines Rohstoffs je Chargennummer zusammengeführt.
* Jeder Einkauf bleibt ein eigener stock_entry (in `entries` enthalten),
* Restbestand/Anzeige werden je batch_number summiert. FEFO-Reihenfolge bleibt erhalten.
*
* @return \Illuminate\Support\Collection<int, array{key: string, batch_number: ?string, entries: Collection<int, StockEntry>, remaining: float, best_before: ?Carbon, suppliers: \Illuminate\Support\Collection<int, string>, locations: \Illuminate\Support\Collection<int, string>}>
*/
public function chargeGroupsForIngredient(int $ingredientId): \Illuminate\Support\Collection
{
return $this->chargesWithRemainingForIngredient($ingredientId)
->groupBy(fn (StockEntry $entry) => $entry->batchGroupKey())
->map(function (Collection $entries, string $key) {
/** @var StockEntry $first */
$first = $entries->first();
return [
'key' => $key,
'batch_number' => $first->batch_number,
'entries' => $entries->values(),
'remaining' => round($entries->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity')), 2),
'best_before' => $entries->pluck('best_before')->filter()->min(),
'suppliers' => $entries->map(fn (StockEntry $e) => $e->supplier?->name)->filter()->unique()->values(),
'locations' => $entries->map(fn (StockEntry $e) => $e->location?->name)->filter()->unique()->values(),
];
})
->values();
}
/**
* Offene (bestellte, noch nicht eingegangene) Einkäufe eines Rohstoffs.
*

View file

@ -350,6 +350,104 @@ class ProductionService
return implode(' - ', $parts);
}
/**
* AP-27: Verfügbare Chargen je batch_number zusammengeführt (FEFO-Reihenfolge bleibt erhalten).
* Grundlage für das Charge-Dropdown der Produktion: eine Option je Charge mit Summen-Rest.
*
* @param Collection<int, StockEntry> $entries FEFO-sortierte Einträge mit remaining_quantity
* @return array<int, array{key: string, label: string, batch_number: ?string, best_before: ?string, remaining: float, purchases: int}>
*/
public function batchOptionsForEntries(Collection $entries): array
{
return $entries
->groupBy(fn (StockEntry $entry) => $entry->batchGroupKey())
->map(function ($group, string $key) {
/** @var StockEntry $first */
$first = $group->first();
$suppliers = $group->map(fn (StockEntry $e) => $e->supplier?->name)->filter()->unique();
$bestBefore = $group->pluck('best_before')->filter()->min();
$labelParts = [];
$labelParts[] = $suppliers->isNotEmpty() ? $suppliers->implode(', ') : __('Lieferant unbekannt');
$labelParts[] = $first->batch_number ?: ('#'.$first->id);
if ($bestBefore) {
$labelParts[] = $bestBefore->format('d.m.Y');
}
return [
'key' => $key,
'label' => implode(' - ', $labelParts),
'batch_number' => $first->batch_number,
'best_before' => $bestBefore?->format('Y-m-d'),
'remaining' => round($group->sum(fn (StockEntry $e) => (float) $e->getAttribute('remaining_quantity')), 2),
'purchases' => $group->count(),
];
})
->values()
->all();
}
/**
* AP-27: Löst Chargen-Zeilen (batch_key) in Einzel-Wareneingänge auf.
* Der Verbrauch wird FEFO auf die Einträge der Charge verteilt; ein etwaiger
* Überhang wird dem letzten Eintrag zugerechnet (kein Restbestands-Hardstop,
* konsistent zum bisherigen Verhalten je Einzel-Eintrag).
* Zeilen im alten Format (stock_entry_id) werden unverändert durchgereicht.
*
* @param array<int, array{ingredient_id: int, stock_entry_id?: int, batch_key?: string, quantity_used?: mixed}> $ingredientLines
* @return array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used: float|int|string}>
*/
public function expandBatchLines(array $ingredientLines, int $locationId, ?int $excludeProductionId = null): array
{
$expanded = [];
$entriesByIngredient = [];
foreach ($ingredientLines as $line) {
$batchKey = trim((string) ($line['batch_key'] ?? ''));
if ($batchKey === '') {
$expanded[] = $line;
continue;
}
$ingredientId = (int) $line['ingredient_id'];
$entriesByIngredient[$ingredientId] ??= $this->availableStockEntriesForIngredient($ingredientId, $locationId, $excludeProductionId);
$batchEntries = $entriesByIngredient[$ingredientId]
->filter(fn (StockEntry $entry) => $entry->batchGroupKey() === $batchKey)
->values();
if ($batchEntries->isEmpty()) {
throw ValidationException::withMessages([
'ingredient_lines' => __('Charge :key ist am gewählten Lagerort nicht (mehr) verfügbar.', [
'key' => substr($batchKey, 2),
]),
]);
}
$needed = round($this->parseQuantity($line['quantity_used'] ?? null), 2);
$lastIndex = $batchEntries->count() - 1;
foreach ($batchEntries as $index => $entry) {
$capacity = (float) $entry->getAttribute('remaining_quantity');
$take = $index === $lastIndex ? $needed : round(min($needed, $capacity), 2);
if ($take > 0) {
$expanded[] = [
'ingredient_id' => $ingredientId,
'stock_entry_id' => (int) $entry->id,
'quantity_used' => $take,
];
}
$needed = round($needed - $take, 2);
if ($needed <= 0) {
break;
}
}
}
return $expanded;
}
/**
* @return array<string, mixed>
*/
@ -367,13 +465,14 @@ class ProductionService
$factor = (float) ($ing->pivot->factor ?? 1.1);
$hasGram = $gram !== null && $gram !== '';
$req = $hasGram ? (float) $gram * $factor * $qty : null;
$available = $this->availableStockEntriesForIngredient((int) $ing->id, $locationId, $excludeProductionId);
$ingredients[] = [
'id' => $ing->id,
'name' => $ing->name,
'gram' => $hasGram ? (float) $gram : null,
'factor' => $factor,
'required_grams_total' => $req,
'stock_entries' => $this->availableStockEntriesForIngredient((int) $ing->id, $locationId, $excludeProductionId)
'stock_entries' => $available
->map(function (StockEntry $se) {
return [
'id' => $se->id,
@ -383,6 +482,8 @@ class ProductionService
'remaining' => (float) $se->getAttribute('remaining_quantity'),
];
})->values()->all(),
// AP-27: Dropdown-Optionen je Charge zusammengeführt (Summen-Rest, FEFO-Reihenfolge).
'batches' => $this->batchOptionsForEntries($available),
];
}

View file

@ -104,8 +104,8 @@ Alle Punkte dieser älteren Runde sind in V4.0 eingeflossen und umgesetzt (URL-F
| 5 | ✅ AP-23 | Rezeptur-Phasen (Hersteller-Rezeptur) — **erledigt 06.07.2026** | – | 3–5 Tage |
| 6 | ✅ AP-24 | Chargen-Nr.-Präfix — **erledigt 06.07.2026** | – | 1 Tag |
| 7 | ✅ OPT-3 + OPT-4 | Pagination + show()-Refactoring — **erledigt 06.07.2026** | – | 1,5–2,5 Tage |
| 8 | ➡️ AP-27 | **NÄCHSTER SCHRITT:** Wareneingang: Chargen-Merge | OPT-4 | 1–2 Tage |
| 9 | AP-28 | Neue Produktions-Maske + Produktionsergebnis | AP-21, AP-23, AP-24, AP-27 | 5–8 Tage |
| 8 | ✅ AP-27 | Wareneingang: Chargen-Merge — **erledigt 06.07.2026** | OPT-4 | 1–2 Tage |
| 9 | ➡️ AP-28 | **NÄCHSTER SCHRITT:** Neue Produktions-Maske + Produktionsergebnis | AP-21, AP-23, AP-24, AP-27 | 5–8 Tage |
| 10 | AP-13 | Shop-Bestandsabzug bei Versand (inkl. Sets; Konzept aus V4.0) | AP-28 (saubere Buchungslogik) | 3–5 Tage |
| 11 | AP-14 | Audit-Trail | – | 2–3 Tage |
| 12 | AP-16 | 2FA für Admins (Google Authenticator) | – | 2–3 Tage |
@ -154,9 +154,9 @@ Die fachlichen Detailspezifikationen zu **AP-20, AP-21, AP-23, AP-24, AP-27, AP-
| 06.07.2026 | **AP-23 (Rezeptur-Phasen)** | **DB:** Migration `2026_07_06_134518_create_recipe_phases_table` — Tabelle `recipe_phases` (`product_id` FK cascade als `unsignedInteger` — `products.id` ist historisch int, kein `foreignId`!; `recipe_type` default `manufacturer`, `name`, `note` nullable, `pos`) + `product_ingredients.recipe_phase_id` (nullable FK, `nullOnDelete`); auf DB ausgeführt. **Abweichung zur V5.0-Spec:** kein Unique auf (`product_id`,`recipe_type`,`name`) — frei editierbare Namen sollen beim Speichern nicht crashen; Duplikate sind fachlich unkritisch. **Models:** `RecipePhase` (+ `Product::recipePhases()`, `ProductIngredient::phase()`, Pivot um `recipe_phase_id` erweitert). **Formular** (`admin/product/form.blade.php` + JS in `edit.blade.php`): Hersteller-Rezeptur-Card in **Phasen-Blöcke** umgebaut — je Block editierbarer Name (Platzhalter Phase A/B … aktualisiert sich), Rohstoff-Tabelle (Anteil %/Faktor/Effektiv wie bisher), Phasen-**Notiz**, „+ Rohstoffe hinzufügen" je Phase (gemeinsames Modal mit Ziel-Phase), „+ Neue Phase" (JS-Template), Phase entfernen (mit Warnung bei Zeilen); **Drag&Drop**: Phasen untereinander + Zeilen innerhalb und **zwischen** Phasen (Sortable `group`); 100-%-Summe global über alle Phasen (Anzeige grün/rot wie bisher); `mfg_row_phase[]`-Sync bei Drag/Add/Remove/Submit. **Repository:** `updateManufacturerIngredients()` persistiert Phasen (`persistRecipePhases()`: leerer Name ⇒ Auto „Phase A/B…", einzelner unbenannter Block ohne Notiz ⇒ **phasenlos wie bisher**, `recipe_phase_id=NULL`); `copy()` kopiert Phasen inkl. Zeilen-Zuordnung (ID-Mapping). Produktion (`requiredGramsByIngredient`) rechnet unverändert über alle Zeilen. **Blade-Falle behoben:** inline `@php(…)` (AP-25-Verfügbarkeits-Card) + neuer `@php…@endphp`-Block im selben Template ließen den Compiler beide falsch paaren (Raw-Block von Z. 106 bis 771) — inline-Variante auf Block-Form umgestellt. Hinweise-Doku fortgeschrieben. | Neuer `tests/Feature/RecipePhaseTest.php` (6 grün, 25 Assertions): Phasen+Zeilen+Notiz+Reihenfolge, Auto-Name, implizit phasenlos, Re-Save ersetzt vollständig, Kopie inkl. Zuordnung, Produktion phasenunabhängig. Volle Suite: **178 grün, 649 Assertions** |
| 06.07.2026 | **AP-24 (Chargen-Nr.-Präfix)** | Migration `2026_07_06_140324_add_batch_prefix_to_products_table` (`products.batch_prefix` VARCHAR(20) nullable); auf DB ausgeführt. `Product` fillable + Helper `suggestedBatchNumber(?Carbon $date)` = Präfix + Datum `ttmmjj` (Beispiel `TP1` + 15.06.26 ⇒ `TP1150626`; ohne Präfix ⇒ null; Default-Datum heute). Eingabefeld „Chargen-Nr.-Präfix" in der Warenwirtschaft-Card des Produktformulars (Hilfetext mit Beispiel); `ProductRepository::update()` trimmt (leer ⇒ null). Vorbelegung des editierbaren Chargen-Nr.-Felds folgt in AP-28 (Produktionsergebnis). Chargen-Format war bereits in der Hinweise-Doku dokumentiert (AP-21-Lieferung). | Neuer `tests/Feature/BatchPrefixTest.php` (6 grün): Speichern/Trim, leer ⇒ null, `ttmmjj`-Zusammensetzung, Default heute, ohne Präfix null, Formular-Render. Volle Suite: **184 grün, 657 Assertions** |
| 06.07.2026 | **OPT-3 + OPT-4** | **OPT-3 (Index-Limits):** `StockEntryRepository::listForIndex(?int $receivedLimit = 500)` — offene Bestellungen immer vollständig, Eingegangene auf 500 gedeckelt (neueste zuerst, stabile Sekundärsortierung nach `id`); ungenutzte Eager-Loads `orderedByUser`/`receivedByUser` entfernt (nur `show` braucht sie). `ProductionRepository::listForIndex(?int $limit = 500)` analog. Beide Controller unterstützen `?all=1`; die Views zeigen bei erreichtem Limit einen Hinweis mit „Alle anzeigen"-Link. **OPT-4 (show() entschlackt):** Chargen-Restmengen (`chargesWithRemainingForIngredient()`, FEFO) und offene Bestellungen (`openOrdersForIngredient()`) in den `InventoryService` verschoben (Konstruktor-Injection `ProductionService`, kein Zyklus); `lastNetPricePerSupplier()` in `StockEntryRepository` als **eine** Window-Function-Query (`ROW_NUMBER() OVER (PARTITION BY supplier_id …)`, läuft auf MySQL 8 + SQLite) statt In-Memory-Schleife über alle Einkäufe. `RawMaterialStockController::show()` ist nur noch Orchestrierung. | `RawMaterialStockTest` +2 (letzter Netto-Preis je Lieferant aus neuestem Einkauf; Liste deckelt Eingegangene, Offene vollständig, `null` = alle). Volle Suite: **186 grün, 663 Assertions** |
| _offen_ | AP-27 | Chargen-Merge Wareneingang | – |
| 06.07.2026 | **AP-27 (Wareneingang: gleiche Charge zusammenführen)** | **Grundprinzip wie in §4 V5.0 bestätigt:** jeder Einkauf bleibt ein eigener `stock_entry` (Preis/Datum/Audit) — zusammengeführt werden nur Anzeige und Restbestand je `batch_number`. **Gruppierungsschlüssel:** `StockEntry::batchGroupKey()` (`b:<batch_number>`, Einträge ohne Chargennummer bleiben einzeln als `e:<id>`). **Existenz-Check:** neuer JSON-Endpoint `GET api/stock-entries/batch-check` (`StockEntryController::batchCheck`, Route `admin.inventory.api.stock-entries.batch-check`; Parameter `ingredient_id`+`batch_number`+optional `exclude`) liefert `exists`/`purchases`/`remaining`/`best_before`/`location`/`quality_id`; die Buchen-Maske (Stufe 2, `stock-entries/show`) prüft beim Tippen (debounced) und zeigt „Charge bereits im Haus — Menge wird dieser Charge zugerechnet" inkl. Rest/Lagerort/MHD; **MHD + Qualität werden vorbelegt** (nur wenn leer, überschreibbar). **Rohstoffbestand-Detail:** `InventoryService::chargeGroupsForIngredient()` (baut auf `chargesWithRemainingForIngredient()` aus OPT-4, FEFO bleibt) — Chargen-Tabelle zeigt je Charge eine Zeile mit Summen-Rest, frühestem MHD und Badge „x Einkäufe"; Einzel-Einkäufe als eingerückte Unterzeilen („Einkauf vom …"). **Produktion:** Recipe-Payload je Rohstoff um `batches` erweitert (`ProductionService::batchOptionsForEntries()`, eine Option je Charge mit Summen-Rest; `stock_entries` bleibt für Audit/Kompatibilität); Charge-Dropdown im Formular listet Chargen statt Einzel-Einkäufe (`ingredient_lines[i][batch_key]`); `StoreProductionRequest` akzeptiert `batch_key` **oder** `stock_entry_id` (`required_without`); `ProductionService::expandBatchLines()` löst Chargen-Zeilen **FEFO** auf die Einzel-Einkäufe auf (Überhang auf letzten Eintrag — kein neuer Hardstop, konsistent zum Bestandsverhalten), Controller ruft sie vor `store`/`updateProduction` auf (bei Update mit `exclude_production`); **Service-API unverändert** (`store()`/`updateProduction()` weiter je `stock_entry_id` — bestehende Tests/Aufrufer unberührt). Edit/Copy fassen bestehende Zeilen je Charge zusammen (Summe je `batch_key`). Hinweise-Doku fortgeschrieben. | Neuer `tests/Feature/StockEntryChargeMergeTest.php` (7 grün, 35 Assertions): Gruppierung + separate Einkäufe, Endpoint (Treffer/Fehlschlag), Detailansicht summiert + Unterzeilen, Payload-`batches`, FEFO-Split, Passthrough + unbekannte Charge, HTTP-Produktion bucht auf 2 Einkäufe. Volle Suite: **193 grün, 698 Assertions** |
| _offen_ | AP-28 | Neue Produktions-Maske + Produktionsergebnis | – |
| _offen_ | AP-13 | Shop-Bestandsabzug | – |
| _offen_ | AP-14/15/16/17 | Audit-Trail, Blockrechte, 2FA, WaWi-Einstellungen | – |
> **➡️ HIER GEHT ES WEITER: AP-27 (Wareneingang: gleiche Charge zusammenführen)** gemäß V5.0-Spezifikation (§6 der V5.0-Datei): Existenz-Check per JSON-Endpoint beim Erfassen (`batch_number` + `ingredient_id` ⇒ Hinweis + Vorbelegung MHD/Lagerort), Chargen-Anzeige/Restbestand je `batch_number` summiert (Detailansicht nutzt jetzt `InventoryService::chargesWithRemainingForIngredient()` aus OPT-4 — dort gruppieren), Charge-Dropdown der Produktion nach `batch_number` gruppiert mit Summen-Rest. Jeder Einkauf bleibt eigener `stock_entry`. Danach AP-28 (neue Produktions-Maske + Produktionsergebnis; nutzt `suggestedBatchNumber()` aus AP-24, Phasen aus AP-23, Lagerort/Alternativname aus AP-21).
> **➡️ HIER GEHT ES WEITER: AP-28 (neue Produktions-Maske + Produktionsergebnis)** gemäß V5.0-Spezifikation (§4 der V5.0-Datei) + Kundenentscheid §4 Punkt 4 (Output-Produkte dürfen andere Produkte sein): Tabelle `production_outputs` (production_id, product_id, quantity, batch_number, pos — **FKs als `unsignedInteger`, products.id ist historisch int!**), `ProductStockService::recordProductionStock` bucht aus Outputs (idempotenter Diff je Produkt + Backfill-Migration für Bestandsproduktionen), Planungs-Stückzahl wird informativ, Chargen-Erfassung nach Rezeptur-**Phasen** gruppiert (AP-23), Chargen-Nr. je Output vorbelegt via `Product::suggestedBatchNumber(produced_at)` (AP-24, editierbar), INCI-Tabelle mit Alternativname + Lagerort/Regal (AP-21), Charge-Dropdown nutzt die AP-27-Batches.

View file

@ -90,6 +90,16 @@
Eigenprodukt ohne Rezeptur markiert.
- **Chargen-Auswahl in der Produktion** zeigt nur Chargen mit Restbestand,
in der Reihenfolge des frühesten Mindesthaltbarkeitsdatums (FEFO).
- **Gleiche Charge wird zusammengeführt (Wareneingang):** Kommt eine bereits
vorhandene Charge (gleiche Chargennummer, gleicher Rohstoff) erneut, weist
die Buchungsmaske darauf hin („Charge bereits im Haus") und belegt MHD und
Qualität aus dem letzten Eingang vor (überschreibbar). Der **Einkaufsvorgang
bleibt trotzdem separat erfasst** (eigener Eintrag mit Preis/Datum) –
zusammengeführt werden nur Anzeige und Restbestand: Rohstoffbestand-Detail
und Chargen-Auswahl der Produktion zeigen die Charge **einmal mit
Summen-Rest** (Einzel-Einkäufe als Unterzeilen bzw. Vermerk „x Einkäufe").
Der Verbrauch einer Produktion wird automatisch **FEFO auf die einzelnen
Einkäufe der Charge verteilt**.
- **Umsatzsteuersätze** werden beim Einkauf als Snapshot gespeichert. Spätere
Änderungen am Steuersatz verändern bereits erfasste Einkäufe nicht.
- **Rohstoffbestand – Verbrauch/Tag** wird aus der Produktionshistorie der

View file

@ -30,16 +30,21 @@
return Number(value).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function buildStockSelect(idx, stockEntries, preselectedEntryId) {
var select = $('<select class="form-control form-control-sm" name="ingredient_lines[' + idx + '][stock_entry_id]" required></select>');
// AP-27: Dropdown je Charge (batch_number) zusammengeführt — Restbestand ist die Summe
// über alle Einkäufe der Charge; die FEFO-Verteilung übernimmt der Server.
function buildStockSelect(idx, batches, preselectedBatchKey) {
var select = $('<select class="form-control form-control-sm" name="ingredient_lines[' + idx + '][batch_key]" required></select>');
select.append('<option value="">{{ __('Charge wählen') }}</option>');
(stockEntries || []).forEach(function (se) {
var label = se.label;
if (se.remaining != null) {
label += ' — {{ __('Rest') }} ' + fmt(se.remaining) + ' g';
(batches || []).forEach(function (batch) {
var label = batch.label;
if (batch.remaining != null) {
label += ' — {{ __('Rest') }} ' + fmt(batch.remaining) + ' g';
}
var opt = $('<option></option>').attr('value', se.id).text(label);
if (preselectedEntryId && String(se.id) === String(preselectedEntryId)) {
if (batch.purchases > 1) {
label += ' (' + batch.purchases + ' {{ __('Einkäufe') }})';
}
var opt = $('<option></option>').attr('value', batch.key).text(label);
if (preselectedBatchKey && String(batch.key) === String(preselectedBatchKey)) {
opt.prop('selected', true);
}
select.append(opt);
@ -47,13 +52,13 @@
return select;
}
function addIngredientRow($wrap, ing, preselectedEntryId, preselectedQty) {
function addIngredientRow($wrap, ing, preselectedBatchKey, preselectedQty) {
var idx = lineIndex++;
var $row = $('<div class="form-row align-items-center mb-1 recipe-row"></div>');
var $colSelect = $('<div class="col-12 col-sm-7 mb-1"></div>');
$colSelect.append($('<input type="hidden" name="ingredient_lines[' + idx + '][ingredient_id]">').val(ing.id));
$colSelect.append(buildStockSelect(idx, ing.stock_entries, preselectedEntryId));
$colSelect.append(buildStockSelect(idx, ing.batches, preselectedBatchKey));
$row.append($colSelect);
var $colQty = $('<div class="col-12 col-sm-5 mb-1"></div>');
@ -137,7 +142,7 @@
var existing = existingLines[String(ing.id)] || [];
if (existing.length > 0) {
existing.forEach(function (ex) {
addIngredientRow($wrap, ing, ex.stock_entry_id, ex.quantity_used);
addIngredientRow($wrap, ing, ex.batch_key, ex.quantity_used);
});
} else {
addIngredientRow($wrap, ing);

View file

@ -18,9 +18,13 @@
@section('scripts')
@php
// AP-27: Zeilen je Charge (batch_key) zusammenfassen — mehrere Einkäufe derselben Charge = eine Formularzeile.
$existingLines = $model
? $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) {
return $lines->map(fn ($l) => ['stock_entry_id' => $l->stock_entry_id, 'quantity_used' => (float) $l->quantity_used])->values();
return $lines
->groupBy(fn ($l) => $l->stockEntry?->batchGroupKey() ?? 'e:'.$l->stock_entry_id)
->map(fn ($group, $key) => ['batch_key' => $key, 'quantity_used' => (float) $group->sum('quantity_used')])
->values();
})
: [];
@endphp

View file

@ -19,8 +19,12 @@
@section('scripts')
@php
// AP-27: Zeilen je Charge (batch_key) zusammenfassen — mehrere Einkäufe derselben Charge = eine Formularzeile.
$existingLines = $model->productionIngredients->groupBy('ingredient_id')->map(function ($lines) {
return $lines->map(fn ($l) => ['stock_entry_id' => $l->stock_entry_id, 'quantity_used' => (float) $l->quantity_used])->values();
return $lines
->groupBy(fn ($l) => $l->stockEntry?->batchGroupKey() ?? 'e:'.$l->stock_entry_id)
->map(fn ($group, $key) => ['batch_key' => $key, 'quantity_used' => (float) $group->sum('quantity_used')])
->values();
});
@endphp
@include('admin.inventory.productions._scripts', ['existingLines' => $existingLines, 'excludeProductionId' => $model->id])

View file

@ -241,21 +241,38 @@
</tr>
</thead>
<tbody>
@forelse($charges as $charge)
{{-- AP-27: Anzeige je Charge (batch_number) summiert; Einzel-Einkäufe als Unterzeilen --}}
@forelse($chargeGroups as $group)
<tr>
<td>{{ $charge->batch_number ?: '#' . $charge->id }}</td>
<td>{{ $charge->supplier?->name ?? '—' }}</td>
<td>{{ $charge->location?->name ?? '—' }}</td>
<td>{{ $charge->best_before?->format('d.m.Y') ?? '—' }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($charge->getAttribute('remaining_quantity'), 0) }} g</td>
<td>
{{ $group['batch_number'] ?: '#' . $group['entries']->first()->id }}
@if($group['entries']->count() > 1)
<span class="badge badge-light border ml-1">{{ $group['entries']->count() }} {{ __('Einkäufe') }}</span>
@endif
</td>
<td>{{ $group['suppliers']->isNotEmpty() ? $group['suppliers']->implode(', ') : '—' }}</td>
<td>{{ $group['locations']->isNotEmpty() ? $group['locations']->implode(', ') : '—' }}</td>
<td>{{ $group['best_before']?->format('d.m.Y') ?? '—' }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($group['remaining'], 0) }} g</td>
</tr>
@if($group['entries']->count() > 1)
@foreach($group['entries'] as $charge)
<tr class="text-muted small">
<td class="pl-4">↳ {{ __('Einkauf vom') }} {{ $charge->received_at?->format('d.m.Y') ?? '—' }}</td>
<td>{{ $charge->supplier?->name ?? '—' }}</td>
<td>{{ $charge->location?->name ?? '—' }}</td>
<td>{{ $charge->best_before?->format('d.m.Y') ?? '—' }}</td>
<td class="text-right">{{ \App\Services\Util::formatNumber($charge->getAttribute('remaining_quantity'), 0) }} g</td>
</tr>
@endforeach
@endif
@empty
<tr>
<td colspan="5"><div class="wawi-empty">{{ __('Kein Restbestand vorhanden.') }}</div></td>
</tr>
@endforelse
</tbody>
@if($charges->isNotEmpty() && count($remainingByLocation) > 0)
@if($chargeGroups->isNotEmpty() && count($remainingByLocation) > 0)
<tfoot>
@foreach($locations as $location)
@if(isset($remainingByLocation[$location->id]) && $remainingByLocation[$location->id] > 0)

View file

@ -207,6 +207,8 @@
@error('batch_number')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
{{-- AP-27: Hinweis, wenn die Charge für diesen Rohstoff bereits im Haus ist --}}
<div id="batch-merge-hint" class="alert alert-info py-2 px-3 mt-2 mb-0 small" style="display:none;"></div>
</div>
<div class="form-group">
@ -240,3 +242,79 @@
@endif
</div>
@endsection
@if($model->isPending() && $model->entry_type === 'ingredient')
@section('scripts')
<script>
(function ($) {
// AP-27: Beim Eintippen der Chargennummer prüfen, ob die Charge schon im Haus ist.
var checkUrl = @json(route('admin.inventory.api.stock-entries.batch-check'));
var ingredientId = @json($model->ingredient_id);
var excludeId = @json($model->id);
var $batch = $('#batch_number');
var $hint = $('#batch-merge-hint');
var timer = null;
var lastChecked = null;
function fmt(value) {
return Number(value).toLocaleString('de-DE', {maximumFractionDigits: 0});
}
function runCheck() {
var batch = ($batch.val() || '').trim();
if (!batch || !ingredientId) {
$hint.hide();
lastChecked = null;
return;
}
if (batch === lastChecked) {
return;
}
lastChecked = batch;
$.getJSON(checkUrl, {ingredient_id: ingredientId, batch_number: batch, exclude: excludeId})
.done(function (data) {
if (!data.exists) {
$hint.hide();
return;
}
var parts = ['<strong>{{ __('Charge bereits im Haus') }}</strong> — {{ __('die Menge wird dieser Charge zugerechnet, der Einkauf bleibt separat erfasst.') }}'];
var facts = [];
facts.push('{{ __('Restbestand') }}: ' + fmt(data.remaining) + ' g');
if (data.purchases > 1) {
facts.push(data.purchases + ' {{ __('Einkäufe') }}');
}
if (data.location) {
facts.push('{{ __('Lagerort') }}: ' + $('<span>').text(data.location).html());
}
if (data.best_before) {
facts.push('{{ __('MHD') }}: ' + data.best_before);
}
parts.push('<br>' + facts.join(' · '));
// Vorbelegung (überschreibbar): MHD + Qualität aus dem letzten Eingang der Charge.
if (data.best_before && !$('#best_before').val()) {
$('#best_before').val(data.best_before);
parts.push('<br>{{ __('Mindesthaltbarkeit wurde vorbelegt.') }}');
}
var $quality = $('#quality_id');
if (data.quality_id && $quality.length && !$quality.val()) {
$quality.val(String(data.quality_id));
}
$hint.html(parts.join('')).show();
})
.fail(function () {
$hint.hide();
});
}
$batch.on('input', function () {
clearTimeout(timer);
timer = setTimeout(runCheck, 400);
});
$batch.on('blur', runCheck);
})(jQuery);
</script>
@endsection
@endif

View file

@ -313,6 +313,7 @@ Route::domain(config('app.domain'))->group(function () {
Route::get('raw-material-stock', [InventoryRawMaterialStockController::class, 'index'])->name('raw-material-stock.index');
Route::get('raw-material-stock/{ingredient}', [InventoryRawMaterialStockController::class, 'show'])->name('raw-material-stock.show');
Route::get('api/ingredients/search', [InventoryStockEntryController::class, 'searchIngredients'])->name('api.ingredients.search');
Route::get('api/stock-entries/batch-check', [InventoryStockEntryController::class, 'batchCheck'])->name('api.stock-entries.batch-check');
Route::get('api/packaging-items/search', [InventoryStockEntryController::class, 'searchPackagingItems'])->name('api.packaging-items.search');
Route::get('api/products/{product}/recipe', [InventoryProductionController::class, 'recipeJson'])->name('api.products.recipe');
Route::get('api/disposals/ingredient-charges/{ingredient}', [InventoryStockDisposalController::class, 'ingredientCharges'])->name('api.disposals.ingredient-charges');

View file

@ -0,0 +1,312 @@
<?php
use App\Models\Country;
use App\Models\Ingredient;
use App\Models\Location;
use App\Models\Product;
use App\Models\ProductIngredient;
use App\Models\Production;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\InventoryService;
use App\Services\ProductionService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap27MakeUser(int $adminLevel = 7): User
{
Country::query()->firstOrCreate(
['code' => 'DE'],
[
'phone' => '00',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Germany',
'fr' => 'Germany',
'it' => 'Germany',
'ru' => 'Germany',
'active' => true,
]
);
$user = User::query()->create([
'email' => uniqid('ap27_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function ap27MakeIngredient(string $name): Ingredient
{
return Ingredient::query()->create([
'name' => $name,
'trans_name' => '',
'inci' => $name,
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
}
/**
* @param array<string, mixed> $overrides
*/
function ap27MakeStock(Ingredient $ing, Location $location, Supplier $supplier, User $user, array $overrides = []): StockEntry
{
return StockEntry::query()->create(array_merge([
'entry_type' => 'ingredient',
'ingredient_id' => $ing->id,
'supplier_id' => $supplier->id,
'location_id' => $location->id,
'unit' => 'gram',
'ordered_by' => $user->id,
'ordered_at' => '2026-01-01',
'ordered_quantity' => 5000,
'price_per_kg' => 15,
'status' => 'received',
'received_by' => $user->id,
'received_at' => '2026-01-15',
'received_quantity' => 5000,
'batch_number' => 'AP27-CHARGE',
'best_before' => '2030-12-31',
], $overrides));
}
function ap27Consume(Ingredient $ing, StockEntry $stock, Location $location, User $user, float $grams): void
{
$product = Product::query()->create([
'name' => 'AP27-Verbrauch-'.uniqid(),
'title' => 'AP27-Verbrauch',
'active' => true,
]);
$production = Production::query()->create([
'product_id' => $product->id,
'location_id' => $location->id,
'produced_by' => $user->id,
'produced_at' => '2026-06-01',
'quantity' => 1,
'mhd_warning' => false,
]);
$production->productionIngredients()->create([
'ingredient_id' => $ing->id,
'stock_entry_id' => $stock->id,
'quantity_used' => $grams,
]);
}
test('chargeGroups fuehrt gleiche charge zusammen, einkaeufe bleiben separat', function () {
$user = ap27MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap27MakeIngredient('AP27-Gruppe');
$first = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]);
ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 3000, 'received_at' => '2026-02-15']);
ap27MakeStock($ing, $location, $supplier, $user, ['batch_number' => 'AP27-ANDERE', 'received_quantity' => 1000]);
ap27Consume($ing, $first, $location, $user, 1000);
$groups = app(InventoryService::class)->chargeGroupsForIngredient($ing->id);
expect($groups)->toHaveCount(2);
$merged = $groups->firstWhere('batch_number', 'AP27-CHARGE');
expect($merged['remaining'])->toBe(7000.0);
expect($merged['entries'])->toHaveCount(2);
$other = $groups->firstWhere('batch_number', 'AP27-ANDERE');
expect($other['remaining'])->toBe(1000.0);
expect($other['entries'])->toHaveCount(1);
// Beide Einkäufe existieren weiterhin als eigene stock_entries.
expect(StockEntry::query()->where('batch_number', 'AP27-CHARGE')->count())->toBe(2);
});
test('batch-check endpoint erkennt vorhandene charge und liefert vorbelegung', function () {
$user = ap27MakeUser();
$location = Location::factory()->create(['name' => 'AP27-Lager']);
$supplier = Supplier::factory()->create();
$ing = ap27MakeIngredient('AP27-Check');
$first = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]);
ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 3000, 'received_at' => '2026-02-15']);
ap27Consume($ing, $first, $location, $user, 1000);
$this->actingAs($user, 'user');
$response = $this->getJson(route('admin.inventory.api.stock-entries.batch-check', [
'ingredient_id' => $ing->id,
'batch_number' => 'AP27-CHARGE',
]));
$response->assertOk();
$response->assertJson([
'exists' => true,
'purchases' => 2,
'remaining' => 7000.0,
'best_before' => '31.12.2030',
'location' => 'AP27-Lager',
]);
$miss = $this->getJson(route('admin.inventory.api.stock-entries.batch-check', [
'ingredient_id' => $ing->id,
'batch_number' => 'GIBT-ES-NICHT',
]));
$miss->assertOk();
$miss->assertJson(['exists' => false]);
});
test('rohstoff-detailansicht zeigt charge summiert mit einkaufs-unterzeilen', function () {
$user = ap27MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap27MakeIngredient('AP27-Detail');
ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]);
ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 3000, 'received_at' => '2026-02-15']);
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.raw-material-stock.show', $ing));
$response->assertOk();
$response->assertSee('AP27-CHARGE');
$response->assertSee('2 Einkäufe');
$response->assertSee('8.000 g');
$response->assertSee('Einkauf vom 15.01.2026');
$response->assertSee('Einkauf vom 15.02.2026');
});
test('recipe-payload gruppiert chargen mit summen-rest', function () {
$user = ap27MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap27MakeIngredient('AP27-Payload');
ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 5000]);
ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 3000, 'received_at' => '2026-02-15']);
$product = Product::query()->create([
'name' => 'AP27-Payload-Produkt',
'title' => 'AP27-Payload-Produkt',
'active' => true,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 100,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
]);
$payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1);
$batches = $payload['ingredients'][0]['batches'];
expect($batches)->toHaveCount(1);
expect($batches[0]['key'])->toBe('b:AP27-CHARGE');
expect($batches[0]['remaining'])->toBe(8000.0);
expect($batches[0]['purchases'])->toBe(2);
// Einzel-Einträge bleiben im Payload erhalten (Audit/Abwärtskompatibilität).
expect($payload['ingredients'][0]['stock_entries'])->toHaveCount(2);
});
test('expandBatchLines verteilt verbrauch fefo auf die einkaeufe der charge', function () {
$user = ap27MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap27MakeIngredient('AP27-FEFO');
// Früheres MHD zuerst (FEFO): Eintrag A 100 g Rest, Eintrag B 200 g Rest.
$entryA = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 100, 'best_before' => '2027-01-01']);
$entryB = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 200, 'best_before' => '2029-01-01']);
$lines = app(ProductionService::class)->expandBatchLines([
['ingredient_id' => $ing->id, 'batch_key' => 'b:AP27-CHARGE', 'quantity_used' => '150'],
], $location->id);
expect($lines)->toHaveCount(2);
expect($lines[0]['stock_entry_id'])->toBe($entryA->id);
expect((float) $lines[0]['quantity_used'])->toBe(100.0);
expect($lines[1]['stock_entry_id'])->toBe($entryB->id);
expect((float) $lines[1]['quantity_used'])->toBe(50.0);
});
test('expandBatchLines reicht stock_entry_id-zeilen unveraendert durch und wirft bei unbekannter charge', function () {
$user = ap27MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap27MakeIngredient('AP27-Passthrough');
$stock = ap27MakeStock($ing, $location, $supplier, $user);
$passthrough = [['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '50']];
expect(app(ProductionService::class)->expandBatchLines($passthrough, $location->id))->toBe($passthrough);
expect(fn () => app(ProductionService::class)->expandBatchLines([
['ingredient_id' => $ing->id, 'batch_key' => 'b:UNBEKANNT', 'quantity_used' => '10'],
], $location->id))->toThrow(ValidationException::class);
});
test('produktion per formular bucht charge fefo auf beide einkaeufe', function () {
$user = ap27MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = ap27MakeIngredient('AP27-Buchung');
$entryA = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 100, 'best_before' => '2027-01-01']);
$entryB = ap27MakeStock($ing, $location, $supplier, $user, ['received_quantity' => 200, 'best_before' => '2029-01-01']);
$product = Product::query()->create([
'name' => 'AP27-Buchung-Produkt',
'title' => 'AP27-Buchung-Produkt',
'active' => true,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 150,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
]);
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.productions.store'), [
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-07-01',
'quantity' => 1,
'ingredient_lines' => [
['ingredient_id' => $ing->id, 'batch_key' => 'b:AP27-CHARGE', 'quantity_used' => '150'],
],
]);
$response->assertSessionHasNoErrors();
$response->assertRedirect();
$production = Production::query()->where('product_id', $product->id)->firstOrFail();
$lines = $production->productionIngredients()->orderBy('stock_entry_id')->get();
expect($lines)->toHaveCount(2);
expect($lines[0]->stock_entry_id)->toBe($entryA->id);
expect((float) $lines[0]->quantity_used)->toBe(100.0);
expect($lines[1]->stock_entry_id)->toBe($entryB->id);
expect((float) $lines[1]->quantity_used)->toBe(50.0);
});