Bots klopfen /oauth/authorize ohne gültige OAuth-Parameter ab; Passport wirft dann korrekt einen 4xx-Fehler. Da der Handler bei jeder gemeldeten Exception mailt und die API ausschliesslich Personal Access Tokens nutzt (Authorize-Flow nie legitim genutzt), war jede dieser Mails Rauschen. OAuthServerException wandert in $dontReport – Endpoint-Verhalten bleibt unveraendert, nur der Mail-Versand fuer diese Client-Fehler entfaellt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
28 KiB
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), Commits65f949d…7198b66Methodik: 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 nurproduction_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öhtesremaining.- 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:
recipe_phases.nameistvarchar(100)(Migration2026_07_06_134518:22),mfg_phase_namehat keinemax-Regel → Phasenname > 100 Zeichen ⇒ Exception im strict mode nach dem Delete.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 unbehandelteQueryException→ 500 statt Fehlermeldung.stock_disposals.location_id=cascadeOnDelete(Migration2026_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:
nullOnDeleteaufstock_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}/copylegt 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.
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 dielike-Suchen inStockEntryController::searchIngredients/searchPackagingItems). - XSS in den WaWi-Blades: keine. Durchgängig
{{ }};{!! $statusPill !!}ist controller-berechneter Enum-String; Notizen vianl2br(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_bywerden serverseitig gesetzt, nie aus dem Payload. (Ausnahme: LegacyimageAttribute, M-7.) - Middleware-Zuordnung entspricht dem Plan: Stammdaten
superadmin, Lieferanten/Verpackungadmin, operative Bewegungencopyreadermit Controller-isAdmin()-Gates für Schreibaktionen (gewollt und getestet, z. B.receivedurch 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 —ProductionOutputTestdeckt das ab);store()/updateProduction()vollständig inDB::transaction; FEFO-Sortierung (best_before is null, best_before asc, id) korrekt;exclude_productionbeim Edit konsequent; unbekanntebatch_keys⇒ ValidationException; Sets als Planung und je Output blockiert; IDOR über fremdestock_entry_idnicht möglich (assertStockEntryMatchesLineprüft Rohstoff/Standort/Status). SettingCrudController(OPT-2): Container-Auflösung der FormRequests führtauthorize()+ Rules nachweislich aus (FormRequestServiceProvider,afterResolving(ValidatesWhenResolved));routeModel()kann keine fremden Models laden. Verbleibende Lücke nurdestroy()(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, hintercopyreader— 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 überDeletionGuard— kein 500er/stiller Cascade-Verlust mehr), M-6 teilweise (INCI-Löschung GET→DELETE mit CSRF;stock-entries/copyund die Produkt-Legacy-GETs bleiben offen), N-16 teilweise (Debug-Logs entfernt). Außerdem behoben: doppeltes@yield('scripts')inlayout-2(Commitd8d4a9b), das alle Seiten-Skripte doppelt band. Die übrigen Befunde (insb. H-1…H-5) sind weiterhin offen.Update 15.07.2026 abends — Fix-Roadmap umgesetzt: Alle Hoch- und Mittel-Befunde sind behoben und einzeln committet: OPT-6
8445038(H-1, M-5), OPT-7c69721d(H-2, H-3, M-1, M-9), OPT-8fa44af0(H-4, H-5, M-8, N-11/12/14 — N-14 als nicht-destruktiver Erhalt statt Entfernung, der Zweig war durchProductPhase0Testabgedeckt), OPT-99ed47c0(M-2, M-3, M-6, M-7, M-10), Niedrig-Paketd34f0a7(N-2/3/4/5/7/8/9/15). Suite nach Abschluss: 231 Tests grün, 823 Assertions.Update 15.07.2026 — Kundenentscheide umgesetzt: N-1 behoben (
9b6c9de): Chargennummern kommen von extern und können kollidieren → Lieferant gehört zum Gruppenschlüssel (b:<supplier>:<lot>), Batch-Check prüft den Lieferanten mit. N-6 behoben (016e8bb): Kundenentscheid — ein einzelner Punkt mit 1–2 Nachkommastellen gilt in WaWi-Eingaben als Dezimaltrenner („5.1" = 5,1, „1.500" bleibt Tausender); neuer HelperreFormatWawiNumber(), JS-Parser vereinheitlicht (parseDeNumberlas „1.500" bisher sogar als 1,5);reFormatNumber()app-weit unverändert. N-10 entschärft (6af6760): Bewegungs-Modal zeigt den aktuellen Bestand und fragt bei Ausgängen über Bestand per Bestätigung nach (Inventur-Korrekturen bleiben möglich). Suite: 233 grün, 832 Assertions.Verbleibend offen (bewusstes Design, kein Defekt): N-13 (ungültige
mfg_row_phase-Indizes fallen still auf „phasenlos" — rein kosmetisch, per Drag&Drop korrigierbar), N-16-Rest (Query-BündelungbuildRecipePayload/computeMhdWarning— Perf-Politur, sinnvoll erst mit AP-13). M-6-Rest: Die dreiimage/attribute-GET-Routen (Product/Category/Sites) bleiben GET, sind durch das M-7-Whitelisting entschärft — Umstellung auf POST am besten mit AP-14 (Audit-Trail).
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_usedund 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.