gruene-seele/dev/product management /audit-bericht-15-07-2026.md
2026-07-15 12:33:15 +00:00

26 KiB
Raw Blame History

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)

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

$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.

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.