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:
parent
7a5b5e0f7b
commit
99a6c92e6c
17 changed files with 1009 additions and 74 deletions
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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'] ?? []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
app/Models/ProductionOutput.php
Normal file
49
app/Models/ProductionOutput.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
325
tests/Feature/ProductionOutputTest.php
Normal file
325
tests/Feature/ProductionOutputTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue