feat(iace): hazard block view — parent/child grouping
Build + Deploy / build-ai-sdk (push) Successful in 54s
Build + Deploy / build-developer-portal (push) Successful in 10s
Build + Deploy / build-admin-compliance (push) Successful in 2m9s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-tts (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 19s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
Build + Deploy / build-document-crawler (push) Successful in 13s
Build + Deploy / build-dsms-gateway (push) Successful in 15s
Build + Deploy / build-dsms-node (push) Successful in 13s
CI / nodejs-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m14s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 59s
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Successful in 28s
CI / test-python-dsms-gateway (push) Successful in 22s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 2m54s
Build + Deploy / build-ai-sdk (push) Successful in 54s
Build + Deploy / build-developer-portal (push) Successful in 10s
Build + Deploy / build-admin-compliance (push) Successful in 2m9s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-tts (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 19s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
Build + Deploy / build-document-crawler (push) Successful in 13s
Build + Deploy / build-dsms-gateway (push) Successful in 15s
Build + Deploy / build-dsms-node (push) Successful in 13s
CI / nodejs-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m14s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Failing after 59s
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Successful in 28s
CI / test-python-dsms-gateway (push) Successful in 22s
CI / validate-canonical-controls (push) Successful in 15s
Build + Deploy / trigger-orca (push) Successful in 2m54s
Backend: - hazard_blocks.go: ComputeHazardBlocks() groups hazards by category + component + zone. Parent = highest risk in group. Children covered by parent's measures are flagged (no separate assessment needed). - iace_handler_blocks.go: GET /projects/:id/hazard-blocks endpoint with summary stats (blocks, covered children, assessments saved) Frontend: - HazardBlockView.tsx: Expandable block view with summary cards, parent-child hierarchy, coverage badges, and "abgedeckt" indicators - hazards/page.tsx: New "Bloecke" tab alongside "Hazard-Liste" and "Risikobewertung" No database schema changes — grouping is computed at runtime. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { CATEGORY_LABELS } from './types'
|
||||
import { RiskBadge } from './RiskBadge'
|
||||
|
||||
interface BlockHazard {
|
||||
hazard: {
|
||||
id: string; name: string; description: string; category: string
|
||||
hazardous_zone: string; scenario?: string; possible_harm?: string
|
||||
}
|
||||
assessment?: { severity: number; exposure: number; probability: number; inherent_risk: number; risk_level: string } | null
|
||||
mitigation_ids: string[]
|
||||
}
|
||||
|
||||
interface HazardBlock {
|
||||
parent_hazard: BlockHazard
|
||||
children: BlockHazard[]
|
||||
block_key: string
|
||||
shared_measure_count: number
|
||||
children_covered_by_parent: boolean
|
||||
}
|
||||
|
||||
interface BlockSummary {
|
||||
total_blocks: number
|
||||
parent_only_blocks: number
|
||||
blocks_with_children: number
|
||||
total_hazards: number
|
||||
covered_children: number
|
||||
uncovered_children: number
|
||||
assessments_needed: number
|
||||
assessments_saved: number
|
||||
}
|
||||
|
||||
export function HazardBlockView() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const [blocks, setBlocks] = useState<HazardBlock[]>([])
|
||||
const [summary, setSummary] = useState<BlockSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
setBlocks(data.blocks || [])
|
||||
setSummary(data.summary || null)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
|
||||
const toggle = (key: string) => setExpanded(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400 py-8 text-center">Lade Bloecke...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<SummaryCard label="Bloecke" value={summary.total_blocks} sub={`${summary.total_hazards} Gefaehrdungen`} />
|
||||
<SummaryCard label="Mit Kindern" value={summary.blocks_with_children} sub={`${summary.covered_children} abgedeckt`} color="green" />
|
||||
<SummaryCard label="Bewertungen noetig" value={summary.assessments_needed} sub={`von ${summary.total_hazards}`} color="purple" />
|
||||
<SummaryCard label="Eingespart" value={summary.assessments_saved} sub="durch Gruppierung" color="green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block List */}
|
||||
<div className="space-y-2">
|
||||
{blocks.map((block) => {
|
||||
const isOpen = expanded[block.block_key]
|
||||
const parent = block.parent_hazard
|
||||
const childCount = block.children.length
|
||||
const covered = block.children_covered_by_parent
|
||||
|
||||
return (
|
||||
<div key={block.block_key} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Parent Row */}
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${childCount > 0 ? '' : 'opacity-90'}`}
|
||||
onClick={() => childCount > 0 && toggle(block.block_key)}
|
||||
>
|
||||
{/* Expand Arrow */}
|
||||
{childCount > 0 ? (
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<div className="w-4 h-4" />
|
||||
)}
|
||||
|
||||
{/* Name + Category */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">{parent.hazard.name}</span>
|
||||
<span className="text-xs text-gray-400">{CATEGORY_LABELS[parent.hazard.category] || parent.hazard.category}</span>
|
||||
</div>
|
||||
{parent.hazard.hazardous_zone && (
|
||||
<div className="text-xs text-gray-500 truncate">{parent.hazard.hazardous_zone}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Risk */}
|
||||
{parent.assessment ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-gray-500">R={parent.assessment.inherent_risk}</span>
|
||||
<RiskBadge level={parent.assessment.risk_level} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Nicht bewertet</span>
|
||||
)}
|
||||
|
||||
{/* Child count badge */}
|
||||
{childCount > 0 && (
|
||||
<div className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
covered
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}`}>
|
||||
+{childCount}
|
||||
{covered && (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Measures count */}
|
||||
<span className="text-xs text-gray-400">{block.shared_measure_count} M.</span>
|
||||
</div>
|
||||
|
||||
{/* Children (expanded) */}
|
||||
{isOpen && childCount > 0 && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-850">
|
||||
{covered && (
|
||||
<div className="px-4 py-2 text-xs text-green-600 dark:text-green-400 bg-green-50/50 dark:bg-green-900/10 border-b border-green-100 dark:border-green-900/30">
|
||||
Alle Untergefaehrdungen durch Massnahmen der Muttergefaehrdung abgedeckt — keine separate Bewertung noetig.
|
||||
</div>
|
||||
)}
|
||||
{block.children.map((child) => (
|
||||
<div key={child.hazard.id} className="flex items-center gap-3 px-4 py-2 pl-12 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">{child.hazard.name}</span>
|
||||
{child.hazard.hazardous_zone && (
|
||||
<span className="text-xs text-gray-400 ml-2">[{child.hazard.hazardous_zone}]</span>
|
||||
)}
|
||||
</div>
|
||||
{child.assessment ? (
|
||||
<span className="text-xs text-gray-500">R={child.assessment.inherent_risk}</span>
|
||||
) : covered ? (
|
||||
<span className="text-xs text-green-500">Abgedeckt</span>
|
||||
) : (
|
||||
<span className="text-xs text-yellow-500">Offen</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, sub, color }: { label: string; value: number; sub: string; color?: string }) {
|
||||
const textColor = color === 'green' ? 'text-green-600' : color === 'purple' ? 'text-purple-600' : 'text-gray-900 dark:text-white'
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-center">
|
||||
<div className={`text-xl font-bold ${textColor}`}>{value}</div>
|
||||
<div className="text-xs font-medium text-gray-600 dark:text-gray-400">{label}</div>
|
||||
<div className="text-[10px] text-gray-400">{sub}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useMemo, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { HazardForm } from './_components/HazardForm'
|
||||
import { HazardTable } from './_components/HazardTable'
|
||||
import { HazardBlockView } from './_components/HazardBlockView'
|
||||
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
|
||||
import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel'
|
||||
import type { ResidualFilter } from './_components/ResidualRiskPanel'
|
||||
@@ -12,7 +13,7 @@ import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
||||
import { CustomHazardModal } from './_components/CustomHazardModal'
|
||||
import { useHazards } from './_hooks/useHazards'
|
||||
|
||||
type ViewMode = 'list' | 'risk'
|
||||
type ViewMode = 'list' | 'risk' | 'blocks'
|
||||
|
||||
export default function HazardsPage() {
|
||||
const params = useParams()
|
||||
@@ -69,6 +70,10 @@ export default function HazardsPage() {
|
||||
className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'risk' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
|
||||
Risikobewertung
|
||||
</button>
|
||||
<button onClick={() => setView('blocks')}
|
||||
className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'blocks' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
|
||||
Bloecke
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -172,6 +177,8 @@ export default function HazardsPage() {
|
||||
<RiskAssessmentTable projectId={projectId} hazards={filteredHazards}
|
||||
onReassess={h.refetch} decisions={decisions} onDecision={handleDecision} />
|
||||
</>
|
||||
) : view === 'blocks' ? (
|
||||
<HazardBlockView />
|
||||
) : (
|
||||
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GetHazardBlocks handles GET /projects/:id/hazard-blocks
|
||||
// Returns hazards grouped into parent-child blocks based on shared category,
|
||||
// component, and zone. The parent hazard in each block has the highest risk.
|
||||
// Children covered by the parent's measures are flagged accordingly.
|
||||
func (h *IACEHandler) GetHazardBlocks(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
hazards, err := h.store.ListHazards(ctx, projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load hazards"})
|
||||
return
|
||||
}
|
||||
|
||||
assessmentMap, _ := h.store.GetLatestAssessmentsByProject(ctx, projectID)
|
||||
var assessments []iace.RiskAssessment
|
||||
for _, a := range assessmentMap {
|
||||
assessments = append(assessments, a)
|
||||
}
|
||||
mitigations, _ := h.store.ListMitigationsByProject(ctx, projectID)
|
||||
|
||||
blocks := iace.ComputeHazardBlocks(hazards, assessments, mitigations)
|
||||
|
||||
// Compute summary stats
|
||||
totalBlocks := len(blocks)
|
||||
parentOnly := 0
|
||||
coveredChildren := 0
|
||||
uncoveredChildren := 0
|
||||
for _, b := range blocks {
|
||||
if len(b.Children) == 0 {
|
||||
parentOnly++
|
||||
} else if b.ChildrenCoveredByParent {
|
||||
coveredChildren += len(b.Children)
|
||||
} else {
|
||||
uncoveredChildren += len(b.Children)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"blocks": blocks,
|
||||
"summary": gin.H{
|
||||
"total_blocks": totalBlocks,
|
||||
"parent_only_blocks": parentOnly,
|
||||
"blocks_with_children": totalBlocks - parentOnly,
|
||||
"total_hazards": len(hazards),
|
||||
"covered_children": coveredChildren,
|
||||
"uncovered_children": uncoveredChildren,
|
||||
"assessments_needed": totalBlocks - parentOnly + uncoveredChildren + parentOnly,
|
||||
"assessments_saved": coveredChildren,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -432,6 +432,7 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
|
||||
iaceRoutes.POST("/library-search", h.SearchLibrary)
|
||||
iaceRoutes.GET("/ce-corpus-documents", h.ListCECorpusDocuments)
|
||||
iaceRoutes.POST("/projects/:id/initialize", h.InitializeProject)
|
||||
iaceRoutes.GET("/projects/:id/hazard-blocks", h.GetHazardBlocks)
|
||||
iaceRoutes.POST("/projects/:id/benchmark/import-gt", h.ImportGroundTruth)
|
||||
iaceRoutes.GET("/projects/:id/benchmark", h.RunBenchmark)
|
||||
iaceRoutes.GET("/projects/:id/benchmark/summary", h.GetBenchmarkSummary)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// HazardBlock groups related hazards under a parent hazard.
|
||||
// The parent is the hazard with the highest inherent risk in the group.
|
||||
// Child hazards are covered by the same or similar protective measures.
|
||||
type HazardBlock struct {
|
||||
ParentHazard HazardBlockEntry `json:"parent_hazard"`
|
||||
Children []HazardBlockEntry `json:"children"`
|
||||
BlockKey string `json:"block_key"`
|
||||
SharedMeasureCount int `json:"shared_measure_count"`
|
||||
// If true, the parent's measures cover all children → children
|
||||
// don't need individual risk assessment.
|
||||
ChildrenCoveredByParent bool `json:"children_covered_by_parent"`
|
||||
}
|
||||
|
||||
// HazardBlockEntry is a hazard with its assessment and linked measures.
|
||||
type HazardBlockEntry struct {
|
||||
Hazard Hazard `json:"hazard"`
|
||||
Assessment *RiskAssessment `json:"assessment,omitempty"`
|
||||
MitigationIDs []uuid.UUID `json:"mitigation_ids"`
|
||||
}
|
||||
|
||||
// ComputeHazardBlocks groups hazards into blocks based on category + component.
|
||||
// Within each block, the hazard with the highest risk becomes the parent.
|
||||
// Children whose measures are a subset of the parent's measures are marked as covered.
|
||||
func ComputeHazardBlocks(
|
||||
hazards []Hazard,
|
||||
assessments []RiskAssessment,
|
||||
mitigations []Mitigation,
|
||||
) []HazardBlock {
|
||||
if len(hazards) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build assessment lookup: hazard_id → latest assessment
|
||||
assessMap := make(map[uuid.UUID]*RiskAssessment)
|
||||
for i := range assessments {
|
||||
a := &assessments[i]
|
||||
if existing, ok := assessMap[a.HazardID]; !ok || a.Version > existing.Version {
|
||||
assessMap[a.HazardID] = a
|
||||
}
|
||||
}
|
||||
|
||||
// Build mitigation lookup: hazard_id → []mitigation_ids
|
||||
mitsByHazard := make(map[uuid.UUID][]uuid.UUID)
|
||||
for _, m := range mitigations {
|
||||
mitsByHazard[m.HazardID] = append(mitsByHazard[m.HazardID], m.ID)
|
||||
}
|
||||
|
||||
// Group by blockKey = category + ":" + componentID
|
||||
groups := make(map[string][]HazardBlockEntry)
|
||||
for _, h := range hazards {
|
||||
key := buildBlockKey(h)
|
||||
entry := HazardBlockEntry{
|
||||
Hazard: h,
|
||||
Assessment: assessMap[h.ID],
|
||||
MitigationIDs: mitsByHazard[h.ID],
|
||||
}
|
||||
groups[key] = append(groups[key], entry)
|
||||
}
|
||||
|
||||
// Build blocks: sort each group by risk, first is parent
|
||||
var blocks []HazardBlock
|
||||
for key, entries := range groups {
|
||||
sortByRiskDesc(entries, assessMap)
|
||||
|
||||
parent := entries[0]
|
||||
children := entries[1:]
|
||||
|
||||
// Check if parent's measures cover children
|
||||
parentMitSet := toUUIDSet(parent.MitigationIDs)
|
||||
allCovered := true
|
||||
for _, child := range children {
|
||||
if !mitigationsCoveredBy(child, parent, mitigations) {
|
||||
allCovered = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
block := HazardBlock{
|
||||
ParentHazard: parent,
|
||||
Children: children,
|
||||
BlockKey: key,
|
||||
SharedMeasureCount: len(parentMitSet),
|
||||
ChildrenCoveredByParent: allCovered && len(children) > 0,
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
|
||||
// Sort blocks: largest (most children) first, then by parent risk
|
||||
sort.Slice(blocks, func(i, j int) bool {
|
||||
ri := inherentRisk(blocks[i].ParentHazard, assessMap)
|
||||
rj := inherentRisk(blocks[j].ParentHazard, assessMap)
|
||||
if len(blocks[i].Children) != len(blocks[j].Children) {
|
||||
return len(blocks[i].Children) > len(blocks[j].Children)
|
||||
}
|
||||
return ri > rj
|
||||
})
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
func buildBlockKey(h Hazard) string {
|
||||
comp := h.ComponentID.String()
|
||||
zone := NormalizeDEPublic(h.HazardousZone)
|
||||
// Use first 3 significant words of zone for grouping
|
||||
words := strings.Fields(zone)
|
||||
var sig []string
|
||||
for _, w := range words {
|
||||
w = strings.Trim(w, ".,;:!?()/-")
|
||||
if len(w) >= 4 {
|
||||
sig = append(sig, w)
|
||||
}
|
||||
if len(sig) >= 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
zoneKey := strings.Join(sig, "_")
|
||||
return h.Category + ":" + comp + ":" + zoneKey
|
||||
}
|
||||
|
||||
func sortByRiskDesc(entries []HazardBlockEntry, assessMap map[uuid.UUID]*RiskAssessment) {
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
ri := inherentRisk(entries[i], assessMap)
|
||||
rj := inherentRisk(entries[j], assessMap)
|
||||
return ri > rj
|
||||
})
|
||||
}
|
||||
|
||||
func inherentRisk(entry HazardBlockEntry, assessMap map[uuid.UUID]*RiskAssessment) float64 {
|
||||
if entry.Assessment != nil {
|
||||
return entry.Assessment.InherentRisk
|
||||
}
|
||||
if a, ok := assessMap[entry.Hazard.ID]; ok {
|
||||
return a.InherentRisk
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// mitigationsCoveredBy checks if child's measures are functionally covered
|
||||
// by parent's measures (same reduction type and hazard category).
|
||||
func mitigationsCoveredBy(child, parent HazardBlockEntry, allMits []Mitigation) bool {
|
||||
if len(child.MitigationIDs) == 0 {
|
||||
return true // No measures needed → covered by default
|
||||
}
|
||||
|
||||
mitMap := make(map[uuid.UUID]Mitigation)
|
||||
for _, m := range allMits {
|
||||
mitMap[m.ID] = m
|
||||
}
|
||||
|
||||
// Check: for each child mitigation type, parent has same type
|
||||
parentTypes := make(map[ReductionType]bool)
|
||||
for _, mid := range parent.MitigationIDs {
|
||||
if m, ok := mitMap[mid]; ok {
|
||||
parentTypes[m.ReductionType] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, mid := range child.MitigationIDs {
|
||||
if m, ok := mitMap[mid]; ok {
|
||||
if !parentTypes[m.ReductionType] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func toUUIDSet(ids []uuid.UUID) map[uuid.UUID]bool {
|
||||
s := make(map[uuid.UUID]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
s[id] = true
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user