Warenwirtschaft: Audit-Bericht 15.07. + Entwicklungsplan-Protokoll (Bugfix, AP-31)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
35b9076cbc
commit
46a832f2be
2 changed files with 250 additions and 0 deletions
248
dev/product management /audit-bericht-15-07-2026.md
Normal file
248
dev/product management /audit-bericht-15-07-2026.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# Audit-Bericht: Warenwirtschaft, Produktion & Produktbestand
|
||||
|
||||
> **Stand:** 15.07.2026
|
||||
> **Prüfgegenstand:** WaWi-Modul (Admin-Panel) auf Basis Entwicklungsplan V6.0 (`entwicklungsplan-aktualisiert-06-07-2026.md`), Commits `65f949d` … `7198b66`
|
||||
> **Methodik:** Vollständige Code-Lektüre der Services (`InventoryService`, `ProductionService`, `ProductStockService`), Repositories (`StockEntryRepository`, `ProductionRepository`, `ProductRepository`), aller 21 Inventory-Controller, FormRequests, Models, Migrationen und Views; Security-Sweep (SQLi/XSS/CSRF/AuthZ) über Routen und Blades; Abgleich Entwicklungsplan ↔ Code ↔ Testsuite. Jeder Befund ist mit Datei + Zeile belegt und gegen die Gegenseite (Validierung, Tests, Frontend) geprüft.
|
||||
|
||||
---
|
||||
|
||||
## 0. Executive Summary
|
||||
|
||||
Das Modul ist **architektonisch solide**: Controller → Repository/Service sauber getrennt, Bestandsrechnung über SQL-Aggregate, Produktionsbuchung transaktional und (seriell) idempotent, keine SQL-Injection, Blades durchgängig escaped, Mass Assignment über explizite Whitelists abgesichert. Die Testsuite ist mit **203 Tests / 737 Assertions zu 100 % grün** und deckt die Kernpfade gut ab. Die Angaben im Entwicklungsplan V6.0 (§1, §6) stimmen mit dem Code überein — **kein einziger Plan-Status war falsch.**
|
||||
|
||||
Es gibt jedoch **einen systematischen Konstruktionsfehler** (Befund H-1: Ausschuss fehlt in allen Chargen-Restmengen — Detailseite, Batch-Check und Produktions-FEFO rechnen falsch, sobald Ausschuss gebucht wurde) sowie **zwei Integritätslücken in der Produktionsbuchung** (H-2: negative Mengen passieren die Validierung; H-3: dieselbe Charge in zwei Zeilen wird doppelt gegen dieselbe Restmenge gerechnet) und **zwei Datenverlust-Risiken in der Rezepturpflege** (H-4, H-5). Diese fünf Punkte sollten vor AP-13 (Shop-Bestandsabzug) behoben werden, da AP-13 auf denselben Bestandsdaten aufsetzt.
|
||||
|
||||
| Schweregrad | Anzahl |
|
||||
|---|---|
|
||||
| Hoch | 5 |
|
||||
| Mittel | 10 |
|
||||
| Niedrig | 16 |
|
||||
|
||||
---
|
||||
|
||||
## 1. Verifikation des Entwicklungsplans (Doku ↔ Code)
|
||||
|
||||
Alle stichprobenartig und strukturell geprüften Behauptungen aus V6.0 halten stand:
|
||||
|
||||
| Behauptung (V6.0) | Ergebnis |
|
||||
|---|---|
|
||||
| Suite „201 grün, 731 Assertions" | ✅ aktuell **203 grün, 737 Assertions** (2 Tests seit Blockabschluss dazugekommen), 0 Failures |
|
||||
| Commits OPT-1…4, AP-20/21/23/24/27/28 einzeln committet | ✅ `65f949d`, `8c90d95`, `d05df49`, `ea36067`, `97d420c`, `209ea46`, `e47d848`, `7a5b5e0`, `99a6c92` vorhanden, Inhalte passen zum Protokoll §6 |
|
||||
| OPT-1: Composite-Indizes | ✅ `2026_07_06_113731_add_aggregation_indexes_to_stock_entries_table.php:16-17` — deckt exakt die WHERE/GROUP-BY-Muster des `InventoryService` ab |
|
||||
| OPT-3: Index-Limits (500, `?all=1`) | ✅ `StockEntryRepository::listForIndex():151-175`, `ProductionRepository::listForIndex()` |
|
||||
| OPT-4: `lastNetPricePerSupplier` als Window-Function | ✅ `StockEntryRepository.php:128-143` (`ROW_NUMBER() OVER`) |
|
||||
| AP-24: Chargen-Format Präfix + `ttmmjj` | ✅ `Product::suggestedBatchNumber()`, `app/Models/Product.php:610-619` (`format('dmy')`) |
|
||||
| AP-28: Buchung je Output-Produkt, idempotenter Differenz-Abgleich | ✅ `ProductStockService::recordProductionStock():82-128`; Backfill idempotent (`2026_07_06_153907`, `WHERE NOT EXISTS`) |
|
||||
| Produktionen bewusst nicht löschbar | ✅ `routes/web.php:327` (`only([...])` ohne `destroy`) — keine Rückbuchungs-Lücke |
|
||||
| Wareneingänge nach Buchung nicht editierbar | ✅ `StockEntryController::edit()/update():104-126` (nur `pending`) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Befunde — HOCH
|
||||
|
||||
### H-1 · Ausschuss (StockDisposal) fehlt in allen Chargen-Restmengen → Detailseite, Batch-Check und Produktions-FEFO rechnen falsch
|
||||
|
||||
**Ort:**
|
||||
- `app/Services/ProductionService.php:348-367` (`consumedByStockEntry` — summiert nur `production_ingredients`)
|
||||
- `app/Services/ProductionService.php:374-397` (`availableStockEntriesForIngredient`)
|
||||
- `app/Services/InventoryService.php:291-311` (`chargesWithRemainingForIngredient`)
|
||||
|
||||
**Problem:** `stock_disposals` speichert `stock_entry_id` (Migration `2026_06_03_124056:19`, Chargen-Auswahl im Formular `stock-disposals/create.blade.php`), aber die per-Charge-Restmenge rechnet ausschließlich `received_quantity − Produktionsverbrauch`. Die **Gesamt**-Aggregation `remainingByIngredient()` (`InventoryService.php:36-61`) zieht Ausschuss dagegen korrekt ab. Zwei Bestandswahrheiten im selben Modul.
|
||||
|
||||
**Betroffene Konsumenten:**
|
||||
- `RawMaterialStockController::show`: „Gesamtbestand" der Detailseite = `$chargeGroups->sum('remaining')` → ignoriert Ausschuss, widerspricht der Index-Seite; der Status (`stockStatus`) wird mit dem überhöhten Wert berechnet — Detail kann „Ausreichend" zeigen, während Index/Badge „Kritisch" melden.
|
||||
- Chargen-Tabelle der Detailseite listet vollständig entsorgte Chargen als verfügbar.
|
||||
- `StockEntryController::batchCheck():236-238` (AP-27-Hinweis) meldet überhöhtes `remaining`.
|
||||
- **Produktion:** Das Chargen-Dropdown (`batchOptionsForEntries`) bietet entsorgte Chargen an; `expandBatchLines()` verteilt FEFO gegen nicht mehr vorhandenes Material → Rohstoffbestand wird real negativ.
|
||||
|
||||
**Szenario:** Eingang 10.000 g Charge X → Ausschuss 10.000 g mit `stock_entry_id = X`. Index: 0 g (korrekt). Detailseite: „10.000 g, verfügbar, Status ok"; Produktion kann die Charge weiter verbuchen.
|
||||
|
||||
**Fix:** In `consumedByStockEntry()` zusätzlich `StockDisposal`-Mengen je `stock_entry_id` summieren (eine gemeinsame „Netto-Verbrauch je Eintrag"-Methode für Produktion + Ausschuss). Disposals **ohne** Chargenbezug entweder FEFO auf die Chargen verteilen oder mindestens den Kopfwert der Detailseite aus `remainingByIngredient()` speisen. Regressionstest: Charge entsorgen → verschwindet aus Dropdown/Detail/batch-check.
|
||||
|
||||
### H-2 · Negative `quantity_used` passiert alle Prüfungen → Rohstoffbestand per Request aufblähbar
|
||||
|
||||
**Ort:** `app/Http/Requests/Inventory/StoreProductionRequest.php:33`; `app/Services/ProductionService.php:233-262` (`assertLinesMatchRecipe`), `592-604` (`parseQuantity`)
|
||||
|
||||
```php
|
||||
'ingredient_lines.*.quantity_used' => ['required', 'string'], // keine numeric/min-Regel
|
||||
```
|
||||
|
||||
**Problem:** `parseQuantity('-50')` liefert −50.0. Die Rezeptur-Prüfung vergleicht nur die **Summe** je INCI (±0,02 g). Zeilen im Alt-Format (`stock_entry_id`) reicht `expandBatchLines()` ungeprüft durch (`ProductionService.php:464-468`).
|
||||
|
||||
**Szenario:** Soll für INCI 5 = 100 g. POST mit Zeilen `{A: +150}` und `{B: −50}` → Summe 100, alle Prüfungen bestehen. Eintrag B erhält −50 g „Verbrauch", sein Restbestand **steigt** dauerhaft um 50 g aus dem Nichts. Kein Test deckt negative Mengen ab.
|
||||
|
||||
**Fix:** Vorzeichen-Validierung im FormRequest (deutsches Zahlformat, > 0) **und** Guard im Service vor `productionIngredients()->create()`.
|
||||
|
||||
### H-3 · `expandBatchLines()`: dieselbe Charge in zwei Zeilen verbraucht dieselbe Restmenge doppelt
|
||||
|
||||
**Ort:** `app/Services/ProductionService.php:458-507`
|
||||
|
||||
**Problem:** Die verfügbaren Einträge werden je Rohstoff gecacht (`$entriesByIngredient[$ingredientId] ??= …`, Z. 472), aber `remaining_quantity` wird nach der Verteilung einer Zeile **nicht dekrementiert**. Jede weitere Zeile mit derselben `batch_key` rechnet gegen die volle, unverbrauchte Kapazität.
|
||||
|
||||
**Szenario (per normaler UI erreichbar, „Weitere Charge"-Button):** Charge `b:X` = Einkauf A (Rest 100 g) + B (Rest 200 g). Zwei Zeilen à 100 g auf dieselbe Charge (Summe = Soll → Validierung ok) → beide Zeilen buchen auf A: A −100 g überzogen, B unberührt. FEFO und Einzelchargen-Bestand falsch, obwohl die Gesamtsumme stimmt. `StockEntryChargeMergeTest` testet nur eine Zeile pro Charge.
|
||||
|
||||
**Fix:** Nach jeder Zeile die Restmengen im Cache reduzieren, oder gleiche `batch_key` je Rohstoff vor der Auflösung zu einer Zeile aggregieren.
|
||||
|
||||
### H-4 · Rezeptur-Neuaufbau ohne Transaktion + ohne Validierung → Totalverlust der Hersteller-Rezeptur möglich
|
||||
|
||||
**Ort:** `app/Repositories/ProductRepository.php:249-290` (`updateManufacturerIngredients`); Aufrufer `ProductController` (validiert im Produkt-Save nur `name`/Set-Felder, keine `mfg_*`-Regeln)
|
||||
|
||||
**Problem:** Delete-then-Insert (Z. 255-260) ohne `DB::transaction()`. Wirft ein späteres `create()` eine `QueryException`, sind Rezeptur und Phasen bereits gelöscht. Zwei reale Trigger:
|
||||
1. `recipe_phases.name` ist `varchar(100)` (Migration `2026_07_06_134518:22`), `mfg_phase_name` hat keine `max`-Regel → Phasenname > 100 Zeichen ⇒ Exception im strict mode **nach** dem Delete.
|
||||
2. `mfg_ingredient_id`, das zwischen Formular-Laden und Speichern gelöscht wurde (oder manipuliert ist), passiert den `> 0`-Check (Z. 275) und scheitert am FK.
|
||||
|
||||
**Fix:** Ablauf in `DB::transaction()` wrappen; `mfg_phase_name.* => max:100` und `mfg_ingredient_id.* => exists:ingredients,id` validieren.
|
||||
|
||||
### H-5 · Rezeptur-Phasen gehen verloren, wenn keine Rohstoffzeile gesendet wird
|
||||
|
||||
**Ort:** `app/Repositories/ProductRepository.php:255-266`
|
||||
|
||||
**Problem:** Das Löschen der Phasen (Z. 258-260) läuft **vor** dem Early-Return bei fehlendem `mfg_ingredient_id[]` (Z. 262-264); `persistRecipePhases()` wird erst danach aufgerufen (Z. 266).
|
||||
|
||||
**Szenario:** (a) Nutzer legt erst die Phasenstruktur mit Namen/Notizen an und speichert, bevor er Rohstoffe zuordnet ⇒ Phasen werden nie persistiert. (b) Nutzer entfernt alle Zeilen einer bestehenden Rezeptur und speichert ⇒ vorhandene Phasen inkl. Notizen kommentarlos gelöscht. `RecipePhaseTest` deckt nur Fälle **mit** Zeilen ab.
|
||||
|
||||
**Fix:** `persistRecipePhases($data)` vor den Early-Return ziehen (Phasen auch ohne Zeilen anlegen).
|
||||
|
||||
---
|
||||
|
||||
## 3. Befunde — MITTEL
|
||||
|
||||
### M-1 · Ausschuss-Buchung ohne Konsistenz- und Bestandsprüfung
|
||||
|
||||
**Ort:** `app/Http/Requests/Inventory/StoreStockDisposalRequest.php:24-28`; `StockDisposalController::store():66-76`
|
||||
|
||||
Serverseitig wird weder geprüft, dass `stock_entry_id` zum gewählten `ingredient_id` gehört (`entry_type='ingredient'`, `status='received'`), noch dass `location_id` zur Charge passt, noch dass die Menge ≤ Restbestand ist. Manipulierter POST: Ausschuss von Rohstoff A mit der Charge von Rohstoff B und `quantity=999999` → Bestand von A beliebig negativ, Liste zeigt die falsche Chargennummer. **Fix:** `Rule::exists('stock_entries','id')->where(...)` + Restbestandsprüfung in `withValidator()`.
|
||||
|
||||
### M-2 · Berechtigungs-Gefälle: `copyreader` (admin ≥ 1) bucht über Produktionen beliebigen Produktbestand
|
||||
|
||||
**Ort:** `routes/web.php:310-332` (Produktions-Routen in der `copyreader`-Gruppe); `StoreProductionRequest::authorize():10-13` (`return true`) vs. `StoreProductStockMovementRequest::authorize()` (`isAdmin()`, admin ≥ 7)
|
||||
|
||||
Manuelle Bestandsbewegungen sind bewusst auf admin ≥ 7 beschränkt (Test „nicht-admin darf keine bewegung buchen"). Eine Produktion mit `no_recipe_required`-Produkt bucht denselben Bestand ohne Rohstoffnachweis — und steht jedem admin ≥ 1 offen, inkl. `update` (beliebige Korrekturen rauf/runter). Die Admin-Schranke ist damit umgehbar. **Fix:** `authorize()` auf `isAdmin()` bzw. Routen in die `admin`-Gruppe (Fachbereich abstimmen — hängt mit dem nachgelagerten AP-15 „Blockrechte" zusammen).
|
||||
|
||||
### M-3 · Tote Formulare: Disposal-/Movement-POST liegt auf `copyreader`-Routen, Request verlangt aber admin ≥ 7
|
||||
|
||||
**Ort:** `routes/web.php:313/324` vs. `StoreStockDisposalRequest.php:12-15`, `StoreProductStockMovementRequest.php:9-12`
|
||||
|
||||
Nutzer mit admin 1–6 sehen die Formulare (Ausschuss erfassen, Bewegung buchen), jeder POST endet in 403. Gegensätzliche Autorisierungs-Muster im selben Modul (alle anderen Inventory-Requests: `authorize() = true` + Middleware). **Fix:** Stufen vereinheitlichen; UI-Elemente für nicht berechtigte Stufen ausblenden.
|
||||
|
||||
### M-4 · Settings-`destroy()` ohne Referenzprüfung: 500er (RESTRICT) oder stiller Historienverlust (CASCADE/`nullOnDelete`)
|
||||
|
||||
**Ort:** `app/Http/Controllers/Admin/Inventory/SettingCrudController.php:101-108`
|
||||
|
||||
- **Location:** `stock_entries.location_id`/`productions.location_id` = RESTRICT → Löschen einer verwendeten Location wirft eine **unbehandelte `QueryException` → 500** statt Fehlermeldung. `stock_disposals.location_id` = **`cascadeOnDelete`** (Migration `2026_06_03_124056:32`) → eine nur in Entsorgungen referenzierte Location löscht beim Entfernen **still die komplette Ausschuss-Historie**; der Rohstoffbestand steigt rückwirkend ohne Buchung.
|
||||
- **PackagingMaterial:** RESTRICT auf `packaging_items` → 500er.
|
||||
- **TaxRate:** `nullOnDelete` auf `stock_entries.tax_rate_id` → historische Einkäufe verlieren still den Steuersatz-Bezug (Brutto/Netto-Nachvollziehbarkeit; `tax_rate_percent`-Snapshot bleibt immerhin erhalten).
|
||||
|
||||
**Fix:** `deletionBlockers()`-Hook im `SettingCrudController` (Referenz-Count → Flash-Fehler, Muster: `IngredientController::delete`); `stock_disposals.location_id` auf RESTRICT/`nullOnDelete` migrieren; „Deaktivieren statt Löschen" empfehlen (`active`-Flag existiert überall).
|
||||
|
||||
### M-5 · Standort-/Gesamt-Aggregation iteriert nur über Eingangs-Keys — Buchungen ohne Eingang fallen aus der Rechnung
|
||||
|
||||
**Ort:** `app/Services/InventoryService.php:55-60` (`remainingByIngredient`), `116-121` (`remainingByLocationForIngredient`)
|
||||
|
||||
Iteriert nur über Keys mit `received`-Einträgen. Ein Ausschuss an Standort B (Formular erlaubt jede aktive Location), wenn nur an A Ware einging, verschwindet aus der Standortansicht (A zeigt vollen Bestand, B fehlt) — Summe der Standorte ≠ Gesamtbestand. Zudem zwei Bezugssysteme in einer Rechnung: Verbrauch wird der **Chargen**-Location zugerechnet (Join Z. 102-107), Ausschuss der **Disposal**-Location. Rohstoffe mit Disposals aber ohne Eingang fehlen ganz; Aufrufer maskieren das mit `?? 0.0` (`RawMaterialStockController:43`) — negativer Bestand wird als 0 angezeigt. **Fix:** Über die Vereinigungsmenge aller Keys iterieren; bei Disposals mit `stock_entry_id` die Chargen-Location verwenden.
|
||||
|
||||
### M-6 · Zustandsändernde GET-Routen (kein CSRF-Schutz), z. T. destruktiv
|
||||
|
||||
**Ort:**
|
||||
- `routes/web.php:339` — `GET /admin/product/delete/{id}/…` **löscht** Produkte/Zutaten (`ProductController::delete`)
|
||||
- `routes/web.php:355` — `GET /admin/product/ingredient/delete/{id}` (`IngredientController::delete:90-103`)
|
||||
- `routes/web.php:325` — `GET stock-entries/{id}/copy` legt einen Datensatz an (`StockEntryController::copy():135-164`; nur additiv → niedrig)
|
||||
- `routes/web.php:342-343` — `GET image/delete/…`, `image/attribute/…`
|
||||
|
||||
GET ist von Laravels CSRF-Middleware ausgenommen: Ein eingeloggter Admin, der eine präparierte Seite besucht (`<img src="…/admin/product/delete/5">`), löscht ungewollt Daten; auch Prefetcher/Linkscanner können auslösen. Die **neuen** WaWi-Formulare machen es durchweg richtig (POST/DELETE + `@csrf`). **Fix:** Mindestens die destruktiven Legacy-GETs auf POST/DELETE mit Token umstellen.
|
||||
|
||||
**Zusatz zu `IngredientController::delete:90-103`:** Geprüft wird nur die Rezeptur-Verwendung (`ProductIngredient`). `production_ingredients.ingredient_id` ist RESTRICT (Migration `2026_03_27_134200:31`) → Rohstoff mit Produktions-Historie ⇒ unbehandelte `QueryException`/500; `stock_entries`/`stock_disposals` sind `nullOnDelete` ⇒ Bestands-Historie verliert still den Rohstoffbezug.
|
||||
|
||||
### M-7 · `ProductController::imageAttribute`: beliebiger Spalten-Write per URL (Mass-Assignment-Umgehung)
|
||||
|
||||
**Ort:** `app/Http/Controllers/ProductController.php:223-239`; Route `routes/web.php:343` (`copyreader`, admin ≥ 1)
|
||||
|
||||
```php
|
||||
$product_image = ProductImage::findOrFail($product_id);
|
||||
$product_image->{$attr} = $val; // $attr ungefiltert aus der URL
|
||||
$product_image->save();
|
||||
```
|
||||
|
||||
`$attr` kommt ungefiltert aus der URL und umgeht per dynamischer Property die `$fillable`-Whitelist — admin ≥ 1 kann auf beliebigen `product_images`-Zeilen beliebige Spalten setzen (`product_id`, `filename`, `active`, …). Zusätzlich GET (M-6). **Fix:** Whitelist (`in_array($attr, ['active','pos'], true)`) + Wert-Validierung, sonst `abort(400)`.
|
||||
|
||||
### M-8 · Doppelter Rohstoff in der Rezeptur: Soll-Menge wird überschrieben statt summiert
|
||||
|
||||
**Ort:** `app/Services/ProductionService.php:282-297` (`requiredGramsByIngredient`: `$required[$id] = …`); `ProductRepository::updateManufacturerIngredients():273-287` (persistiert Duplikate anstandslos per `create()`)
|
||||
|
||||
Das Frontend verhindert Duplikate (Modal deaktiviert bereits verwendete INCIs, `edit.blade.php:447-459`) — serverseitig existiert **kein** Schutz. Kommt derselbe Rohstoff zweimal vor (manipulierter POST, JS-Fehler, künftige Importe), rechnet die Produktion nur mit der **letzten** Zeile statt der Summe; `assertLinesMatchRecipe` validiert dann gegen das falsche Soll. **Fix:** Beim Persistieren Duplikate je (`recipe_type`, `ingredient_id`, ggf. Phase) ablehnen oder in `requiredGramsByIngredient` `+=` verwenden.
|
||||
|
||||
### M-9 · Kein Locking bei paralleler Buchung — Differenz-Abgleich und FEFO können divergieren
|
||||
|
||||
**Ort:** `app/Services/ProductStockService.php:82-128`; `app/Services/ProductionService.php:90-149`
|
||||
|
||||
Der Differenz-Abgleich liest den gebuchten Stand ohne `lockForUpdate()`; zwei parallele `update`-Requests derselben Produktion lesen denselben Snapshot (REPEATABLE READ) und schreiben je ein Delta → Movements und `production_outputs` divergieren dauerhaft (z. B. Outputs 60, Bestand 50). Analog vergeben zwei parallele Produktionen denselben Chargen-Rest doppelt. Im Ein-Personen-Admin-Betrieb unwahrscheinlich, aber real (Doppelklick/zwei Tabs). **Fix:** `lockForUpdate()` auf die `Production`-Zeile zu Transaktionsbeginn.
|
||||
|
||||
### M-10 · URL-Felder ohne Schema-Validierung → gespeicherte `javascript:`-Links in Admin-Views
|
||||
|
||||
**Ort:** `StoreSupplierRequest.php:21,24`, `StorePackagingItemRequest.php:27`, `StoreIngredientRequest` (Pivot-URLs); gerendert als `<a href="{{ … }}">` in `suppliers/_details.blade.php:29,47`, `packaging-items/index.blade.php:45`, `raw-material-stock/show.blade.php:164-165` („Zum Shop")
|
||||
|
||||
Ein admin ≥ 7 kann `javascript:…` hinterlegen, das beim Klick im Superadmin-Kontext ausgeführt wird (Privilegien-Eskalation innerhalb des Adminbereichs). **Fix:** Regel `['nullable', 'url:http,https', 'max:2048']` — die im `InventoryUrlFieldsTest` geforderten Konfigurator-URLs bleiben gültig.
|
||||
|
||||
---
|
||||
|
||||
## 4. Befunde — NIEDRIG
|
||||
|
||||
| # | Befund | Ort | Anmerkung |
|
||||
|---|---|---|---|
|
||||
| N-1 | Chargen-Merge gruppiert nur nach `batch_number` — gleiche Lot-Nr. zweier Lieferanten verschmilzt (min-MHD über beide) | `app/Models/StockEntry.php:132-137`; `InventoryService::chargeGroupsForIngredient():333` | ggf. `supplier_id` in den Schlüssel oder Kundenentscheid dokumentieren |
|
||||
| N-2 | „Letzter Einkauf (Netto/kg)" zählt auch offene, nie gelieferte Bestellungen | `StockEntryRepository::lastNetPricePerSupplier():130-135` (kein `status`-Filter) | `->where('status','received')` falls nur echte Eingänge zählen sollen |
|
||||
| N-3 | Verbrauchsfenster off-by-one: bis ~91 Tage durch 90 geteilt (Tagesverbrauch ~1 % überschätzt) | `InventoryService.php:215,227` (`subDays()->startOfDay()`) | durch tatsächliche Fenstertage teilen |
|
||||
| N-4 | Kritisch-Zählung dreifach uneinheitlich: Controller-Wert (ohne `critical_ordered`) wird von der View überschrieben (toter Code), Badge zählt wieder anders | `RawMaterialStockController:62` vs. `raw-material-stock/index.blade.php:5` vs. `InventoryService::criticalIngredientCount():378-386` | eine Zählweise festlegen, tote Zeile entfernen |
|
||||
| N-5 | Ausschuss-Liste hart auf 500 gedeckelt — ohne Hinweis/`?all=1` (anders als OPT-3 bei stock-entries) | `StockDisposalController::index():36` | Hinweis + „Alle anzeigen" analog OPT-3 |
|
||||
| N-6 | `reFormatNumber('1.5')` → 15 (Punkt = Tausendertrenner): stiller 10×-Fehler bei Preis-/Mengeneingaben | `app/Services/Util.php:52-59` | Eingaben mit genau einem Punkt ohne Komma als Dezimal akzeptieren oder ablehnen |
|
||||
| N-7 | Server/JS-Parsing divergiert: `parseQuantity('1.500')` = 1,5 g (is_numeric-Shortcut), UI-Ampel rechnet 1500 g | `ProductionService.php:592-604` vs. `productions/_scripts.blade.php` (`parseGerman`) | deutsche Notation vor `is_numeric` behandeln |
|
||||
| N-8 | Produktions-Kopie übernimmt alte Chargen-Nummern der Outputs (Datum = heute, Charge = alt → Duplikat-Charge) | `productions/_form_fields.blade.php:6-15` i. V. m. `_scripts.blade.php:391-393` | beim Copy `batch_number` leeren → Auto-Vorschlag greift |
|
||||
| N-9 | Soft-gelöschte Output-Produkte: Differenz-Gegenbuchung wird still übersprungen (Bestand bleibt auf gelöschtem Produkt stehen) | `ProductStockService.php:108,113` (kein `withTrashed()`) | `withTrashed()` beim Nachladen für Gegenbuchungen |
|
||||
| N-10 | Manuelle Bewegung kann Produktbestand unter 0 drücken (nur `min:1`) | `StoreProductStockMovementRequest.php:19-24` | falls gewollt (Inventur): aktuellen Bestand im Formular anzeigen |
|
||||
| N-11 | Set-Umstellung löscht Rezepturzeilen, aber nicht die Phasen → Orphan-Phasen tauchen nach Rück-Umstellung wieder auf | `ProductRepository.php:108-112` | Phasen mitlöschen |
|
||||
| N-12 | Auto-Phasenname bricht nach „Z" (`chr(65+$index)` → `Phase [`) | `ProductRepository.php:320` | kosmetisch; `AA`-Schema oder Kappung |
|
||||
| N-13 | Manipulierte `mfg_row_phase`-Indizes fallen still auf „phasenlos" | `ProductRepository.php:285` | Fehler werfen oder Verhalten dokumentieren |
|
||||
| N-14 | Toter, destruktiver Legacy-Zweig: `updateIngredients` ohne Sync-Flag legt Zeilen mit `gram = null` neu an | `ProductRepository.php:190-213` | Zweig entfernen |
|
||||
| N-15 | Markdown-Rendering (Hinweise/Leitfaden) mit `html_input = ALLOW` + `{!! !!}` — Quelle sind Repo-Dateien, daher nur Defense-in-Depth | `NoticeController.php:40`, `notices/index.blade.php` | `Str::markdown($md, ['html_input' => 'strip', 'allow_unsafe_links' => false])` |
|
||||
| N-16 | Debug-Reste: voller Request-Payload wird bei jedem Speichern geloggt; Performance-Kleinigkeiten: `buildRecipePayload` 2 Queries/INCI, `assertStockEntryMatchesLine`/`computeMhdWarning` laden denselben Eintrag doppelt je Zeile | `PackagingItemController.php:61-66, 93-98`; `ProductionService.php:528, 304, 330` | `\Log::debug`-Blöcke **entfernen**; `whereIn`-Bündelung |
|
||||
|
||||
**Sammelbefund Duplikate (Wartbarkeit, niedrig):** `StoreMaterialQualityRequest`/`StoreSupplierCategoryRequest`/`StorePackagingMaterialRequest` sind zeilengleich; Feldbenennung `name` vs. `label` und `max:100` vs. `max:255` uneinheitlich über die Settings-CRUDs. Ein gemeinsamer Basis-Request würde Drift verhindern (Fortsetzung von OPT-2).
|
||||
|
||||
---
|
||||
|
||||
## 5. Explizit sauber geprüfte Bereiche
|
||||
|
||||
- **SQL-Injection: keine.** Alle `selectRaw`/`orderByRaw`/`whereRaw` (InventoryService, ProductionService, ProductStockService, StockEntryRepository) sind konstante Strings; Nutzereingaben laufen über Bindings (auch die `like`-Suchen in `StockEntryController::searchIngredients/searchPackagingItems`).
|
||||
- **XSS in den WaWi-Blades: keine.** Durchgängig `{{ }}`; `{!! $statusPill !!}` ist controller-berechneter Enum-String; Notizen via `nl2br(e(…))`; JS-DOM-Aufbau via `.text()`/`@json`. (Einzige Ausnahme: URL-Schema, M-10.)
|
||||
- **Mass Assignment:** explizite `$fillable`-Listen; `status`/`ordered_by`/`received_by`/`user_id`/`produced_by` werden serverseitig gesetzt, nie aus dem Payload. (Ausnahme: Legacy `imageAttribute`, M-7.)
|
||||
- **Middleware-Zuordnung** entspricht dem Plan: Stammdaten `superadmin`, Lieferanten/Verpackung `admin`, operative Bewegungen `copyreader` mit Controller-`isAdmin()`-Gates für Schreibaktionen (gewollt und getestet, z. B. `receive` durch copyreader, `InventoryPhase3Test`). Offene Punkte nur M-2/M-3.
|
||||
- **Buchungslogik:** `recordProductionStock()` seriell korrekt idempotent (Soll/Ist-Differenz, append-only, Rückführung entfernter Outputs auf 0 — `ProductionOutputTest` deckt das ab); `store()`/`updateProduction()` vollständig in `DB::transaction`; FEFO-Sortierung (`best_before is null, best_before asc, id`) korrekt; `exclude_production` beim Edit konsequent; unbekannte `batch_keys` ⇒ ValidationException; Sets als Planung **und** je Output blockiert; IDOR über fremde `stock_entry_id` nicht möglich (`assertStockEntryMatchesLine` prüft Rohstoff/Standort/Status).
|
||||
- **`SettingCrudController` (OPT-2):** Container-Auflösung der FormRequests führt `authorize()` + Rules nachweislich aus (`FormRequestServiceProvider`, `afterResolving(ValidatesWhenResolved)`); `routeModel()` kann keine fremden Models laden. Verbleibende Lücke nur `destroy()` (M-4).
|
||||
- **Migrationen:** FK-Typen konsistent zur historischen `products.id` (`unsignedInteger`); `production_outputs`-Backfill idempotent (`WHERE NOT EXISTS`); Composite-Indizes decken die Aggregations-Hotpaths exakt ab.
|
||||
- **`batchCheck`-Endpoint:** rein lesend, Eingaben normalisiert, Bindings, hinter `copyreader` — sauber.
|
||||
|
||||
---
|
||||
|
||||
> **Update 15.07.2026 (nach Auslieferung AP-31, Commit `35b9076`):** Durch das Lösch-Paket sind bereits behoben: **M-4** (Settings-`destroy()` prüft jetzt Abhängigkeiten über `DeletionGuard` — kein 500er/stiller Cascade-Verlust mehr), **M-6 teilweise** (INCI-Löschung GET→DELETE mit CSRF; `stock-entries/copy` und die Produkt-Legacy-GETs bleiben offen), **N-16 teilweise** (Debug-Logs entfernt). Außerdem behoben: doppeltes `@yield('scripts')` in `layout-2` (Commit `d8d4a9b`), das alle Seiten-Skripte doppelt band. Die übrigen Befunde (insb. H-1…H-5) sind weiterhin offen.
|
||||
|
||||
## 6. Empfohlene Fix-Roadmap (Vorschlag: neuer Block AP-30 / OPT-6…OPT-9)
|
||||
|
||||
| Prio | Paket | Inhalt | Aufwand |
|
||||
|---|---|---|---|
|
||||
| 1 | **OPT-6 „Bestandswahrheit"** | H-1 (Disposals in Chargen-Restmengen) + M-5 (Key-Vereinigung in Aggregationen) + Regressionstests | 1–1,5 Tage |
|
||||
| 2 | **OPT-7 „Buchungs-Härtung"** | H-2 (Vorzeichen), H-3 (Batch-Dekrement), M-1 (Disposal-Konsistenzregeln), M-9 (`lockForUpdate`) | 1 Tag |
|
||||
| 3 | **OPT-8 „Rezeptur-Robustheit"** | H-4 (Transaktion + Validierung), H-5 (Phasen ohne Zeilen), M-8 (Duplikat-Schutz), N-11/N-13/N-14 | 0,5–1 Tag |
|
||||
| 4 | **OPT-9 „AuthZ/CSRF-Aufräumen"** | M-2/M-3 (Stufen vereinheitlichen — mit Kunde abstimmen, hängt an AP-15), M-6/M-7 (GET-Routen, `imageAttribute`), M-10 (URL-Schema), Debug-Logs (N-16) | 1 Tag |
|
||||
| 5 | **Lückenfüller** | M-4 (`deletionBlockers` + Disposal-FK-Migration), N-1…N-10 nach Gelegenheit | 1–1,5 Tage |
|
||||
|
||||
**Vor AP-13 (Shop-Bestandsabzug) sollten mindestens OPT-6 und OPT-7 umgesetzt sein** — AP-13 bucht gegen dieselben Bestandsdaten; jede der o. g. Inkonsistenzen würde sich sonst in die Verkaufs-Abgänge fortpflanzen.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testabdeckungs-Lücken (aus den Befunden abgeleitet)
|
||||
|
||||
- Chargen-Restmenge nach Ausschuss (H-1) — bisher kombiniert kein einziger Test Disposal + Charge.
|
||||
- Negative/manipulierte `quantity_used` und Duplikat-Zeilen gleicher Charge (H-2, H-3).
|
||||
- Rezeptur-Speichern ohne Zeilen bzw. mit überlangen Phasennamen (H-4, H-5).
|
||||
- `destroy()` verwendeter Stammdaten (M-4) — aktuell nur der Happy Path getestet.
|
||||
- Parallelität ist per Pest schwer testbar — hier genügt der `lockForUpdate()`-Fix mit Code-Review.
|
||||
|
|
@ -160,6 +160,8 @@ Die fachlichen Detailspezifikationen zu **AP-20, AP-21, AP-23, AP-24, AP-27, AP-
|
|||
| 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** |
|
||||
| 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** |
|
||||
| 15.07.2026 | **Bugfix Layout** | Doppeltes `@yield('scripts')` in `layout-2.blade.php` entfernt (seit `9ce711d` rendert das Eltern-Layout `application.blade.php:145` die Section zusätzlich) — alle Seiten-Skripte liefen doppelt, sichtbar u. a. als „Weitere Charge" legt 2 Zeilen an (Kundenmeldung 15.07.). Commit `d8d4a9b`. | Produktions-/Chargen-Tests grün |
|
||||
| 15.07.2026 | **AP-31 (Löschen: Soft-Delete + Abhängigkeitsprüfung)** | Kundenanforderung 15.07.: nahezu alle WaWi-Einträge löschbar. **Migration** `2026_07_15_100000` (deleted_at auf 14 Tabellen) + SoftDeletes-Trait in den Models. **Neuer `App\Services\DeletionGuard`**: zentrale Abhängigkeitsprüfung — referenzierte Einträge nicht löschbar, Fehlermeldung nennt die Verwendungen (Stammdaten: TaxRate/MaterialQuality/Location/SupplierCategory/PackagingMaterial/StorageSegmente; Lieferanten/Verpackungsartikel/INCI; Wareneingänge). **Wareneingänge**: auch gebuchte löschbar ohne Verbrauch/Ausschuss. **Ausschuss**: löschbar, Menge wieder verfügbar. **Produktionen**: löschbar (admin ≥ 7), Produktbestand per Gegenbuchung zurück (`reverseProductionStock`), Verbrauchs-Queries schließen gelöschte Produktionen aus. Bestätigungswarnung vor jeder Löschung (deutsche Confirm-Texte). INCI-Löschroute GET→DELETE. Nebenbei: Debug-Logs PackagingItemController entfernt, `lastNetPricePerSupplier`-Global-Scope-Fix. Behebt Audit-Befunde M-4, M-6 (teilw.), N-16 (teilw.). Commit `35b9076`. | Neuer `InventoryDeletionTest` (10 grün), `StorageSettingsTest` angepasst. Volle Suite: **214 grün, 783 Assertions** |
|
||||
| _offen_ | AP-13 | Shop-Bestandsabzug | – |
|
||||
| _offen_ | AP-14/15/16/17 | Audit-Trail, Blockrechte, 2FA, WaWi-Einstellungen | – |
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue