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:
parent
e47d848d3d
commit
7a5b5e0f7b
16 changed files with 673 additions and 34 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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', ''));
|
||||
|
|
|
|||
|
|
@ -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'] ?? []),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
312
tests/Feature/StockEntryChargeMergeTest.php
Normal file
312
tests/Feature/StockEntryChargeMergeTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue