"""Neutral CRA applicability verdict for the 'Eingangstür' readiness check. Separates LEGAL obligation from MARKET pull — the distinction SME manufacturers actually care about: a product may not yet be legally in CRA scope, but its B2B customers (machine/plant builders) will demand CRA evidence anyway. Key rule: not the development date but the PLACING ON THE MARKET (Inverkehrbringen) decides. A legacy product (e.g. designed 2019) still placed on the market after the CRA cutoff must be conformant. Pure + deterministic: no DB, no LLM, no network. Reuses the existing Annex III/IV classifier (passed in as `cra_class`) — this is the verdict layer on top, not a fourth classifier. """ CRA_CUTOFF = "2027-12-11" # CRA main obligations apply to products placed on the market from here # Verdict tiers ZWINGEND = "zwingend" RATSAM = "ratsam" NICHT_BETROFFEN = "nicht_betroffen" _TIER_LABEL = { ZWINGEND: "CRA zwingend (Rechtspflicht)", RATSAM: "CRA nicht zwingend, aber dringend ratsam", NICHT_BETROFFEN: "CRA nicht betroffen", } # Producer archetypes — drive default assumptions + verdict emphasis. COMPONENT = "component" # Zulieferteil/Komponente (B2B), z. B. OWIS PS90+ END_DEVICE = "end_device" # Endgerät MACHINE_INTEGRATOR = "machine_integrator" # Anlagen-/Maschinenbauer — Internet/OTA meist gegeben SOFTWARE_APP = "software_app" # App/Frontend/Cloud, keine Hardware PRODUCER_TYPES = (COMPONENT, END_DEVICE, MACHINE_INTEGRATOR, SOFTWARE_APP) # Standard CRA evidence the cyber technical file needs (Annex I/II + Art. 13/14). EVIDENCE_ITEMS = [ {"key": "sbom", "label": "Software-Stückliste (SBOM)"}, {"key": "vdp", "label": "Vulnerability-Disclosure-Policy / Security-Kontakt"}, {"key": "patch_process", "label": "Patch-/Update-Prozess (signiert)"}, {"key": "support_lifecycle", "label": "Support-Zeitraum / Lifecycle-Zusage"}, {"key": "threat_model", "label": "Threat Model / Cyber-Risikobeurteilung"}, {"key": "security_logging", "label": "Security-Logging"}, {"key": "auth_concept", "label": "Authentisierung / Passwortkonzept (Netzwerk)"}, {"key": "incident_process", "label": "Incident-/Meldeprozess (Art. 14, 24/72h)"}, ] _EVIDENCE_KEYS = {e["key"] for e in EVIDENCE_ITEMS} def in_scope(cra_class: str) -> bool: """A product is in CRA scope once it has any digital element / Annex match.""" return (cra_class or "").upper() not in ("", "NOT_IN_SCOPE") def compute_verdict( cra_class: str, placed_on_market_after_cutoff, producer_type: str = "", customers_request: bool = False, ) -> dict: """Neutral 3-tier verdict. `placed_on_market_after_cutoff`: True/False/None (None = unknown → assumed True, conservative: most products keep being sold).""" scope = in_scope(cra_class) after_cutoff = True if placed_on_market_after_cutoff is None else bool(placed_on_market_after_cutoff) # Components are inherently subject to downstream demand even absent an explicit signal. market_pull = bool(customers_request) or producer_type == COMPONENT reasons: list = [] if not scope: tier = NICHT_BETROFFEN reasons.append("Keine digitalen Elemente / keine CRA-Kategorie erkannt.") if market_pull: reasons.append("Hinweis: Kunden könnten dennoch CRA-Nachweise anfragen.") elif after_cutoff: tier = ZWINGEND reasons.append( f"Produkt mit digitalen Elementen und Inverkehrbringen ab {CRA_CUTOFF} → Rechtspflicht." ) reasons.append("Maßgeblich ist das Inverkehrbringen, nicht der Entwicklungszeitpunkt.") else: tier = RATSAM reasons.append("Produkt mit digitalen Elementen, aber kein Inverkehrbringen im CRA-Geltungszeitraum.") reasons.append(f"Sobald ab dem {CRA_CUTOFF} (weiter) in Verkehr gebracht wird, wird CRA zwingend.") if market_pull and tier != NICHT_BETROFFEN: reasons.append("Markt-Druck: B2B-Kunden (Maschinen-/Anlagenbauer) fordern CRA-Nachweise zur Komponente.") return { "tier": tier, "label": _TIER_LABEL[tier], "in_scope": scope, "market_pull": market_pull, "cra_class": (cra_class or "").upper(), "cutoff": CRA_CUTOFF, "placed_on_market_after_cutoff": after_cutoff, "reasons": reasons, } def maturity(provided_evidence_keys) -> dict: """Reifegrad over the standard CRA evidence checklist.""" have = {k for k in (provided_evidence_keys or []) if k in _EVIDENCE_KEYS} present = [e for e in EVIDENCE_ITEMS if e["key"] in have] missing = [e for e in EVIDENCE_ITEMS if e["key"] not in have] total = len(EVIDENCE_ITEMS) pct = round(100.0 * len(present) / total) if total else 0 return {"pct": pct, "present": present, "missing": missing, "total": total}