Warenwirtschaft: AP-28 Neue Produktions-Maske + Produktionsergebnis

- production_outputs (Migration inkl. idempotentem Backfill): eine Produktion
  kann mehrere Output-Produkte erzeugen (z. B. 40x50 ml + 50x5 ml)
- ProductStockService bucht je Output-Produkt (idempotenter Diff, append-only);
  die geplante Stückzahl bucht nichts mehr, entfernte Outputs werden rückgebucht
- ProductionService store/update mit outputs[] (leer = Fallback auf Planung
  inkl. suggestedBatchNumber — Backfill-Semantik, Alt-Aufrufer stabil)
- Maske: INCI-Liste (Alternativname/Qualität/Anteil/Gramm/Regal), Chargen-
  Erfassung nach Rezeptur-Phasen mit Notiz + Status-Ampel, Produktionsergebnis-
  Repeater mit Chargen-Nr.-Vorbelegung (Präfix + ttmmjj), Planungs-Hinweis
- Show: Produktionsergebnis-Karte; Index: Ergebnis-Spalte
- Hinweise-Doku + Plan V6.0 §6 fortgeschrieben (nächster Schritt: AP-13)

Tests: ProductionOutputTest (8 neu); Suite 201 grün, 731 Assertionen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-06 14:02:54 +00:00
parent 7a5b5e0f7b
commit 99a6c92e6c
17 changed files with 1009 additions and 74 deletions

View file

@ -40,7 +40,7 @@ class ProductionController extends Controller
$defaultProductId = (int) $request->query('product_id') ?: null;
return view('admin.inventory.productions.create', [
'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name']),
'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name', 'batch_prefix']),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
'defaultProductId' => $defaultProductId,
@ -68,7 +68,8 @@ class ProductionController extends Controller
'notes' => $payload['notes'],
],
$ingredientLines,
(int) $request->user()->id
(int) $request->user()->id,
$payload['outputs']
);
} catch (ValidationException $e) {
return redirect()->back()->withInput()->withErrors($e->errors());
@ -91,6 +92,7 @@ class ProductionController extends Controller
'productionIngredients.ingredient',
'productionIngredients.stockEntry',
'productionPackagings.packagingItem.packagingMaterial',
'outputs.product',
]);
return view('admin.inventory.productions.show', [
@ -105,6 +107,7 @@ class ProductionController extends Controller
'location',
'productionIngredients.ingredient',
'productionIngredients.stockEntry',
'outputs',
]);
$defaultLocationId = $production->location_id;
@ -114,7 +117,7 @@ class ProductionController extends Controller
'products' => Product::query()
->where(fn ($q) => $q->where('active', 1)->where('is_set', false))
->orWhere('id', $production->product_id)
->orderBy('name')->get(['id', 'name']),
->orderBy('name')->get(['id', 'name', 'batch_prefix']),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
]);
@ -142,7 +145,8 @@ class ProductionController extends Controller
'notes' => $payload['notes'],
],
$ingredientLines,
(int) $request->user()->id
(int) $request->user()->id,
$payload['outputs']
);
} catch (ValidationException $e) {
return redirect()->back()->withInput()->withErrors($e->errors());
@ -162,13 +166,14 @@ class ProductionController extends Controller
'product',
'productionIngredients.ingredient',
'productionIngredients.stockEntry',
'outputs',
]);
$defaultLocationId = $production->location_id;
return view('admin.inventory.productions.create', [
'model' => $production,
'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name']),
'products' => Product::query()->where('active', 1)->where('is_set', false)->orderBy('name')->get(['id', 'name', 'batch_prefix']),
'locations' => Location::query()->where('active', true)->orderBy('name')->get(),
'defaultLocationId' => $defaultLocationId,
]);

View file

@ -31,6 +31,11 @@ class StoreProductionRequest extends FormRequest
'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'],
// AP-28: Produktionsergebnis — bucht den Produktbestand; Planung oben ist unverbindlich.
'outputs' => ['required', 'array', 'min:1'],
'outputs.*.product_id' => ['required', 'integer', 'exists:products,id'],
'outputs.*.quantity' => ['required', 'integer', 'min:1'],
'outputs.*.batch_number' => ['nullable', 'string', 'max:100'],
];
}
@ -67,6 +72,13 @@ class StoreProductionRequest extends FormRequest
return $out;
}, $data['ingredient_lines'] ?? []),
'outputs' => array_map(function (array $output): array {
return [
'product_id' => (int) $output['product_id'],
'quantity' => (int) $output['quantity'],
'batch_number' => $output['batch_number'] ?? null,
];
}, $data['outputs'] ?? []),
];
}
}

View file

@ -69,4 +69,15 @@ class Production extends Model
{
return $this->hasMany(ProductionPackaging::class);
}
/**
* AP-28: Produktionsergebnis-Zeilen — buchen den Produktbestand;
* `quantity` an der Produktion ist nur noch die Planung.
*
* @return HasMany<ProductionOutput, $this>
*/
public function outputs(): HasMany
{
return $this->hasMany(ProductionOutput::class)->orderBy('pos')->orderBy('id');
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* AP-28: Eine Ergebniszeile einer Produktion (Produkt + produzierte Stückzahl
* + Chargen-Nr.). Der Produktbestand bucht aus diesen Zeilen, nicht aus der
* geplanten Stückzahl der Produktion.
*/
class ProductionOutput extends Model
{
protected $fillable = [
'production_id',
'product_id',
'quantity',
'batch_number',
'pos',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'quantity' => 'integer',
'pos' => 'integer',
];
}
/**
* @return BelongsTo<Production, $this>
*/
public function production(): BelongsTo
{
return $this->belongsTo(Production::class);
}
/**
* @return BelongsTo<Product, $this>
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View file

@ -15,7 +15,7 @@ class ProductionRepository
public function listForIndex(?int $limit = 500): Collection
{
return Production::query()
->with(['product', 'location'])
->with(['product', 'location', 'outputs.product'])
->orderByDesc('produced_at')
->orderByDesc('id')
->when($limit !== null, fn ($q) => $q->limit($limit))

View file

@ -73,37 +73,58 @@ class ProductStockService
}
/**
* Verbucht den Produktbestand einer Produktion idempotent: gleicht die bereits gebuchte
* Menge dieser Produktion mit der Soll-Stückzahl ab und bucht nur die Differenz.
* Verbucht den Produktbestand einer Produktion idempotent, seit AP-28 **je Output-Produkt**
* (Produktionsergebnis): Soll = Summe der production_outputs je Produkt; gebucht wird nur
* die Differenz zur bereits gebuchten Menge (append-only). Die geplante Stückzahl der
* Produktion bucht nichts mehr. Entfernte Output-Produkte werden per Gegenbuchung
* auf null zurückgeführt.
*/
public function recordProductionStock(Production $production, ?int $userId = null): void
{
$production->loadMissing('product');
$production->load('outputs');
$alreadyBooked = (int) ProductStockMovement::query()
$target = $production->outputs
->groupBy('product_id')
->map(fn ($rows) => (int) $rows->sum('quantity'));
$booked = ProductStockMovement::query()
->where('source', 'production')
->where('reference_type', $production->getMorphClass())
->where('reference_id', $production->getKey())
->selectRaw("COALESCE(SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END), 0) as net")
->value('net');
->selectRaw("product_id, COALESCE(SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END), 0) as net")
->groupBy('product_id')
->pluck('net', 'product_id');
$target = (int) $production->quantity;
$delta = $target - $alreadyBooked;
$productIds = $target->keys()
->merge($booked->keys())
->map(fn ($id) => (int) $id)
->unique()
->values();
if ($delta === 0) {
if ($productIds->isEmpty()) {
return;
}
$this->recordMovement(
$production->product,
$delta > 0 ? 'in' : 'out',
abs($delta),
$alreadyBooked === 0 ? __('Produktion') : __('Produktionskorrektur'),
'production',
null,
$userId ?? $production->produced_by,
$production,
);
$products = Product::query()->whereIn('id', $productIds)->get()->keyBy('id');
foreach ($productIds as $productId) {
$alreadyBooked = (int) ($booked[$productId] ?? 0);
$delta = (int) ($target[$productId] ?? 0) - $alreadyBooked;
if ($delta === 0 || ! isset($products[$productId])) {
continue;
}
$this->recordMovement(
$products[$productId],
$delta > 0 ? 'in' : 'out',
abs($delta),
$alreadyBooked === 0 ? __('Produktion') : __('Produktionskorrektur'),
'production',
null,
$userId ?? $production->produced_by,
$production,
);
}
}
/**

View file

@ -20,10 +20,11 @@ class ProductionService
/**
* @param array<string, mixed> $data
* @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used: float|int|string}> $ingredientLines
* @param array<int, array{product_id: int, quantity: int, batch_number?: ?string}> $outputs AP-28: Produktionsergebnis; leer ⇒ Fallback auf geplantes Produkt + Stückzahl
*/
public function store(array $data, array $ingredientLines, int $userId): Production
public function store(array $data, array $ingredientLines, int $userId, array $outputs = []): Production
{
return DB::transaction(function () use ($data, $ingredientLines, $userId) {
return DB::transaction(function () use ($data, $ingredientLines, $userId, $outputs) {
$product = Product::query()
->with(['manufacturer_ingredients', 'packagings'])
->findOrFail($data['product_id']);
@ -72,20 +73,23 @@ class ProductionService
$this->syncPackagingSnapshot($production, $product, $producedQty);
$this->persistOutputs($production, $product, $producedQty, $outputs);
$production->setRelation('product', $product);
$this->productStockService->recordProductionStock($production, $userId);
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']);
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings', 'outputs']);
});
}
/**
* @param array<string, mixed> $data
* @param array<int, array{ingredient_id: int, stock_entry_id: int, quantity_used: float|int|string}> $ingredientLines
* @param array<int, array{product_id: int, quantity: int, batch_number?: ?string}> $outputs AP-28: Produktionsergebnis; leer ⇒ Fallback auf geplantes Produkt + Stückzahl
*/
public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId): Production
public function updateProduction(Production $production, array $data, array $ingredientLines, int $userId, array $outputs = []): Production
{
return DB::transaction(function () use ($production, $data, $ingredientLines, $userId) {
return DB::transaction(function () use ($production, $data, $ingredientLines, $userId, $outputs) {
$product = Product::query()
->with(['manufacturer_ingredients', 'packagings'])
->findOrFail($data['product_id']);
@ -135,13 +139,67 @@ class ProductionService
$production->productionPackagings()->delete();
$this->syncPackagingSnapshot($production, $product, $producedQty);
$production->outputs()->delete();
$this->persistOutputs($production, $product, $producedQty, $outputs);
$production->setRelation('product', $product);
$this->productStockService->recordProductionStock($production, $userId);
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings']);
return $production->fresh(['product', 'location', 'productionIngredients', 'productionPackagings', 'outputs']);
});
}
/**
* AP-28: Produktionsergebnis-Zeilen persistieren. Ohne übergebene Outputs
* (Service-Aufrufer/Altverhalten) wird eine Zeile aus der Planung erzeugt —
* gleiche Semantik wie der Backfill der Bestandsdaten.
*
* @param array<int, array{product_id: int, quantity: int, batch_number?: ?string}> $outputs
*/
private function persistOutputs(Production $production, Product $plannedProduct, int $plannedQty, array $outputs): void
{
if ($outputs === []) {
$producedAt = $production->produced_at instanceof Carbon
? $production->produced_at
: Carbon::parse((string) $production->produced_at);
$outputs = [[
'product_id' => (int) $plannedProduct->id,
'quantity' => $plannedQty,
'batch_number' => $plannedProduct->suggestedBatchNumber($producedAt),
]];
}
$productIds = array_map(fn (array $output) => (int) $output['product_id'], $outputs);
$products = Product::query()->whereIn('id', $productIds)->get()->keyBy('id');
foreach (array_values($outputs) as $index => $output) {
$productId = (int) $output['product_id'];
$quantity = (int) $output['quantity'];
/** @var Product|null $outputProduct */
$outputProduct = $products[$productId] ?? null;
if ($outputProduct === null) {
throw ValidationException::withMessages(['outputs' => __('Unbekanntes Produkt im Produktionsergebnis.')]);
}
if ($outputProduct->is_set) {
throw ValidationException::withMessages(['outputs' => __('Sets können nicht produziert werden. Bitte ein Einzelprodukt im Produktionsergebnis wählen.')]);
}
if ($quantity < 1) {
throw ValidationException::withMessages(['outputs' => __('Die produzierte Stückzahl muss je Ergebniszeile mindestens 1 sein.')]);
}
$batchNumber = trim((string) ($output['batch_number'] ?? ''));
$production->outputs()->create([
'product_id' => $productId,
'quantity' => $quantity,
'batch_number' => $batchNumber !== '' ? $batchNumber : null,
'pos' => $index,
]);
}
}
/**
* Sets sind Bündel aus Einzelprodukten und werden nicht produziert.
*/
@ -453,7 +511,9 @@ class ProductionService
*/
public function buildRecipePayload(Product $product, int $locationId, int $productionQuantity, ?int $excludeProductionId = null): array
{
$product->loadMissing(['manufacturer_ingredients', 'packagings.packagingMaterial']);
$product->loadMissing(['manufacturer_ingredients', 'packagings.packagingMaterial', 'recipePhases']);
// AP-28: Zusatzspalten der INCI-Tabelle (Alternativname, Qualität, Regal) ohne N+1 laden.
$product->manufacturer_ingredients->load(['materialQuality', 'storageRoom', 'storageShelf', 'storageSlot']);
$qty = max(1, $productionQuantity);
$recipeRequired = ! (bool) $product->no_recipe_required;
@ -469,6 +529,10 @@ class ProductionService
$ingredients[] = [
'id' => $ing->id,
'name' => $ing->name,
'alt_name' => $ing->alt_name,
'quality' => $ing->materialQuality?->name,
'storage_label' => $ing->storageLabel(),
'phase_id' => $ing->pivot->recipe_phase_id !== null ? (int) $ing->pivot->recipe_phase_id : null,
'gram' => $hasGram ? (float) $gram : null,
'factor' => $factor,
'required_grams_total' => $req,
@ -506,11 +570,20 @@ class ProductionService
'name' => $product->name,
'shelf_life_type' => $product->shelf_life_type,
'shelf_life_months' => $product->shelf_life_months,
// AP-28: für die Chargen-Nr.-Vorbelegung im Produktionsergebnis.
'batch_prefix' => $product->batch_prefix,
],
'recipe_required' => $recipeRequired,
'has_recipe' => $hasRecipe,
'location_id' => $locationId,
'production_quantity' => $qty,
// AP-28: Phasen der Hersteller-Rezeptur (AP-23) für die gruppierte Chargen-Erfassung.
'phases' => $product->recipePhases
->map(fn ($phase) => [
'id' => (int) $phase->id,
'name' => $phase->name,
'note' => $phase->note,
])->values()->all(),
'ingredients' => $ingredients,
'packagings' => $packagings,
];

View file

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* AP-28: Produktionsergebnis — eine Produktion kann mehrere Output-Produkte
* erzeugen (z. B. 40×50 ml + 50×5 ml). Der Produktbestand bucht künftig aus
* diesen Zeilen; productions.quantity bleibt als geplante Stückzahl erhalten.
*/
public function up(): void
{
Schema::create('production_outputs', function (Blueprint $table) {
$table->id();
$table->foreignId('production_id')->constrained('productions')->cascadeOnDelete();
// products.id ist historisch int unsigned — kein foreignId()!
$table->unsignedInteger('product_id');
$table->foreign('product_id')->references('id')->on('products')->cascadeOnDelete();
$table->unsignedInteger('quantity');
$table->string('batch_number', 100)->nullable();
$table->unsignedInteger('pos')->default(0);
$table->timestamps();
$table->index(['production_id', 'pos']);
});
// Backfill (idempotent): Bestandsproduktionen erhalten eine Output-Zeile
// (geplantes Produkt + geplante Stückzahl), damit der bereits gebuchte
// Produktbestand konsistent bleibt.
DB::statement(<<<'SQL'
INSERT INTO production_outputs (production_id, product_id, quantity, batch_number, pos, created_at, updated_at)
SELECT p.id, p.product_id, p.quantity, NULL, 0, p.created_at, p.created_at
FROM productions p
WHERE NOT EXISTS (
SELECT 1 FROM production_outputs po WHERE po.production_id = p.id
)
SQL);
}
public function down(): void
{
Schema::dropIfExists('production_outputs');
}
};

View file

@ -105,8 +105,8 @@ Alle Punkte dieser älteren Runde sind in V4.0 eingeflossen und umgesetzt (URL-F
| 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 | 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 |
| 9 | ✅ AP-28 | Neue Produktions-Maske + Produktionsergebnis — **erledigt 06.07.2026** | AP-21, AP-23, AP-24, AP-27 | 5–8 Tage |
| 10 | ➡️ AP-13 | **NÄCHSTER SCHRITT:** 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 |
| 13 | AP-15 | Blockbasierte Rechte (sehen/bearbeiten je Block) | AP-16 sinnvoll davor | 4–6 Tage |
@ -155,8 +155,8 @@ Die fachlichen Detailspezifikationen zu **AP-20, AP-21, AP-23, AP-24, AP-27, AP-
| 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** |
| 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 | – |
| 06.07.2026 | **AP-28 (Neue Produktions-Maske + Produktionsergebnis)** | **DB:** Migration `2026_07_06_153907_create_production_outputs_table` — Tabelle `production_outputs` (`production_id` FK cascade als `foreignId` — productions.id ist bigint; `product_id` als **`unsignedInteger`+foreign** — products.id historisch int!; `quantity` UINT, `batch_number` VARCHAR(100) nullable, `pos`; Index (production_id, pos)) **inkl. idempotentem Backfill** (Bestandsproduktionen ⇒ je eine Output-Zeile aus geplantem Produkt + Stückzahl, `NOT EXISTS`-Guard); auf DB ausgeführt (3 Produktionen backgefüllt). **Buchung:** `ProductStockService::recordProductionStock()` bucht jetzt **je Output-Produkt** (Soll = Summe `production_outputs.quantity` je product_id, idempotenter Differenz-Abgleich, append-only; entfernte Output-Produkte werden per Gegenbuchung auf null zurückgeführt) — die geplante Stückzahl bucht **nichts** mehr. **Service:** `ProductionService::store()/updateProduction()` um `outputs[]` erweitert (4. Parameter, Default `[]` ⇒ **Fallback auf geplantes Produkt + Stückzahl** mit `suggestedBatchNumber(produced_at)` — hält Alt-Aufrufer/Tests stabil und entspricht der Backfill-Semantik); `persistOutputs()` validiert (Produkt existiert, kein Set, Menge ≥ 1), Update ersetzt Outputs vollständig. **HTTP:** `StoreProductionRequest` verlangt `outputs` (≥ 1 Zeile; product_id exists, quantity ≥ 1, batch_number nullable). **Payload:** `buildRecipePayload()` liefert zusätzlich `product.batch_prefix`, `phases[]` (AP-23) und je Rohstoff `alt_name`/`quality`/`storage_label`/`phase_id` (Relationen eager, kein N+1). **Maske** (`_form_fields` + `_scripts`): Planungs-Stückzahl umbenannt („Geplante Stückzahl (Planung)" + Hinweis „bucht keinen Bestand"), **INCI-Liste** (Name, Alternativname, Qualität, Anteil %, Gramm gesamt, Regal, Summenzeile), Chargen-Zuordnung **nach Phasen gruppiert** (Phasen-Block mit Name + Notiz; Rohstoffe ohne Phase in eigenem Block), **Status-Ampel** je Rohstoff (grün = Summe ≈ Soll ± 0,02 g), **Produktionsergebnis-Repeater** (Produkt-Select, Stückzahl, Chargen-Nr. mit Auto-Vorbelegung Präfix+`ttmmjj` aus Produktionsdatum — folgt Produkt-/Datumswechsel bis zur manuellen Eingabe; Default-Zeile spiegelt die Planung, bis sie angefasst wird; old()/Edit/Copy-Vorbelegung). **Blade-Falle erneut:** ein Kommentar mit literalem `@endphp` im `@php`-Block beendete den Block vorzeitig — Kommentartext angepasst. **Show:** neue Karte „Produktionsergebnis"; Planung als solche gekennzeichnet. **Index:** Spalte „Ergebnis" (Outputs summiert, Fallback Planung). Hinweise-Doku fortgeschrieben. | Neuer `tests/Feature/ProductionOutputTest.php` (8 grün, 32 Assertions): Mehrfach-Outputs buchen je Produkt / Planung bucht nichts, Service-Fallback inkl. Chargen-Nr., Differenz-Korrektur + Entfernen ⇒ Rückbuchung, Rohstoffverbrauch bleibt chargenbasiert, HTTP-Pflicht ≥ 1 Output, Set/Menge-Validierung, Payload-Felder, Masken-Render. Zwei bestehende HTTP-Tests um `outputs` ergänzt. Volle Suite: **201 grün, 731 Assertions** |
| _offen_ | AP-13 | Shop-Bestandsabzug | – |
| _offen_ | AP-14/15/16/17 | Audit-Trail, Blockrechte, 2FA, WaWi-Einstellungen | – |
> **➡️ 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.
> **➡️ HIER GEHT ES WEITER: AP-13 (Shop-Bestandsabzug bei Versand, inkl. Sets)** gemäß Konzept aus V4.0 (`entwicklungsplan-aktualisiert-02-06-2026.md`), mit der V6.0-Änderung: Buchung gegen die **`production_outputs`-basierten Bestände** (AP-28). Kern: beim **gebuchten Versand** je Bestellposition einen Abgang `source='sale'` im Produktbestand erzeugen (Sets in ihre Einzelprodukte auflösen); Stornos/Retouren als Gegenbuchung. `monthlyConsumptionByProduct()` berücksichtigt `source='sale'` bereits automatisch. Danach AP-14 (Audit-Trail) → AP-16 (2FA) → AP-15 (Blockrechte) → AP-17 (WaWi-Einstellungen).

View file

@ -111,12 +111,22 @@
(bestellt) und erhöht den Bestand erst, wenn beim Einkauf der **Wareneingang
gebucht** wird (Stufe 2: eingegangen, mit Menge/Charge/MHD). Bis dahin erscheint
die Menge nur unter „Offen bestellt".
- **Produktbestand wächst automatisch durch Produktion.** Jede gebuchte
Produktion erzeugt einen Eingang in Höhe der produzierten Stückzahl; eine
spätere Mengenkorrektur der Produktion wird als Differenz nachgebucht. Manuelle
Korrekturen erfolgen ausschließlich über Gegenbuchungen (`+`/`−`) – Einträge
der Historie werden nicht gelöscht. Verkaufsabgänge folgen mit der
Shop-Anbindung.
- **Produktbestand wächst automatisch durch Produktion — aus dem
Produktionsergebnis.** Die Stückzahl oben in der Produktions-Maske ist **nur
Planung** und bucht nichts. Ganz unten unter **„Produktionsergebnis"** wird
erfasst, was real entstanden ist — auch **mehrere Produkte** je Produktion
(z. B. Plan 50×50 ml → Ergebnis 40×50 ml + 50×5 ml). Jede Ergebniszeile hat
eine **Chargen-Nr.**, die aus Produkt-Präfix + Produktionsdatum (`ttmmjj`)
vorbelegt wird und editierbar bleibt. Eine spätere Korrektur des Ergebnisses
wird je Produkt als Differenz nachgebucht. Manuelle Korrekturen erfolgen
ausschließlich über Gegenbuchungen (`+`/`−`) – Einträge der Historie werden
nicht gelöscht. Verkaufsabgänge folgen mit der Shop-Anbindung.
- **Produktions-Maske:** zeigt die Hersteller-Rezeptur als **INCI-Liste** mit
Alternativname, Qualität, Anteil %, Gesamt-Gramm und **Regal** (Lagerort);
die Chargen-Zuordnung ist nach **Rezeptur-Phasen** gegliedert (inkl.
Phasen-Notiz), mit Status-Ampel je Rohstoff (grün = Soll erfüllt). Der
**Rohstoffbestand** berechnet sich unverändert aus den je Charge
eingetragenen Ist-Mengen — unabhängig vom Produktionsergebnis.
- **Bestellweg je INCI-Lieferant:** Am Inhaltsstoff wird pro Lieferant eine
Shop-URL gepflegt („+ weiteren Lieferanten anlegen"). Ist die URL **leer**,
gilt für diesen Lieferanten **Bestellung per Mail** – der Rohstoffbestand

View file

@ -1,4 +1,19 @@
@php($isEdit = $isEdit ?? false)
@php
// Achtung: in diesem Template nur die Block-Form von php-Direktiven verwenden (Blade-Compiler-Falle, siehe AP-23).
$isEdit = $isEdit ?? false;
// AP-28: Vorbelegung der Produktionsergebnis-Zeilen (old() > bestehende Outputs > leer = Default aus Planung per JS).
$initialOutputs = old('outputs');
if (! $initialOutputs) {
$initialOutputs = ($model && $model->relationLoaded('outputs') && $model->outputs->isNotEmpty())
? $model->outputs->map(fn ($o) => [
'product_id' => $o->product_id,
'quantity' => $o->quantity,
'batch_number' => $o->batch_number,
])->values()->all()
: [];
}
@endphp
<div class="form-row">
<div class="form-group col-12 col-md-6">
<label for="product_id">{{ __('Produkt') }}</label>
@ -36,9 +51,10 @@
@enderror
</div>
<div class="form-group col-12 col-sm-6">
<label for="quantity">{{ __('Produzierte Stückzahl') }}</label>
<label for="quantity">{{ __('Geplante Stückzahl (Planung)') }}</label>
<input type="number" name="quantity" id="quantity" min="1" step="1" class="form-control @error('quantity') is-invalid @enderror"
value="{{ old('quantity', $model?->quantity ?? 1) }}" required>
<small class="text-muted">{{ __('Nur Planung — bucht keinen Bestand. Der Produktbestand entsteht aus dem Produktionsergebnis unten.') }}</small>
@error('quantity')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
@ -49,6 +65,27 @@
<div id="recipe-area" class="mb-3" style="display:none;">
<hr>
{{-- AP-28: INCI-Liste der Hersteller-Rezeptur mit Alternativname, Qualität und Regal --}}
<div id="recipe-inci" style="display:none;">
<h6>{{ __('INCI-Liste (Hersteller-Rezeptur)') }}</h6>
<div class="table-responsive border rounded mb-3">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>{{ __('Rohstoff') }}</th>
<th>{{ __('Alternativer Name') }}</th>
<th>{{ __('Qualität') }}</th>
<th class="text-right">{{ __('Anteil %') }}</th>
<th class="text-right">{{ __('Gramm (gesamt)') }}</th>
<th>{{ __('Regal') }}</th>
</tr>
</thead>
<tbody id="recipe-inci-body"></tbody>
<tfoot id="recipe-inci-foot"></tfoot>
</table>
</div>
</div>
<h6>{{ __('Chargen zuordnen') }}</h6>
<p class="text-muted small" id="recipe-hint"></p>
<div id="recipe-ingredient-lines"></div>
@ -69,3 +106,42 @@
<label for="notes">{{ __('Notizen') }}</label>
<textarea name="notes" id="notes" class="form-control" rows="2">{{ old('notes', $model?->notes) }}</textarea>
</div>
{{-- AP-28: Produktionsergebnis — treibt den Produktbestand (mehrere Output-Produkte möglich) --}}
<hr>
<h6>{{ __('Produktionsergebnis') }}</h6>
<div class="alert alert-info py-2 px-3 small">
{{ __('Die Planung oben ist unverbindlich. Der Produktbestand entsteht ausschließlich aus dem Produktionsergebnis — auch mehrere Produkte möglich (z. B. 40×50 ml + 50×5 ml).') }}
</div>
@error('outputs')
<div class="text-danger small mb-2">{{ $message }}</div>
@enderror
<div class="form-row text-muted small mb-1 d-none d-sm-flex">
<div class="col-sm-5">{{ __('Produkt') }}</div>
<div class="col-sm-3">{{ __('Produzierte Stückzahl') }}</div>
<div class="col-sm-3">{{ __('Chargen-Nr.') }}</div>
<div class="col-sm-1"></div>
</div>
<div id="production-outputs" data-initial='@json($initialOutputs)'></div>
<button type="button" class="btn btn-sm btn-outline-secondary mb-3" id="btn-add-output">{{ __('+ weitere Ergebniszeile') }}</button>
<script type="text/template" id="output-row-template">
<div class="form-row align-items-center mb-1 output-row">
<div class="col-12 col-sm-5 mb-1">
<select name="outputs[__INDEX__][product_id]" class="form-control form-control-sm output-product" required>
@foreach($products as $p)
<option value="{{ $p->id }}" data-batch-prefix="{{ $p->batch_prefix }}">{{ $p->name }}</option>
@endforeach
</select>
</div>
<div class="col-6 col-sm-3 mb-1">
<input type="number" name="outputs[__INDEX__][quantity]" min="1" step="1" class="form-control form-control-sm output-quantity" required>
</div>
<div class="col-6 col-sm-3 mb-1">
<input type="text" name="outputs[__INDEX__][batch_number]" maxlength="100" class="form-control form-control-sm output-batch" placeholder="{{ __('Chargen-Nr.') }}">
</div>
<div class="col-12 col-sm-1 mb-1 text-right">
<a href="#" class="text-danger output-remove" title="{{ __('Zeile entfernen') }}"><span class="fas fa-times"></span></a>
</div>
</div>
</script>

View file

@ -30,6 +30,14 @@
return Number(value).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function parseGerman(value) {
value = String(value || '').trim();
if (!value) {
return 0;
}
return parseFloat(value.replace(/\./g, '').replace(',', '.')) || 0;
}
// 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) {
@ -63,7 +71,7 @@
var $colQty = $('<div class="col-12 col-sm-5 mb-1"></div>');
var $group = $('<div class="input-group input-group-sm"></div>');
$group.append($('<input type="text" class="form-control" required placeholder="0" name="ingredient_lines[' + idx + '][quantity_used]">').val(preselectedQty || ''));
$group.append($('<input type="text" class="form-control recipe-qty-input" required placeholder="0" name="ingredient_lines[' + idx + '][quantity_used]">').val(preselectedQty || ''));
$group.append('<div class="input-group-append"><span class="input-group-text">g</span></div>');
$colQty.append($group);
$row.append($colQty);
@ -71,6 +79,30 @@
$wrap.find('.recipe-rows').first().append($row);
}
// AP-28: Status-Ampel je Rohstoff — grün, wenn Summe der Chargen-Mengen dem Soll entspricht.
function updateIngredientStatus($wrap) {
var ing = $wrap.data('recipe-ing');
var $dot = $wrap.find('.recipe-status').first();
if (!ing || ing.gram == null || !$dot.length) {
return;
}
var qty = parseInt($('#quantity').val(), 10) || 1;
var soll = ing.gram * (ing.factor || 1) * qty;
var sum = 0;
$wrap.find('.recipe-qty-input').each(function () {
sum += parseGerman($(this).val());
});
var ok = Math.abs(sum - soll) <= 0.02;
$dot.css('color', ok ? '#28a745' : '#fd7e14')
.attr('title', ok ? '{{ __('Soll erfüllt') }}' : '{{ __('Summe weicht vom Soll ab') }}');
}
function updateAllStatuses() {
$('#recipe-ingredient-lines [data-ingredient-id]').each(function () {
updateIngredientStatus($(this));
});
}
function recomputeSoll() {
var qty = parseInt($('#quantity').val(), 10) || 1;
$('#recipe-ingredient-lines [data-ingredient-id]').each(function () {
@ -82,6 +114,98 @@
var soll = ing.gram * (ing.factor || 1) * qty;
$wrap.find('.recipe-soll').text(fmt(soll));
});
renderInciTable();
updateAllStatuses();
}
// AP-28: INCI-Liste der Hersteller-Rezeptur (Alternativname, Qualität, Anteil, Gramm, Regal).
function renderInciTable() {
var $body = $('#recipe-inci-body').empty();
var $foot = $('#recipe-inci-foot').empty();
if (!recipeIngredients.length) {
$('#recipe-inci').hide();
return;
}
var qty = parseInt($('#quantity').val(), 10) || 1;
var totalPercent = 0;
var totalGrams = 0;
recipeIngredients.forEach(function (ing) {
var grams = ing.gram != null ? ing.gram * (ing.factor || 1) * qty : null;
if (ing.gram != null) {
totalPercent += ing.gram;
}
if (grams != null) {
totalGrams += grams;
}
$body.append($('<tr></tr>')
.append($('<td></td>').text(ing.name))
.append($('<td></td>').text(ing.alt_name || '—'))
.append($('<td></td>').text(ing.quality || '—'))
.append($('<td class="text-right"></td>').text(ing.gram != null ? fmt(ing.gram) : '—'))
.append($('<td class="text-right"></td>').text(grams != null ? fmt(grams) : '—'))
.append($('<td></td>').text(ing.storage_label || '—')));
});
$foot.append($('<tr class="font-weight-bold"></tr>')
.append($('<td></td>').text('{{ __('Summe') }}'))
.append('<td></td><td></td>')
.append($('<td class="text-right"></td>').text(fmt(totalPercent) + ' %'))
.append($('<td class="text-right"></td>').text(fmt(totalGrams) + ' g'))
.append('<td></td>'));
$('#recipe-inci').show();
}
// AP-28: Chargen-Erfassung nach Rezeptur-Phasen (AP-23) gruppiert.
function buildPhaseGroups(data) {
var phases = data.phases || [];
var ingredients = (data.ingredients || []).filter(function (ing) { return ing.gram != null; });
if (!phases.length) {
return ingredients.length ? [{name: null, note: null, ingredients: ingredients}] : [];
}
var groups = [];
var byPhase = {};
phases.forEach(function (ph) {
var group = {name: ph.name, note: ph.note, ingredients: []};
byPhase[String(ph.id)] = group;
groups.push(group);
});
var loose = {name: null, note: null, ingredients: []};
ingredients.forEach(function (ing) {
var group = ing.phase_id != null ? byPhase[String(ing.phase_id)] : null;
(group || loose).ingredients.push(ing);
});
if (loose.ingredients.length) {
groups.push(loose);
}
return groups.filter(function (g) { return g.ingredients.length > 0; });
}
function renderIngredientWrap(ing, $container) {
var qty = parseInt($('#quantity').val(), 10) || 1;
var soll = ing.gram * (ing.factor || 1) * qty;
var $wrap = $('<div class="border rounded p-2 mb-2" data-ingredient-id="' + ing.id + '"></div>');
$wrap.data('recipe-ing', ing);
var $head = $('<div class="d-flex justify-content-between flex-wrap"></div>');
var $title = $('<strong></strong>').text(ing.name);
if (ing.alt_name) {
$title.append($('<span class="text-muted font-weight-normal"></span>').text(' · ' + ing.alt_name));
}
$head.append($title);
$head.append($('<span class="text-muted small"></span>')
.html('{{ __('Soll') }}: <span class="recipe-soll">' + fmt(soll) + '</span> g <span class="recipe-status" style="font-size:1rem;">●</span>'));
$wrap.append($head);
$wrap.append('<div class="recipe-rows mt-1"></div>');
$wrap.append('<button type="button" class="btn btn-sm btn-outline-secondary btn-add-split mt-1">{{ __('Weitere Charge') }}</button>');
$container.append($wrap);
var existing = existingLines[String(ing.id)] || [];
if (existing.length > 0) {
existing.forEach(function (ex) {
addIngredientRow($wrap, ing, ex.batch_key, ex.quantity_used);
});
} else {
addIngredientRow($wrap, ing);
}
updateIngredientStatus($wrap);
}
function renderRecipe(data) {
@ -93,6 +217,7 @@
$('#recipe-warning').hide();
$('#btn-submit-production').prop('disabled', false);
$('#recipe-area').show();
$('#recipe-inci').hide();
$('#recipe-hint')
.text('{{ __('Dieses Produkt benötigt keine Rezeptur. Es wird ohne Rohstoffverbrauch produziert.') }}')
.removeClass('text-danger').addClass('text-muted');
@ -118,38 +243,35 @@
$('#btn-submit-production').prop('disabled', false);
$('#recipe-area').show();
var qty = parseInt($('#quantity').val(), 10) || 1;
var hintParts = [];
var hasMissingGram = false;
renderInciTable();
var hintParts = [];
recipeIngredients.forEach(function (ing) {
if (ing.gram == null) {
hasMissingGram = true;
hintParts.push(ing.name + ': {{ __('Anteil in der Hersteller-Rezeptur fehlt') }}');
return;
}
var soll = ing.gram * (ing.factor || 1) * qty;
var $wrap = $('<div class="border rounded p-2 mb-2" data-ingredient-id="' + ing.id + '"></div>');
$wrap.data('recipe-ing', ing);
var $head = $('<div class="d-flex justify-content-between flex-wrap"></div>');
$head.append($('<strong></strong>').text(ing.name));
$head.append($('<span class="text-muted small"></span>').html('{{ __('Soll') }}: <span class="recipe-soll">' + fmt(soll) + '</span> g'));
$wrap.append($head);
$wrap.append('<div class="recipe-rows mt-1"></div>');
$wrap.append('<button type="button" class="btn btn-sm btn-outline-secondary btn-add-split mt-1">{{ __('Weitere Charge') }}</button>');
$('#recipe-ingredient-lines').append($wrap);
var existing = existingLines[String(ing.id)] || [];
if (existing.length > 0) {
existing.forEach(function (ex) {
addIngredientRow($wrap, ing, ex.batch_key, ex.quantity_used);
});
} else {
addIngredientRow($wrap, ing);
}
});
if (hasMissingGram) {
var groups = buildPhaseGroups(data);
groups.forEach(function (group) {
var $target = $('#recipe-ingredient-lines');
if (group.name != null) {
var $block = $('<div class="border rounded p-2 mb-3 recipe-phase-block"></div>');
var $header = $('<div class="mb-2"></div>');
$header.append($('<strong></strong>').text(group.name));
if (group.note) {
$header.append($('<div class="text-muted small"></div>').text(group.note));
}
$block.append($header);
$('#recipe-ingredient-lines').append($block);
$target = $block;
}
group.ingredients.forEach(function (ing) {
renderIngredientWrap(ing, $target);
});
});
if (hintParts.length) {
$('#recipe-hint').text(hintParts.join(' · ')).removeClass('text-muted').addClass('text-danger');
} else {
$('#recipe-hint')
@ -173,6 +295,11 @@
return;
}
addIngredientRow($wrap, ing);
updateIngredientStatus($wrap);
});
$(document).on('input change', '.recipe-qty-input', function () {
updateIngredientStatus($(this).closest('[data-ingredient-id]'));
});
function loadRecipe() {
@ -209,10 +336,139 @@
}
});
// ------------------------------------------------------------------
// AP-28: Produktionsergebnis-Repeater — treibt den Produktbestand.
// Standard: eine Zeile, die der Planung folgt, bis sie manuell geändert wird.
// Chargen-Nr. wird aus Produkt-Präfix + Produktionsdatum (ttmmjj) vorbelegt.
// ------------------------------------------------------------------
var outputIndex = 0;
function batchDateSuffix() {
var raw = String($('#produced_at').val() || '').trim();
var m = raw.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!m) {
return null;
}
return m[1] + m[2] + m[3].slice(2);
}
function suggestedBatch($select) {
var prefix = String($select.find('option:selected').data('batch-prefix') || '').trim();
var suffix = batchDateSuffix();
if (!prefix || !suffix) {
return '';
}
return prefix + suffix;
}
function refreshBatch($row) {
var $batch = $row.find('.output-batch');
if ($batch.data('manual') && String($batch.val() || '').trim() !== '') {
return;
}
$batch.val(suggestedBatch($row.find('.output-product'))).data('manual', false);
}
function addOutputRow(data, isAuto) {
var idx = outputIndex++;
var html = $('#output-row-template').html().replace(/__INDEX__/g, idx);
var $row = $(html);
$row.data('auto', !!isAuto);
if (data && data.product_id != null) {
$row.find('.output-product').val(String(data.product_id));
} else {
var planned = $('#product_id').val();
if (planned) {
$row.find('.output-product').val(String(planned));
}
}
if (data && data.quantity != null) {
$row.find('.output-quantity').val(data.quantity);
} else {
$row.find('.output-quantity').val($('#quantity').val() || 1);
}
if (data && data.batch_number) {
$row.find('.output-batch').val(data.batch_number).data('manual', true);
}
$('#production-outputs').append($row);
if (!data || !data.batch_number) {
refreshBatch($row);
}
}
$('#btn-add-output').on('click', function (e) {
e.preventDefault();
addOutputRow(null, false);
});
$(document).on('click', '.output-remove', function (e) {
e.preventDefault();
if ($('#production-outputs .output-row').length <= 1) {
return;
}
$(this).closest('.output-row').remove();
});
$(document).on('change', '.output-product', function () {
var $row = $(this).closest('.output-row');
$row.data('auto', false);
refreshBatch($row);
});
$(document).on('input change', '.output-quantity', function () {
$(this).closest('.output-row').data('auto', false);
});
$(document).on('input', '.output-batch', function () {
$(this).data('manual', String($(this).val() || '').trim() !== '');
});
// Planung oben spiegelt sich in der Auto-Zeile, solange diese unberührt ist.
$('#product_id').on('change', function () {
var val = $(this).val();
$('#production-outputs .output-row').each(function () {
var $row = $(this);
if ($row.data('auto') && val) {
$row.find('.output-product').val(String(val));
refreshBatch($row);
}
});
});
$('#quantity').on('change input', function () {
var val = $(this).val();
$('#production-outputs .output-row').each(function () {
var $row = $(this);
if ($row.data('auto')) {
$row.find('.output-quantity').val(val || 1);
}
});
});
$('#produced_at').on('change', function () {
$('#production-outputs .output-row').each(function () {
refreshBatch($(this));
});
});
function initOutputs() {
var initial = $('#production-outputs').data('initial') || [];
if (Array.isArray(initial) && initial.length > 0) {
initial.forEach(function (row) {
addOutputRow(row, false);
});
} else {
addOutputRow(null, true);
}
}
$(document).ready(function () {
if ($('#product_id').val() && $('#location_id').val()) {
loadRecipe();
}
initOutputs();
});
})(jQuery);
</script>

View file

@ -24,7 +24,7 @@
<th style="max-width: 130px;"></th>
<th>{{ __('Datum') }}</th>
<th>{{ __('Produkt') }}</th>
<th>{{ __('Stück') }}</th>
<th>{{ __('Ergebnis') }}</th>
<th>{{ __('Standort') }}</th>
<th>{{ __('MHD-Hinweis') }}</th>
</tr>
@ -45,7 +45,14 @@
</td>
<td data-sort="{{ $row->produced_at?->timestamp ?? 0 }}">{{ $row->produced_at?->format('d.m.Y') }}</td>
<td><span class="wawi-item-name">{{ $row->product?->name ?? '—' }}</span></td>
<td>{{ $row->quantity }}</td>
<td>
{{-- AP-28: reale Outputs statt Planungs-Stückzahl --}}
@if($row->outputs->isNotEmpty())
{{ $row->outputs->map(fn ($o) => $o->quantity . '× ' . ($o->product?->name ?? '—'))->implode(', ') }}
@else
{{ $row->quantity }} <span class="text-muted small">({{ __('Planung') }})</span>
@endif
</td>
<td>{{ $row->location?->name ?? '—' }}</td>
<td>
@if($row->mhd_warning)

View file

@ -32,8 +32,8 @@
<span class="wawi-deflist__value">{{ $model->produced_at?->format('d.m.Y') }}</span>
</div>
<div class="wawi-deflist__item">
<span class="wawi-deflist__label">{{ __('Stückzahl') }}</span>
<span class="wawi-deflist__value">{{ $model->quantity }}</span>
<span class="wawi-deflist__label">{{ __('Geplante Stückzahl') }}</span>
<span class="wawi-deflist__value">{{ $model->quantity }} <span class="text-muted small">({{ __('Planung — bucht keinen Bestand') }})</span></span>
</div>
<div class="wawi-deflist__item">
<span class="wawi-deflist__label">{{ __('Standort') }}</span>
@ -63,6 +63,37 @@
</div>
</div>
{{-- AP-28: Produktionsergebnis — diese Zeilen buchen den Produktbestand --}}
<div class="card wawi-card mb-3">
<div class="wawi-card__header">{{ __('Produktionsergebnis') }}</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table wawi-table mb-0">
<thead>
<tr>
<th>{{ __('Produkt') }}</th>
<th>{{ __('Produzierte Stückzahl') }}</th>
<th>{{ __('Chargen-Nr.') }}</th>
</tr>
</thead>
<tbody>
@forelse($model->outputs as $output)
<tr>
<td>{{ $output->product?->name ?? '—' }}</td>
<td>{{ $output->quantity }}</td>
<td>{{ $output->batch_number ?: '—' }}</td>
</tr>
@empty
<tr>
<td colspan="3"><div class="wawi-empty">{{ __('Kein Produktionsergebnis erfasst.') }}</div></td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
<div class="card wawi-card mb-3">
<div class="wawi-card__header">{{ __('Rohstoff-Verbrauch (Chargen)') }}</div>
<div class="card-body p-0">

View file

@ -354,6 +354,10 @@ test('produktion per http speichert eigenprodukt ohne chargen', function () {
'location_id' => $location->id,
'produced_at' => '01.03.2026',
'quantity' => 12,
// AP-28: Produktionsergebnis ist Pflicht (bucht den Produktbestand).
'outputs' => [
['product_id' => $product->id, 'quantity' => 12, 'batch_number' => null],
],
]);
$response->assertRedirect();
@ -361,6 +365,10 @@ test('produktion per http speichert eigenprodukt ohne chargen', function () {
'product_id' => $product->id,
'quantity' => 12,
]);
$this->assertDatabaseHas('production_outputs', [
'product_id' => $product->id,
'quantity' => 12,
]);
});
test('produktentwicklung platzhalter rendert briefing-hinweis', function () {

View file

@ -0,0 +1,325 @@
<?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\ProductStockMovement;
use App\Models\StockEntry;
use App\Models\Supplier;
use App\Services\ProductionService;
use App\Services\ProductStockService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function ap28MakeUser(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('ap28_', true).'@test.example',
'password' => bcrypt('password'),
]);
$user->forceFill([
'admin' => $adminLevel,
'confirmed' => true,
'active' => true,
'wizard' => 100,
'blocked' => false,
])->save();
return $user->fresh();
}
function ap28MakeProduct(string $name, array $overrides = []): Product
{
return Product::query()->create(array_merge([
'name' => $name,
'title' => $name,
'active' => true,
], $overrides));
}
/**
* Produkt ohne Rezeptur-Pflicht — Produktionen brauchen dann keine Chargen-Zeilen.
*/
function ap28MakeSimpleProduct(string $name, array $overrides = []): Product
{
return ap28MakeProduct($name, array_merge(['no_recipe_required' => true], $overrides));
}
function ap28Stock(int $productId): int
{
return app(ProductStockService::class)->currentStock($productId);
}
test('mehrere outputs buchen produktbestand je produkt, planung bucht nichts', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$planned = ap28MakeSimpleProduct('AP28-Plan-50ml');
$small = ap28MakeSimpleProduct('AP28-5ml');
$production = app(ProductionService::class)->store(
[
'product_id' => $planned->id,
'location_id' => $location->id,
'produced_at' => '2026-07-01',
'quantity' => 50, // Planung — darf nicht buchen
'notes' => null,
],
[],
$user->id,
[
['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => 'GS1010726'],
['product_id' => $small->id, 'quantity' => 50, 'batch_number' => 'GS2010726'],
]
);
expect($production->outputs)->toHaveCount(2);
expect(ap28Stock($planned->id))->toBe(40);
expect(ap28Stock($small->id))->toBe(50);
});
test('service ohne outputs faellt auf planung zurueck (backfill-semantik)', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$product = ap28MakeSimpleProduct('AP28-Fallback', ['batch_prefix' => 'TP1']);
$production = app(ProductionService::class)->store(
[
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '2026-06-15',
'quantity' => 12,
'notes' => null,
],
[],
$user->id
);
expect($production->outputs)->toHaveCount(1);
expect($production->outputs->first()->quantity)->toBe(12);
// Chargen-Nr. der Fallback-Zeile = Präfix + ttmmjj des Produktionsdatums (AP-24).
expect($production->outputs->first()->batch_number)->toBe('TP1150626');
expect(ap28Stock($product->id))->toBe(12);
});
test('bearbeiten korrigiert produktbestand per differenz je output-produkt', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$planned = ap28MakeSimpleProduct('AP28-Edit-50ml');
$small = ap28MakeSimpleProduct('AP28-Edit-5ml');
$service = app(ProductionService::class);
$production = $service->store(
['product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, 'notes' => null],
[],
$user->id,
[['product_id' => $planned->id, 'quantity' => 50, 'batch_number' => null]]
);
expect(ap28Stock($planned->id))->toBe(50);
// Ergebnis nachträglich: nur 40×50 ml, dafür 50×5 ml.
$production = $service->updateProduction(
$production,
['product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, 'notes' => null],
[],
$user->id,
[
['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => null],
['product_id' => $small->id, 'quantity' => 50, 'batch_number' => null],
]
);
expect(ap28Stock($planned->id))->toBe(40);
expect(ap28Stock($small->id))->toBe(50);
// Append-only: Korrektur als Gegenbuchung, keine Historie gelöscht.
expect(ProductStockMovement::query()->where('product_id', $planned->id)->count())->toBe(2);
// Output-Produkt komplett entfernen ⇒ Bestand wird auf null zurückgeführt.
$service->updateProduction(
$production->fresh(),
['product_id' => $planned->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 50, 'notes' => null],
[],
$user->id,
[['product_id' => $planned->id, 'quantity' => 40, 'batch_number' => null]]
);
expect(ap28Stock($small->id))->toBe(0);
});
test('rohstoffverbrauch bleibt chargenbasiert und unabhaengig vom ergebnis', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$supplier = Supplier::factory()->create();
$ing = Ingredient::query()->create([
'name' => 'AP28-Rohstoff',
'trans_name' => '',
'inci' => 'AP28-Rohstoff',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
]);
$stock = StockEntry::query()->create([
'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,
'status' => 'received',
'received_by' => $user->id,
'received_at' => '2026-01-15',
'received_quantity' => 5000,
'batch_number' => 'AP28-CHARGE',
'best_before' => '2030-12-31',
]);
$product = ap28MakeProduct('AP28-Rezeptur-Produkt');
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 10,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
]);
// Plan 2 Stück (Soll 20 g) — Ergebnis weicht ab (nur 1 Stück), Verbrauch bleibt bei den Chargen-Werten.
$production = app(ProductionService::class)->store(
['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 2, 'notes' => null],
[['ingredient_id' => $ing->id, 'stock_entry_id' => $stock->id, 'quantity_used' => '20']],
$user->id,
[['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null]]
);
expect((float) $production->productionIngredients->first()->quantity_used)->toBe(20.0);
expect(ap28Stock($product->id))->toBe(1);
});
test('http-validierung verlangt mindestens eine ergebniszeile', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$product = ap28MakeSimpleProduct('AP28-HTTP-Pflicht');
$this->actingAs($user, 'user');
$response = $this->post(route('admin.inventory.productions.store'), [
'product_id' => $product->id,
'location_id' => $location->id,
'produced_at' => '01.07.2026',
'quantity' => 10,
]);
$response->assertSessionHasErrors('outputs');
expect(Production::query()->count())->toBe(0);
});
test('outputs validieren produkt und stueckzahl im service', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$product = ap28MakeSimpleProduct('AP28-Validierung');
$set = ap28MakeProduct('AP28-Set', ['is_set' => true, 'no_recipe_required' => true]);
$base = ['product_id' => $product->id, 'location_id' => $location->id, 'produced_at' => '2026-07-01', 'quantity' => 1, 'notes' => null];
expect(fn () => app(ProductionService::class)->store($base, [], $user->id, [
['product_id' => $set->id, 'quantity' => 5, 'batch_number' => null],
]))->toThrow(ValidationException::class);
expect(fn () => app(ProductionService::class)->store($base, [], $user->id, [
['product_id' => $product->id, 'quantity' => 0, 'batch_number' => null],
]))->toThrow(ValidationException::class);
});
test('recipe-payload liefert batch_prefix, phasen und inci-zusatzspalten', function () {
$user = ap28MakeUser();
$location = Location::factory()->create();
$ing = Ingredient::query()->create([
'name' => 'AP28-Payload-Rohstoff',
'trans_name' => '',
'inci' => 'AP28-Payload-Rohstoff',
'trans_inci' => '',
'effect' => '',
'trans_effect' => '',
'active' => true,
'pos' => 0,
'alt_name' => 'Phospholipon 80 H',
]);
$product = ap28MakeProduct('AP28-Payload-Produkt', ['batch_prefix' => 'TP1']);
$phase = $product->recipePhases()->create([
'recipe_type' => 'manufacturer',
'name' => 'Phase A',
'note' => 'auf 70 °C erhitzen',
'pos' => 0,
]);
ProductIngredient::query()->create([
'product_id' => $product->id,
'ingredient_id' => $ing->id,
'pos' => 0,
'gram' => 100,
'factor' => 1.0,
'recipe_type' => 'manufacturer',
'recipe_phase_id' => $phase->id,
]);
$payload = app(ProductionService::class)->buildRecipePayload($product->fresh(), $location->id, 1);
expect($payload['product']['batch_prefix'])->toBe('TP1');
expect($payload['phases'])->toHaveCount(1);
expect($payload['phases'][0]['name'])->toBe('Phase A');
expect($payload['phases'][0]['note'])->toBe('auf 70 °C erhitzen');
$first = $payload['ingredients'][0];
expect($first['alt_name'])->toBe('Phospholipon 80 H');
expect($first['phase_id'])->toBe($phase->id);
expect(array_key_exists('storage_label', $first))->toBeTrue();
expect(array_key_exists('quality', $first))->toBeTrue();
});
test('produktions-maske rendert ergebnis-block und planungs-hinweis', function () {
$user = ap28MakeUser();
$this->actingAs($user, 'user');
$response = $this->get(route('admin.inventory.productions.create'));
$response->assertOk();
$response->assertSee('Produktionsergebnis');
$response->assertSee('Geplante Stückzahl (Planung)');
$response->assertSee('Die Planung oben ist unverbindlich.', false);
$response->assertSee('output-row-template', false);
});

View file

@ -296,6 +296,9 @@ test('produktion per formular bucht charge fefo auf beide einkaeufe', function (
'ingredient_lines' => [
['ingredient_id' => $ing->id, 'batch_key' => 'b:AP27-CHARGE', 'quantity_used' => '150'],
],
'outputs' => [
['product_id' => $product->id, 'quantity' => 1, 'batch_number' => null],
],
]);
$response->assertSessionHasNoErrors();