# PGS 15:2025 Compliance Scanner — Audit Rapport **Versie**: 1.0 **Datum**: 27 april 2026 **Auditor**: Kas (Director of Operations) **Scope**: Volledige audit van app.py (4421 regels), BEORDELINGSAANPAK.md, templates, API endpoints **Referentienorm**: PGS 15:2025 Publicatie 565 --- ## 1. Samenvatting | Categorie | Status | Kritiek | |-----------|--------|---------| | Data Correctheid | ⚠️ CONDITIONAL | 3 kritieke bevindingen | | Berekeningslogica | ⚠️ CONDITIONAL | 2 bugs, 1 ontwerpkeuze | | API & Rapport | ✅ PASS | DOCX + HTML volledig | | UI/Frontend | ✅ PASS | Enkel template, functioneel | | CLP Routes | ⚠️ CONDITIONAL | 2 afwijkingen vs BEORDELINGSAANPAK | ### Overall: ⚠️ CONDITIONAL PASS De scanner is functioneel en produceert bruikbare rapporten. Er zijn echter kritieke data-afwijkingen ten opzichte van PGS 15:2025 die gecorrigeerd moeten worden voordat de tool juridisch betrouwbaar kan worden ingezet. --- ## 2. Data Correctheid ### 2.1 ONDERGRENS (regel ~41) **Structuur**: ```python ONDERGRENS = { '3': {'I': 1, 'II': 25, 'III': 50}, '4.1': {'I': 1, 'II': 50, 'III': 50}, '4.2': {'I': 1, 'II': 50, 'III': 50}, '4.3': {'I': 1, 'II': 50, 'III': 50}, '5.1': {'I': 1, 'II': 50, 'III': 50}, '6.1': {'I': 1, 'II': 50, 'III': 50}, '8': {'I': 1, 'II': 250, 'III': 250}, '9': {'I': 1, 'II': 250, 'III': 250}, } ``` **Bevindingen**: | Item | Verwacht (PGS 15 Tabel 5) | Werkelijk | Status | |------|---------------------------|-----------|--------| | ADR 3 VG I | 1 kg | 1 kg | ✅ | | ADR 3 VG II | 25 kg | 25 kg | ✅ | | ADR 3 VG III | 50 kg | 50 kg | ✅ | | ADR 4.1 VG I | 1 kg | 1 kg | ⚠️ Niet verifieerbaar — PGS 15:2025 kent mogelijk andere ondergrens voor 4.1 VG I | | ADR 8 VG II | 250 kg | 250 kg | ✅ | | ADR 8 VG III | 250 kg | 250 kg | ✅ | | ADR 5.1 VG I | Ontbreekt in NIVEAUS | — | 🔴 Zie 2.3 | | ADR 5.2 | Ontbreekt in ONDERGRENS | — | ⚠️ 5.2 heeft eigen ONDERGRENS=30 in NIVEAUS | **🔴 BEVINDING 1**: `ONDERGRENS` wordt NIET gebruikt in de `run_scan()` functie. De niveau-bepaling gaat uitsluitend via `determine_niveau()` die de ondergrens uit `NIVEAUS` haalt. De `ONDERGRENS` dict is dode code. ### 2.2 CLP_ROUTES (regel ~52) ```python CLP_ROUTES = { 'acute_tox_1': {'ondergrens': 1, 'C': 1000, 'B': None, 'A': 1000}, 'acute_tox_2_3': {'ondergrens': 1, 'C': 10000, 'B': None, 'A': 10000}, 'cmr_vast': {'ondergrens': 25, 'C': None, 'B': None, 'A': None}, 'cmr_vloeibaar': {'ondergrens': 1, 'C': None, 'B': None, 'A': None}, } ``` **Vergelijking met BEORDELINGSAANPAK.md §2.1**: | Route | BEORDELINGSAANPAK | CLP_ROUTES | Status | |-------|-------------------|------------|--------| | CMR 1A/1B vloeibaar | ondergrens 1 kg, C=1 kg | ondergrens=1, C=None | 🔴 | | CMR 1A/1B poedervorm | ondergrens 25 kg, C=25 kg | ondergrens=25, C=None | 🔴 | | CMR 2 vloeibaar | ondergrens 25 kg | ontbreekt geheel | 🔴 | | CMR 2 poedervorm | ondergrens 250 kg | ontbreekt geheel | 🔴 | | Acute Tox. Cat. 1 | ondergrens 1, C=1, A=1000 | ondergrens=1, C=1000, A=1000 | 🔴 | | Acute Tox. Cat. 2/3 | ondergrens 1, C=1, B=10000, A=20000 | ondergrens=1, C=10000, A=10000 | 🔴 | **🔴 BEVINDING 2 (KRITIEK)**: Er zijn significante discrepanties tussen CLP_ROUTES en BEORDELINGSAANPAK.md: 1. **CMR-routes**: In de code hebben CMR-stoffen `C=None, B=None, A=None` — dit betekent dat CMR-stoffen ALLEEN "Basis" niveau krijgen boven de ondergrens. Per BEORDELINGSAANPAK zouden CMR-stoffen wél niveau C moeten krijgen (C=ondergrens). **Dit betekent dat CMR-stoffen momenteel te laag worden geclassificeerd.** 2. **CMR Cat. 2 ontbreekt**: De code onderscheidt alleen "vast" vs "vloeibaar", niet CMR Cat. 1A/1B vs Cat. 2. Cat. 2 heeft hogere ondergrenzen (25 kg vloeibaar, 250 kg poedervorm) die genegeerd worden. 3. **Acute Tox. Cat. 1**: Code heeft C=1000, maar BEORDELINGSAANPAK zegt C=1. Dit betekent dat Acute Tox. Cat. 1 stoffen tussen 1-999 kg ten onrechte GEEN niveau C krijgen. 4. **Acute Tox. Cat. 2/3**: Code heeft B=None, maar BEORDELINGSAANPAK zegt B=10000. Code heeft A=10000, BEORDELINGSAANPAK zegt A=20000. Beide zijn afwijkend. 5. **Acute Tox. 2/3 ondergrens voor C**: Code: C=10000 (dus C bij ≥10000), BEORDELINGSAANPAK: C=1 (dus C bij ≥1). **Impact**: Dit zijn de meest kritieke fouten in de scanner. De CLP-route classificatie is niet betrouwbaar voor compliance-doeleinden. ### 2.3 NIVEAUS (regel ~66, Tabel 6) **Bevindingen**: | Key | Code | PGS 15:2025 | Status | |-----|------|-------------|--------| | `('3','I')` | (1, 1, None, 10000) | C vanaf 1kg, geen B, A >10ton | ✅ | | `('3','II')` | (25, 25, None, 10000) | C vanaf 25kg, geen B, A >10ton | ✅ | | `('3','III')` | (50, 50, None, 10000) | C vanaf 50kg, geen B, A >10ton | ✅ | | `('4.1','I')` | (1, None, 10000, 20000) | Geen C; B vanaf 1kg; A >20ton | ⚠️ Niet volledig verifieerbaar | | `('4.1','II')` | (50, None, 50, 20000) | Geen C; B vanaf ondergrens | ⚠️ B=50 = onmiddellijk B | | `('4.1','III')` | (50, None, 50, None) | Geen C, geen A | ✅ | | `('4.2','I')` | (50, None, 50, 20000) | Geen C | ⚠️ | | `('5.1','II')` | (50, 50, 2500, None) | C<2.5ton, B>2.5ton, geen A | ✅ | | `('5.1','III')` | (50, 50, 2500, None) | C<2.5ton, B>2.5ton, geen A | ✅ | | `('6.1','I')` | (1, 1, None, 1000) | C<1ton, A>1ton | ✅ | | `('6.1','II')` | (50, 50, None, 10000) | C<10ton, A>10ton | ✅ | | `('8','I')` | (250, 250, 10000, 10000) | FP-afhankelijk (zie func) | ✅ | | `('8','II')` | (250, 250, 10000, None) | Brandbaar: C<10t, B>10t | ✅ | | `('8','III')` | (250, 250, 10000, None) | Brandbaar: C<10t, B>10t | ✅ | **🔴 BEVINDING 3**: `('5.1','I')` ontbreekt in NIVEAUS. ADR 5.1 VG I stoffen hebben geen entry, dus `determine_niveau('5.1','I',x)` returned 'Geen ADR-klasse'. Per PGS 15:2025 zou 5.1 VG I wel degelijk een niveau moeten hebben. ### 2.4 DOELEN (regel ~121) Alle 4 niveaus (Basis, C, B, A) bevatten dezelfde 13 doelstellingen (D1-D13). Dit komt overeen met PGS 15:2025: alle doelstellingen gelden op alle niveaus, maar de MAATREGELEN per doelstelling verschillen per niveau. **Status**: ✅ CORRECT ### 2.5 MAATREGELEN_ALLE (regel ~208) 167 maatregelen (MW1 + M2 t/m M167). Dit is een zeer uitgebreide set. **Bevindingen**: - MW1 (Zorgplicht basisveiligheid) — correct apart van M1 range - M2 t/m M167 — volledig - M98 (Criteria beoordeling specifieke scenario's bedrijfsbrandweer) heeft een lege toelichting `''` — ⚠️ MINOR **Status**: ✅ PASS (met minor: M98 toelichting leeg) ### 2.6 DOELSTELLING_MAP (regel ~395) De mapping bevat 13 doelstellingen (D1-D13), elk met: - `titel`: naam - `omschrijving`: beschrijving - `scenario`: relevante scenario's - `maatregelen`: dict met keys als 'Basis', 'C', 'B', 'A', 'Binnen', 'Buiten', 'Container', 'Tijdelijk', en ADR-specifieke keys **Bevindingen**: - De mapping is uitgebreid en gedetailleerd - ADR-specifieke categorieën (bijv. `ADR_2_gasflessen`, `ADR_3`, `ADR_8`) zorgen voor context-afhankelijke maatregelen - ✅ De structuur is logisch en volledig ### 2.7 DOELSTELLING_PER_NIVEAU Alle 4 niveaus bevatten alle 13 doelstellingen. Consistent met DOELEN. **Status**: ✅ CORRECT --- ## 3. Berekeningslogica ### 3.1 determine_niveau() **Functionele tests**: | Input | Verwacht | Werkelijk | Status | |-------|----------|-----------|--------| | ADR 8 VG II, 100kg, onbrandbaar | Buiten scope (<250) | Buiten scope | ✅ | | ADR 8 VG II, 300kg, onbrandbaar | Basis | Basis | ✅ | | ADR 8 VG II, 300kg, brandbaar | C | C | ✅ | | ADR 8 VG II, 10000kg, brandbaar | B | B | ✅ | | ADR 3 VG I, 10kg | C (boven ondergrens 1) | C | ✅ | | ADR 3 VG II, 100kg | C (<10000) | C | ✅ | | ADR 3 VG II, 15000kg | A (>10000) | A | ✅ | | ADR 6.1 VG I, 500kg | C (<1000) | C | ✅ | | ADR 6.1 VG I, 2000kg | A (>1000) | A | ✅ | | ADR 5.1 VG II, 100kg | C (<2500) | C | ✅ | | ADR 5.1 VG II, 3000kg | B (>2500) | B | ✅ | | ADR 5.1 VG I, 100kg | "Geen ADR-klasse" | Geen ADR-klasse | 🔴 BUG | **🔴 BUG 1**: `determine_niveau('5.1','I',100)` returned 'Geen ADR-klasse' omdat key `('5.1','I')` ontbreekt in NIVEAUS. PGS 15:2025 moet een niveau hebben voor 5.1 VG I. **⚠️ ONTWERPKEUZE**: ADR 8 VG I gebruikt `flashpoint=None → default 0` (regel ~760). Dit betekent: als geen flashpoint bekend, wordt aangenomen dat FP ≤ 100°C (brandbaar). Dit is een conservatieve aanname die in de praktijk veilig is, maar zou expliciet gedocumenteerd moeten worden. ### 3.2 Aggregatie De aggregatie-logica in `run_scan()` (regel ~2290) werkt als volgt: 1. Stoffen per ADR-klasse groeperen 2. Totaal per klasse berekenen 3. Per klasse: niveau bepalen door te itereren over VG I, II, III 4. Zwaarste niveau van alle klassen = geaggregeerd niveau **🟡 BEVINDING 4**: De aggregatie loopt over **alle VGs** (I, II, III) per ADR-klasse en neemt het zwaarste niveau. Dit is mogelijk te streng: in de praktijk zouden alleen de VGs van de daadwerkelijk aanwezige stoffen moeten tellen. Bijvoorbeeld: als een locatie alleen ADR 8 VG II stoffen heeft, moet de aggregatie niet ook VG I en VG III controleren. **Testcase**: 2x ADR 8 VG II onbrandbaar (200kg + 100kg = 300kg) - Individueel: beide Buiten scope (<250) - Geaggregeerd via VG I: `determine_niveau('8','I',300)` → C (omdat FP=None → 0 → FP≤100) - Geaggregeerd niveau: **C** Dit betekent dat de aggregatie de stoffen een niveau C oplevert, terwijl individueel beide onder de ondergrens vallen. Per PGS 15:2025 is aggregatie per ADR-klasse verplicht, dus dit is correct gedrag — MAAR het feit dat VG I wordt meegenomen terwijl de stoffen VG II zijn, is discutabel. **Aanbeveling**: Aggregatie zou alleen de zwaarste daadwerkelijk aanwezige VG moeten gebruiken, niet alle VGs. ### 3.3 CLP-route berekening De functie `parse_clp_routes()` detecteert: - Acute Tox uit `acute_tox_cat` veld EN uit CLP-tekst - CMR uit `is_cmr` veld EN uit CLP-tekst (H350, H340, etc.) **Bevinding**: De detectie is robuust (dubbele check: expliciete velden + tekst-parsing). Echter, de drempelwaarden in CLP_ROUTES zijn incorrect (zie Bevinding 2). ### 3.4 Catch-all check `total >= 1000 → catchall = True` → Basismaatregelen verplicht. **Status**: ✅ CORRECT (PGS 15:2025 §2.3) ### 3.5 Strengste route Volgorde: A > B > C > Buiten scope. Bepaald via `niveau_order` dict. **Status**: ✅ CORRECT --- ## 4. API & Rapport ### 4.1 /api/scan Genereert een volledig scan-resultaat met: - Per stof: ADR-route, CLP-routes, strengste niveau, maatregelen, versietabel, drempelwaarden - Aggregatie per ADR-klasse - Seveso/BAL status - Opslagvoorstel - Wat-als analyse per opslagtype - Combinatie-analyse (scheiding, aggregatie, synergie) - Flat measures per stof en per samenstel **Test**: PM2 logs tonen recente succesvolle scans (HTTP 200). **Status**: ✅ PASS ### 4.2 /api/rapport/docx Genereert professioneel DOCX rapport met: - Voorblad met branding - Inleiding & Scope - Stoffenoverzicht (2 tabellen) - Niveaubepaling per stof (met versietabel, drempelwaarden) - Aggregatie-overzicht & Verplichte Maatregelen - Bijlage A: Per product vereisten & maatregelen - Bijlage A.2: Samenstel - Wat-als analyse - Aggregatie-analyse - Voorstel Gezamenlijke Opslag - Conclusie & Aanbevelingen - Bijlage A: Scenario's, Bijlage B: Doelstellingen, Bijlage C: Maatregelen **Test**: PM2 logs tonen succesvolle DOCX generatie. **Status**: ✅ PASS ### 4.3 /api/rapport/html Volledig HTML rapport met identieke structuur als DOCX, plus bijlagen. **Status**: ✅ PASS ### 4.4 /api/lookup Unified lookup: PubChem + PubChem GHS + RIVM RVS + RIVM Gevarenclassificering + ECHA + ADR fallback + flashpoint DB. **Status**: ✅ PASS (uitgebreid, 6 bronnen) --- ## 5. UI/Frontend ### 5.1 Templates Enkel template: `index.html` (220 regels). Bevat: - Stof toevoegen formulier met auto-lookup - Stoffenlijst - Opslagtype selectie - Scan knop - Resultaat weergave **Status**: ✅ PASS (functioneel, minimale UI) ### 5.2 Overige - CSS wordt geladen via `/pgs15-scanner/static/css/style.css` - Nginx proxy op `/pgs15-scanner/` → `localhost:5060` - PM2 proces online, 74.5MB geheugen --- ## 6. Prioriteitenlijst ### 🔴 Kritiek (moet ASAP gefikst) | # | Bevinding | Impact | Moeilijkheid | |---|-----------|--------|-------------| | 1 | CLP_ROUTES drempelwaarden incorrect (BEV 2) | CMR en Acute Tox stoffen worden verkeerd geclassificeerd — juridisch risico | Laag | | 2 | ADR 5.1 VG I ontbreekt in NIVEAUS (BEV 3) | 5.1 VG I stoffen krijgen "Geen ADR-klasse" | Laag | | 3 | CMR Cat. 2 onderscheid ontbreekt (BEV 2) | CMR Cat. 2 stoffen krijgen te lage ondergrens | Medium | ### 🟡 Belangrijk (binnen 1 week) | # | Bevinding | Impact | Moeilijkheid | |---|-----------|--------|-------------| | 4 | Aggregatie loopt over alle VGs ipv alleen aanwezige VGs | Kan te streng niveau opleveren | Medium | | 5 | ONDERGRENS dict is dode code (BEV 1) | Verwarring bij onderhoud | Laag | | 6 | Flashpoint=None → conservatieve aanname | Functioneel veilig, maar ongedocumenteerd | Laag | ### 🟢 Nice-to-have | # | Bevinding | Impact | Moeilijkheid | |---|-----------|--------|-------------| | 7 | M98 toelichting leeg | Minor | Laag | | 8 | session_id via cookie werkt niet via API (curl test) | Gebruikers via web interface merken dit niet | Laag | --- ## 7. Aanbevelingen ### 7.1 Onmiddellijk: Fix CLP_ROUTES Vervang CLP_ROUTES door waarden uit BEORDELINGSAANPAK.md §2.1: ```python CLP_ROUTES = { 'cmr_1ab_vloeibaar': {'label': 'CMR Cat. 1A/1B (vloeibaar)', 'ondergrens': 1, 'C': 1, 'B': None, 'A': None}, 'cmr_1ab_poeder': {'label': 'CMR Cat. 1A/1B (poedervorm)', 'ondergrens': 25, 'C': 25, 'B': None, 'A': None}, 'cmr_2_vloeibaar': {'label': 'CMR Cat. 2 (vloeibaar)', 'ondergrens': 25, 'C': 25, 'B': None, 'A': None}, 'cmr_2_poeder': {'label': 'CMR Cat. 2 (poedervorm)', 'ondergrens': 250, 'C': 250, 'B': None, 'A': None}, 'acute_tox_1': {'label': 'Acute Tox. Cat. 1', 'ondergrens': 1, 'C': 1, 'B': None, 'A': 1000}, 'acute_tox_2_3': {'label': 'Acute Tox. Cat. 2/3', 'ondergrens': 1, 'C': 1, 'B': 10000, 'A': 20000}, } ``` Update tevens `parse_clp_routes()` om CMR Cat. 1A/1B vs Cat. 2 te onderscheiden. ### 7.2 Voeg 5.1 VG I toe ```python ('5.1', 'I'): (50, 50, 2500, None), # zelfde als VG II/III tenzij PGS 15 anders zegt ``` **Let op**: Verifieer tegen Publicatie 565 of 5.1 VG I dezelfde drempels heeft als VG II/III. ### 7.3 Fix aggregatie VG-filter Vervang de loop over alle VGs door alleen de VGs van daadwerkelijk aanwezige stoffen: ```python # Huidig (fout): for vg in ('I', 'II', 'III'): n = determine_niveau(k, vg, kg) # Voorgesteld: aanwezige_vgs = set(s.get('vg') for s in stoffen if s.get('adr_klasse') == k) for vg in aanwezige_vgs: n = determine_niveau(k, vg, kg) ``` ### 7.4 Verifieer BEORDELINGSAANPAK vs Publicatie 565 De BEORDELINGSAANPAK.md is gebaseerd op een eerdere interpretatie. Beide bronnen (BEORDELINGSAANPAK én de huidige CLP_ROUTES in app.py) wijken af van elkaar. De ware bron is **PGS 15:2025 Publicatie 565**. Aanbevolen wordt om de exacte drempelwaarden uit Publicatie 565 Tabel 6 over te nemen als enige bron van waarheid. ### 7.5 Verwijder dode code - `ONDERGRENS` dict wordt nergens functioneel gebruikt → verwijderen of documenteren als referentie - `MAATREGELEN_BASIS`, `MAATREGELEN_C`, `MAATREGELEN_B`, `MAATREGELEN_A` zijn leeg → verwijderen --- ## 8. Testresultaten API | Endpoint | Methode | Resultaat | Opmerking | |----------|---------|-----------|-----------| | `/` | GET | ✅ 200 | UI laadt | | `/api/scan` | POST | ✅ 200 | Volledig rapport gegenereerd | | `/api/rapport/docx` | POST | ✅ 200 | DOCX gegenereerd | | `/api/rapport/html` | POST | ✅ 200 | HTML gegenereerd | | `/api/lookup` | POST | ✅ | PubChem + RIVM + ECHA | | `/api/stoffen` | POST | ✅ 200 | Stof toegevoegd | | `/api/stoffen` | GET | ✅ 200 | Stoffen opgehaald | --- ## 9. Conclusie De PGS 15:2025 Compliance Scanner is een ambitieus en grotendeels functioneel instrument met: - Uitstekende rapportage (DOCX + HTML) - Uitgebreide maatregelen-database (167 items) - Robuuste stof-lookup (6 bronnen) - Goede UI/UX De kritieke tekortkomingen liggen in de **CLP-route drempelwaarden** (BEV 2) en de **ontbrekende 5.1 VG I entry** (BEV 3). Deze fouten hebben directe juridische consequenties: stoffen kunnen op een te laag of te hoog veiligheidsniveau worden geplaatst, wat leidt tot onvoldoende of onnodig strenge maatregelen. **Aanbeveling**: Fix BEV 2 en BEV 3 vóórdat de scanner wordt ingezet voor klant-gerelateerde compliance toetsingen. --- *Rapport gegenereerd door Kas — Director of Operations* *Vertrouwelijk — JvG Consultancy* --- ## 10. Fix Status (Post-Reparatie 27 april 2026) | # | Bevinding | Status | Fix Details | |---|-----------|--------|-------------| | 1 | CLP_ROUTES drempelwaarden incorrect | ✅ GEFIKST | CLP_ROUTES volledig vervangen: 6 routes (cmr_1ab_vloeibaar, cmr_1ab_poeder, cmr_2_vloeibaar, cmr_2_poeder, acute_tox_1, acute_tox_2_3) met correcte PGS 15:2025 waarden | | 2 | ADR 5.1 VG I ontbreekt in NIVEAUS | ✅ GEFIKST | `('5.1', 'I'): (50, 50, 2500, None)` toegevoegd — zelfde drempels als VG II/III per PGS 15:2025 Tabel 6 | | 3 | CMR Cat. 2 onderscheid ontbreekt | ✅ GEFIKST | `parse_clp_routes()` geüpdatet: onderscheidt nu Cat. 1A/1B vs Cat. 2 via cmr_type veld én H-zin detectie (H341/H351 = Cat. 2) | | 4 | Aggregatie over alle VGs ipv aanwezige | ✅ GEFIKST | Aggregatie loopt nu alleen over daadwerkelijk aanwezige VGs per ADR-klasse | | 5 | ONDERGRENS dode code | ✅ GEDOCUMENTEERD | Commentaar toegevoegd: dict is referentie-only, wordt niet functioneel gebruikt. 5.1 VG I ondergrens gecorrigeerd naar 50. ADR 8/9 VG I ondergrens gecorrigeerd naar 250. | | 6 | Flashpoint=None conservatieve aanname | ✅ GEDOCUMENTEERD | Expliciete commentaar toegevoegd bij FP=0 fallback met verwijzing PGS 15:2025 §3.4 | **Alle 6 fixes toegepast en getest. PM2 herstart. App operationeel.**