feat(iace): hazard block view — parent/child grouping
Build + Deploy / build-admin-compliance (push) Successful in 2m9s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-ai-sdk (push) Successful in 54s
Build + Deploy / build-developer-portal (push) Successful in 10s
Build + Deploy / build-tts (push) Successful in 12s
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 / 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
CI / python-lint (push) Has been skipped
CI / nodejs-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-admin-compliance (push) Successful in 2m9s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-ai-sdk (push) Successful in 54s
Build + Deploy / build-developer-portal (push) Successful in 10s
Build + Deploy / build-tts (push) Successful in 12s
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 / 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
CI / python-lint (push) Has been skipped
CI / nodejs-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 { useParams } from 'next/navigation'
|
||||||
import { HazardForm } from './_components/HazardForm'
|
import { HazardForm } from './_components/HazardForm'
|
||||||
import { HazardTable } from './_components/HazardTable'
|
import { HazardTable } from './_components/HazardTable'
|
||||||
|
import { HazardBlockView } from './_components/HazardBlockView'
|
||||||
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
|
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
|
||||||
import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel'
|
import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel'
|
||||||
import type { ResidualFilter } from './_components/ResidualRiskPanel'
|
import type { ResidualFilter } from './_components/ResidualRiskPanel'
|
||||||
@@ -12,7 +13,7 @@ import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
|||||||
import { CustomHazardModal } from './_components/CustomHazardModal'
|
import { CustomHazardModal } from './_components/CustomHazardModal'
|
||||||
import { useHazards } from './_hooks/useHazards'
|
import { useHazards } from './_hooks/useHazards'
|
||||||
|
|
||||||
type ViewMode = 'list' | 'risk'
|
type ViewMode = 'list' | 'risk' | 'blocks'
|
||||||
|
|
||||||
export default function HazardsPage() {
|
export default function HazardsPage() {
|
||||||
const params = useParams()
|
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'}`}>
|
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
|
Risikobewertung
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -172,6 +177,8 @@ export default function HazardsPage() {
|
|||||||
<RiskAssessmentTable projectId={projectId} hazards={filteredHazards}
|
<RiskAssessmentTable projectId={projectId} hazards={filteredHazards}
|
||||||
onReassess={h.refetch} decisions={decisions} onDecision={handleDecision} />
|
onReassess={h.refetch} decisions={decisions} onDecision={handleDecision} />
|
||||||
</>
|
</>
|
||||||
|
) : view === 'blocks' ? (
|
||||||
|
<HazardBlockView />
|
||||||
) : (
|
) : (
|
||||||
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
<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.POST("/library-search", h.SearchLibrary)
|
||||||
iaceRoutes.GET("/ce-corpus-documents", h.ListCECorpusDocuments)
|
iaceRoutes.GET("/ce-corpus-documents", h.ListCECorpusDocuments)
|
||||||
iaceRoutes.POST("/projects/:id/initialize", h.InitializeProject)
|
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.POST("/projects/:id/benchmark/import-gt", h.ImportGroundTruth)
|
||||||
iaceRoutes.GET("/projects/:id/benchmark", h.RunBenchmark)
|
iaceRoutes.GET("/projects/:id/benchmark", h.RunBenchmark)
|
||||||
iaceRoutes.GET("/projects/:id/benchmark/summary", h.GetBenchmarkSummary)
|
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