Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4087bb5f18 | |||
| 85e758b250 | |||
| 916dec87ee | |||
| 5fc16dd61d | |||
| 46278cda5b | |||
| 75174273f4 | |||
| 6baf44ac84 | |||
| 299375e486 | |||
| 2b1fe3713a | |||
| 872145d883 | |||
| 9bdaa28038 |
@@ -0,0 +1,207 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
interface HistoryEntry {
|
||||||
|
cid: string
|
||||||
|
version: string | null
|
||||||
|
document_type: string | null
|
||||||
|
document_id: string | null
|
||||||
|
parent_cid: string | null
|
||||||
|
created_at: string | null
|
||||||
|
checksum: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffResponse {
|
||||||
|
kind: 'text' | 'binary'
|
||||||
|
cid_a: string
|
||||||
|
cid_b: string
|
||||||
|
metadata_diff: Record<string, { old: unknown; new: unknown }>
|
||||||
|
diff?: string
|
||||||
|
added_lines?: number
|
||||||
|
removed_lines?: number
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cid: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function shorten(cid: string): string {
|
||||||
|
if (cid.length <= 14) return cid
|
||||||
|
return cid.slice(0, 8) + '…' + cid.slice(-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CIDHistoryModal({ cid, onClose }: Props) {
|
||||||
|
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [diffPair, setDiffPair] = useState<{ a: string; b: string } | null>(null)
|
||||||
|
const [diff, setDiff] = useState<DiffResponse | null>(null)
|
||||||
|
const [diffLoading, setDiffLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancel = false
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
fetch(`/api/sdk/v1/dsms/documents/${encodeURIComponent(cid)}/history`)
|
||||||
|
.then(async (r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||||
|
const json = await r.json()
|
||||||
|
if (!cancel) setHistory(json.history || [])
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancel) setError(e?.message || 'Fehler beim Laden')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancel) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancel = true
|
||||||
|
}
|
||||||
|
}, [cid])
|
||||||
|
|
||||||
|
async function loadDiff(a: string, b: string) {
|
||||||
|
setDiffPair({ a, b })
|
||||||
|
setDiff(null)
|
||||||
|
setDiffLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/sdk/v1/dsms/documents/${encodeURIComponent(a)}/diff/${encodeURIComponent(b)}`
|
||||||
|
)
|
||||||
|
if (res.ok) {
|
||||||
|
const json = (await res.json()) as DiffResponse
|
||||||
|
setDiff(json)
|
||||||
|
} else {
|
||||||
|
setDiff({ kind: 'binary', cid_a: a, cid_b: b, metadata_diff: {}, note: `HTTP ${res.status}` })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDiffLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-gray-800 rounded-xl shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">DSMS-Versionsverlauf</h2>
|
||||||
|
<code className="text-[10px] font-mono text-gray-500 dark:text-gray-400">{shorten(cid)}</code>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 dark:text-gray-400 text-sm">
|
||||||
|
Schliessen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||||
|
{loading && <div className="text-sm text-gray-500">Verlauf wird geladen…</div>}
|
||||||
|
{error && <div className="text-sm text-red-600 dark:text-red-400">{error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && history.length === 0 && (
|
||||||
|
<div className="text-sm text-gray-500 italic">
|
||||||
|
Kein Versionsverlauf gefunden. Diese CID hat keine parent_cid-Kette.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && history.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{history.length} Version{history.length > 1 ? 'en' : ''} in der Kette (neueste oben).
|
||||||
|
</div>
|
||||||
|
<ol className="relative border-l-2 border-emerald-500/40 pl-4 space-y-3">
|
||||||
|
{history.map((entry, idx) => {
|
||||||
|
const next = history[idx + 1]
|
||||||
|
return (
|
||||||
|
<li key={entry.cid} className="relative">
|
||||||
|
<div className="absolute -left-[1.4rem] top-1.5 w-3 h-3 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-gray-800" />
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/40 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Version {entry.version || '?'} {idx === 0 && <span className="ml-2 text-[10px] text-emerald-600 font-semibold">AKTUELL</span>}
|
||||||
|
</div>
|
||||||
|
<code className="text-[10px] font-mono text-gray-500 dark:text-gray-400 break-all">{entry.cid}</code>
|
||||||
|
</div>
|
||||||
|
{next && (
|
||||||
|
<button
|
||||||
|
onClick={() => loadDiff(next.cid, entry.cid)}
|
||||||
|
className="shrink-0 text-[11px] text-purple-600 hover:text-purple-800 dark:text-purple-400 hover:underline"
|
||||||
|
title="Aenderungen zur Vorversion anzeigen"
|
||||||
|
>
|
||||||
|
Diff zu V{next.version || '?'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-0.5">
|
||||||
|
{entry.document_type && <span>Typ: {entry.document_type}</span>}
|
||||||
|
{entry.document_id && <span>Dok-ID: {entry.document_id}</span>}
|
||||||
|
{entry.created_at && <span>{new Date(entry.created_at).toLocaleString('de-DE')}</span>}
|
||||||
|
</div>
|
||||||
|
{entry.checksum && (
|
||||||
|
<div className="mt-1 text-[10px] text-gray-400 font-mono">SHA-256: {entry.checksum.slice(0, 16)}…</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diffPair && (
|
||||||
|
<div className="mt-4 border-t border-gray-200 dark:border-gray-700 pt-4 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-900 dark:text-white">
|
||||||
|
Diff: {shorten(diffPair.a)} → {shorten(diffPair.b)}
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => { setDiff(null); setDiffPair(null) }} className="text-[11px] text-gray-500 hover:text-gray-700">
|
||||||
|
Schliessen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{diffLoading && <div className="text-xs text-gray-500">Diff wird geladen…</div>}
|
||||||
|
{!diffLoading && diff && (
|
||||||
|
<>
|
||||||
|
{Object.keys(diff.metadata_diff || {}).length > 0 && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="font-medium text-gray-700 dark:text-gray-300 mb-1">Metadaten-Aenderungen</div>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(diff.metadata_diff).map(([field, { old, new: nv }]) => (
|
||||||
|
<tr key={field} className="border-b border-gray-100 dark:border-gray-800">
|
||||||
|
<td className="py-0.5 pr-2 font-mono text-[10px] text-gray-500">{field}</td>
|
||||||
|
<td className="py-0.5 pr-2 text-red-600 dark:text-red-400 line-through">{JSON.stringify(old)}</td>
|
||||||
|
<td className="py-0.5 text-green-700 dark:text-green-400">{JSON.stringify(nv)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{diff.kind === 'text' && diff.diff && (
|
||||||
|
<>
|
||||||
|
<div className="text-[11px] text-gray-500">
|
||||||
|
{diff.added_lines ?? 0} Zeilen hinzu, {diff.removed_lines ?? 0} entfernt
|
||||||
|
</div>
|
||||||
|
<pre className="text-[10px] font-mono whitespace-pre-wrap bg-gray-900 text-gray-100 p-3 rounded max-h-64 overflow-y-auto">
|
||||||
|
{diff.diff}
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{diff.kind === 'binary' && (
|
||||||
|
<div className="text-xs text-amber-700 dark:text-amber-400 italic">
|
||||||
|
{diff.note || 'Binaere Datei — kein Text-Diff verfuegbar.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
||||||
|
import CIDHistoryModal from './_components/CIDHistoryModal'
|
||||||
|
|
||||||
const ENTITY_LABELS: Record<string, string> = {
|
const ENTITY_LABELS: Record<string, string> = {
|
||||||
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
||||||
@@ -16,8 +18,24 @@ const ACTION_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
|
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
|
||||||
|
|
||||||
|
// new_value may be a plain CID (from Python evidence flow) or a JSON envelope
|
||||||
|
// {"cid":"X","filename":"...","size":"..."} (from the Go IACE tech-file flow).
|
||||||
|
function extractCID(value: string): string {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed)
|
||||||
|
if (typeof parsed.cid === 'string') return parsed.cid
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
export default function AuditTimelinePage() {
|
export default function AuditTimelinePage() {
|
||||||
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
||||||
|
const [historyCID, setHistoryCID] = useState<string | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
@@ -58,16 +76,18 @@ export default function AuditTimelinePage() {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<TimelineEntry key={entry.id} entry={entry} />
|
<TimelineEntry key={entry.id} entry={entry} onShowHistory={setHistoryCID} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{historyCID && <CIDHistoryModal cid={historyCID} onClose={() => setHistoryCID(null)} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
function TimelineEntry({ entry, onShowHistory }: { entry: AuditEntry; onShowHistory: (cid: string) => void }) {
|
||||||
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
|
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
|
||||||
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
||||||
const date = new Date(entry.performed_at)
|
const date = new Date(entry.performed_at)
|
||||||
@@ -94,7 +114,7 @@ function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
|||||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
|
||||||
)}
|
)}
|
||||||
{isCID && entry.new_value && (
|
{isCID && entry.new_value && (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||||
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -102,6 +122,16 @@ function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
|||||||
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
|
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
|
||||||
</code>
|
</code>
|
||||||
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (entry.new_value) onShowHistory(extractCID(entry.new_value))
|
||||||
|
}}
|
||||||
|
className="text-[10px] text-purple-600 hover:text-purple-800 dark:text-purple-400 underline-offset-2 hover:underline"
|
||||||
|
title="DSMS-Versionsverlauf und Diff zur Vorversion anzeigen"
|
||||||
|
>
|
||||||
|
Verlauf anzeigen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { calculateAP } from './useFMEA'
|
||||||
|
|
||||||
|
describe('calculateAP — AIAG-VDA 2019 Handbook Action Priority', () => {
|
||||||
|
it('returns H for severity 10 with mid occurrence', () => {
|
||||||
|
expect(calculateAP(10, 5, 5)).toBe('H')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns H for severity 9 with low detection', () => {
|
||||||
|
expect(calculateAP(9, 4, 7)).toBe('H')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns M for severity 9 with low occurrence and good detection', () => {
|
||||||
|
expect(calculateAP(9, 2, 5)).toBe('M')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns L for severity 9 with very low occurrence and detection', () => {
|
||||||
|
expect(calculateAP(9, 1, 4)).toBe('L')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns H for severity 7 with high occurrence', () => {
|
||||||
|
expect(calculateAP(7, 5, 1)).toBe('H')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns M for severity 7 with mid occurrence', () => {
|
||||||
|
expect(calculateAP(7, 3, 5)).toBe('M')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns L for low-severity well-controlled mode', () => {
|
||||||
|
expect(calculateAP(3, 1, 1)).toBe('L')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns L for severity 5 with very low occurrence and detection', () => {
|
||||||
|
expect(calculateAP(5, 1, 1)).toBe('L')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -156,5 +156,52 @@ export function useFMEA(projectId: string) {
|
|||||||
// Get unique components for the suggest button
|
// Get unique components for the suggest button
|
||||||
const components = [...new Map(rows.map((r) => [r.component.id, r.component])).values()]
|
const components = [...new Map(rows.map((r) => [r.component.id, r.component])).values()]
|
||||||
|
|
||||||
return { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions }
|
/**
|
||||||
|
* Accept a suggested FM: build an FMEA row from the FM defaults, prepend it
|
||||||
|
* to the table state, and remove the FM from the suggestion list.
|
||||||
|
* Returns false if the (component, fm.id) combo already exists in rows.
|
||||||
|
*/
|
||||||
|
function acceptSuggestion(fm: FailureMode, componentId: string): boolean {
|
||||||
|
const comp = components.find((c) => c.id === componentId)
|
||||||
|
if (!comp) return false
|
||||||
|
const dup = rows.find((r) => r.component.id === componentId && r.failureMode.id === fm.id)
|
||||||
|
if (dup) {
|
||||||
|
// Still drop the suggestion so the UI does not keep offering it.
|
||||||
|
setSuggestions((prev) => prev.filter((s) => s.id !== fm.id))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const s = fm.default_severity || 5
|
||||||
|
const o = fm.default_occurrence || 5
|
||||||
|
const d = fm.default_detection || 5
|
||||||
|
const newRow: FMEARow = {
|
||||||
|
component: comp,
|
||||||
|
failureMode: fm,
|
||||||
|
severity: s,
|
||||||
|
occurrence: o,
|
||||||
|
detection: d,
|
||||||
|
rpz: s * o * d,
|
||||||
|
ap: calculateAP(s, o, d),
|
||||||
|
}
|
||||||
|
setRows((prev) => [newRow, ...prev].sort((a, b) => b.rpz - a.rpz))
|
||||||
|
setSuggestions((prev) => prev.filter((sg) => sg.id !== fm.id))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectSuggestion(fmId: string) {
|
||||||
|
setSuggestions((prev) => prev.filter((sg) => sg.id !== fmId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
loading,
|
||||||
|
stats,
|
||||||
|
components,
|
||||||
|
suggestFMs,
|
||||||
|
suggesting,
|
||||||
|
suggestions,
|
||||||
|
suggestSource,
|
||||||
|
setSuggestions,
|
||||||
|
acceptSuggestion,
|
||||||
|
rejectSuggestion,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { useFMEA, type FMEARow } from './_hooks/useFMEA'
|
import { useFMEA, type FMEARow } from './_hooks/useFMEA'
|
||||||
|
|
||||||
@@ -27,8 +27,17 @@ function rpzLabel(rpz: number): string {
|
|||||||
|
|
||||||
export default function FMEAPage() {
|
export default function FMEAPage() {
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions } = useFMEA(projectId)
|
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions, acceptSuggestion, rejectSuggestion } = useFMEA(projectId)
|
||||||
const [suggestComp, setSuggestComp] = useState<string | null>(null)
|
const [suggestComp, setSuggestComp] = useState<string | null>(null)
|
||||||
|
const [acceptedCount, setAcceptedCount] = useState(0)
|
||||||
|
|
||||||
|
// Reset accepted-count when a fresh suggestion run is loaded or the panel closes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (suggesting) setAcceptedCount(0)
|
||||||
|
}, [suggesting])
|
||||||
|
useEffect(() => {
|
||||||
|
if (suggestions.length === 0) setAcceptedCount(0)
|
||||||
|
}, [suggestions.length])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -97,26 +106,60 @@ export default function FMEAPage() {
|
|||||||
{suggestions.length > 0 && (
|
{suggestions.length > 0 && (
|
||||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4">
|
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
<div>
|
||||||
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek'}
|
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||||
</h3>
|
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek-Fallback'}
|
||||||
|
</h3>
|
||||||
|
{acceptedCount > 0 && (
|
||||||
|
<div className="text-xs text-green-700 dark:text-green-400 mt-0.5">
|
||||||
|
{acceptedCount} Vorschlag{acceptedCount > 1 ? 'e' : ''} uebernommen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button onClick={() => setSuggestions([])} className="text-xs text-purple-600 hover:text-purple-800">Schliessen</button>
|
<button onClick={() => setSuggestions([])} className="text-xs text-purple-600 hover:text-purple-800">Schliessen</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{suggestions.map((fm, i) => (
|
{suggestions.map((fm) => {
|
||||||
<div key={i} className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
const rpz = fm.default_severity * fm.default_occurrence * fm.default_detection
|
||||||
<div className="flex-1 min-w-0">
|
return (
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
<div key={fm.id} className="flex items-start justify-between gap-3 bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||||
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
||||||
<span>S={fm.default_severity}</span>
|
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
||||||
<span>O={fm.default_occurrence}</span>
|
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||||
<span>D={fm.default_detection}</span>
|
<span>S={fm.default_severity}</span>
|
||||||
<span className="font-bold">RPZ={fm.default_severity * fm.default_occurrence * fm.default_detection}</span>
|
<span>O={fm.default_occurrence}</span>
|
||||||
|
<span>D={fm.default_detection}</span>
|
||||||
|
<span className={`font-bold ${rpz > 200 ? 'text-red-600' : rpz > 100 ? 'text-orange-600' : 'text-gray-500'}`}>RPZ={rpz}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!suggestComp) return
|
||||||
|
const ok = acceptSuggestion(fm, suggestComp)
|
||||||
|
if (ok) setAcceptedCount((c) => c + 1)
|
||||||
|
}}
|
||||||
|
disabled={!suggestComp}
|
||||||
|
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-medium rounded transition-colors"
|
||||||
|
title="Diesen Fehlermodus der FMEA-Tabelle hinzufuegen"
|
||||||
|
>
|
||||||
|
Uebernehmen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => rejectSuggestion(fm.id)}
|
||||||
|
className="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-xs font-medium rounded transition-colors"
|
||||||
|
title="Diesen Vorschlag verwerfen"
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-purple-700 dark:text-purple-400 mt-3">
|
||||||
|
Hinweis: Uebernommene Fehlermodi erscheinen sofort in der Tabelle unten. Bewertung (S/O/D) ist anpassbar — Standardwerte aus der Bibliothek.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
|||||||
<AdditionalModuleItem href="/sdk/ai-registration" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>} label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} />
|
<AdditionalModuleItem href="/sdk/ai-registration" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>} label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
<AdditionalModuleItem href="/sdk/compliance-optimizer" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>} label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} />
|
<AdditionalModuleItem href="/sdk/compliance-optimizer" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>} label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
<AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
|
<AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
|
<AdditionalModuleItem href="/sdk/benchmark" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>} label="Branchen-Benchmark" isActive={pathname?.startsWith('/sdk/benchmark') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CRA Compliance */}
|
{/* CRA Compliance */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -412,7 +413,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("PDF export failed: %v", err)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("PDF export failed: %v", err)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.pdf", safeName), projectID.String())
|
h.archiveTechFile(c, data, fmt.Sprintf("CE-Akte-%s.pdf", safeName), projectID)
|
||||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.pdf"`, safeName))
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.pdf"`, safeName))
|
||||||
c.Data(http.StatusOK, "application/pdf", data)
|
c.Data(http.StatusOK, "application/pdf", data)
|
||||||
|
|
||||||
@@ -422,7 +423,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Excel export failed: %v", err)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Excel export failed: %v", err)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.xlsx", safeName), projectID.String())
|
h.archiveTechFile(c, data, fmt.Sprintf("CE-Akte-%s.xlsx", safeName), projectID)
|
||||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.xlsx"`, safeName))
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.xlsx"`, safeName))
|
||||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
|
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
|
||||||
|
|
||||||
@@ -432,7 +433,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("DOCX export failed: %v", err)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("DOCX export failed: %v", err)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.docx", safeName), projectID.String())
|
h.archiveTechFile(c, data, fmt.Sprintf("CE-Akte-%s.docx", safeName), projectID)
|
||||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.docx"`, safeName))
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.docx"`, safeName))
|
||||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", data)
|
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", data)
|
||||||
|
|
||||||
@@ -442,7 +443,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Markdown export failed: %v", err)})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Markdown export failed: %v", err)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.md", safeName), projectID.String())
|
h.archiveTechFile(c, data, fmt.Sprintf("CE-Akte-%s.md", safeName), projectID)
|
||||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.md"`, safeName))
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.md"`, safeName))
|
||||||
c.Data(http.StatusOK, "text/markdown", data)
|
c.Data(http.StatusOK, "text/markdown", data)
|
||||||
|
|
||||||
@@ -468,7 +469,30 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// archiveTechFile stores a tech-file export to DSMS (best-effort, non-blocking).
|
// archiveTechFile stores a tech-file export to DSMS (best-effort, non-blocking)
|
||||||
func archiveTechFile(data []byte, filename, projectID string) {
|
// AND records the resulting CID in the IACE audit trail so the export is
|
||||||
dsms.Archive(data, filename, "ce_techfile", projectID, "1")
|
// traceable. The "new_values" JSON carries the CID + filename so the audit
|
||||||
|
// timeline can later resolve the CID against the DSMS gateway for verify.
|
||||||
|
func (h *IACEHandler) archiveTechFile(c *gin.Context, data []byte, filename string, projectID uuid.UUID) {
|
||||||
|
result := dsms.Archive(data, filename, "ce_techfile", projectID.String(), "1")
|
||||||
|
if result == nil || result.CID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := map[string]string{
|
||||||
|
"cid": result.CID,
|
||||||
|
"filename": filename,
|
||||||
|
"size": fmt.Sprintf("%d", result.Size),
|
||||||
|
}
|
||||||
|
newValues, _ := json.Marshal(payload)
|
||||||
|
userID := rbac.GetUserID(c)
|
||||||
|
_ = h.store.AddAuditEntry(
|
||||||
|
c.Request.Context(),
|
||||||
|
projectID,
|
||||||
|
"tech_file_export",
|
||||||
|
projectID,
|
||||||
|
iace.AuditActionCreate,
|
||||||
|
userID.String(),
|
||||||
|
nil,
|
||||||
|
newValues,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package dsms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArchive_Success_ReturnsCID(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" || r.URL.Path != "/api/v1/documents" {
|
||||||
|
http.Error(w, "wrong route", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
|
||||||
|
http.Error(w, "wrong content-type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") == "" {
|
||||||
|
http.Error(w, "missing auth", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.ReadAll(r.Body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(ArchiveResult{
|
||||||
|
CID: "bafytest123",
|
||||||
|
Size: 42,
|
||||||
|
GatewayURL: "/ipfs/bafytest123",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
old := gatewayURL
|
||||||
|
defer func() { gatewayURL = old }()
|
||||||
|
gatewayURL = server.URL
|
||||||
|
|
||||||
|
got := Archive([]byte("hello"), "test.pdf", "ce_techfile", "proj-1", "1")
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected non-nil result on 200 OK")
|
||||||
|
}
|
||||||
|
if got.CID != "bafytest123" {
|
||||||
|
t.Errorf("expected CID bafytest123, got %q", got.CID)
|
||||||
|
}
|
||||||
|
if got.Size != 42 {
|
||||||
|
t.Errorf("expected Size 42, got %d", got.Size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArchive_GatewayDown_ReturnsNil(t *testing.T) {
|
||||||
|
old := gatewayURL
|
||||||
|
defer func() { gatewayURL = old }()
|
||||||
|
gatewayURL = "http://127.0.0.1:1" // unreachable
|
||||||
|
got := Archive([]byte("hello"), "test.pdf", "ce_techfile", "proj-1", "1")
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("expected nil when gateway unreachable, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArchive_GatewayReturnsError_ReturnsNil(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
old := gatewayURL
|
||||||
|
defer func() { gatewayURL = old }()
|
||||||
|
gatewayURL = server.URL
|
||||||
|
|
||||||
|
got := Archive([]byte("hello"), "test.pdf", "ce_techfile", "proj-1", "1")
|
||||||
|
if got != nil {
|
||||||
|
t.Errorf("expected nil on 500 response, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,13 +38,15 @@ _PROCESS_INTERNAL_PATTERNS = [
|
|||||||
r"\bsensibilisier",
|
r"\bsensibilisier",
|
||||||
# Vertraege intern
|
# Vertraege intern
|
||||||
r"\bauftragsverarbeitungsvertrag\b",
|
r"\bauftragsverarbeitungsvertrag\b",
|
||||||
r"\bAVV\b\s+abgeschlossen",
|
r"\bavv\b\s+abgeschlossen",
|
||||||
r"\bvertrag.*abgeschlossen",
|
r"\bvertrag.*abgeschlossen",
|
||||||
r"\bdpa\s+(geschlossen|abgeschlossen|vorhanden)",
|
r"\bdpa\s+(geschlossen|abgeschlossen|vorhanden)",
|
||||||
r"\bSCC\s+(geschlossen|abgeschlossen|implementiert)",
|
r"\bscc\s+(geschlossen|abgeschlossen|implementiert)",
|
||||||
# Technisch-organisatorische Massnahmen (intern)
|
# Technisch-organisatorische Massnahmen (intern). Lowercase: blob
|
||||||
|
# ist bereits .lower(); Case-sensitive Patterns (TOM/AVV/SCC) matchen
|
||||||
|
# nie. Daher hier explizit klein.
|
||||||
r"\btechnisch[-\s]*organisatorische\s+ma(ß|ss)nahmen?\b",
|
r"\btechnisch[-\s]*organisatorische\s+ma(ß|ss)nahmen?\b",
|
||||||
r"\bTOM\s+(umgesetzt|dokumentiert|implementiert)",
|
r"\btom\s+(umgesetzt|dokumentiert|implementiert)",
|
||||||
r"\bverschluesselung\s+(implementiert|aktiv)",
|
r"\bverschluesselung\s+(implementiert|aktiv)",
|
||||||
r"\bpseudonymisierung\s+(implementiert|aktiv)",
|
r"\bpseudonymisierung\s+(implementiert|aktiv)",
|
||||||
r"\bbackup[s]?\s+(eingerichtet|vorhanden)",
|
r"\bbackup[s]?\s+(eingerichtet|vorhanden)",
|
||||||
@@ -68,7 +70,11 @@ _PROCESS_INTERNAL_PATTERNS = [
|
|||||||
r"\bbitte\s+(intern\s+)?dokumentieren",
|
r"\bbitte\s+(intern\s+)?dokumentieren",
|
||||||
r"\bin\s+der\s+verfahrens",
|
r"\bin\s+der\s+verfahrens",
|
||||||
r"\bnach\s+innen\s+geh",
|
r"\bnach\s+innen\s+geh",
|
||||||
r"\bausnahmen\s+(dokumentieren|protokollieren)",
|
# "Ausnahmen ... dokumentieren" — Wortabstand bis 60 Zeichen erlauben,
|
||||||
|
# damit "ausnahmen bei bereits vorhandenen informationen dokumentieren"
|
||||||
|
# matcht. Sonst Pattern zu eng → process-internal-MC bleibt FAIL.
|
||||||
|
r"\bausnahmen\b[^\n]{0,60}\b(dokumentieren|protokollieren)\b",
|
||||||
|
r"\bdokumentations[\s-]?pflicht",
|
||||||
r"\bkostenfrei\s+(zur\s+verfuegung|gewaehren|ermoegli)",
|
r"\bkostenfrei\s+(zur\s+verfuegung|gewaehren|ermoegli)",
|
||||||
r"\bunentgeltlich\s+(zur\s+verfuegung)",
|
r"\bunentgeltlich\s+(zur\s+verfuegung)",
|
||||||
# Vertragsleistung / Service-Level (intern)
|
# Vertragsleistung / Service-Level (intern)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Heuristic-coverage tests for mc_audit_type.classify_mc_audit_type.
|
||||||
|
|
||||||
|
Hand-kuratierte Beispiele aus DSGVO-/AI-Act-/eCall-Kontext, die nahe an
|
||||||
|
den real-world FAILs aus VW-/BMW-Audits liegen. Wenn ein Pattern hier
|
||||||
|
fehlschlaegt, fehlt _PROCESS_INTERNAL_PATTERNS bzw. _VERIFIABLE_PATTERNS
|
||||||
|
eine entsprechende Regel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from compliance.services.mc_audit_type import classify_mc_audit_type
|
||||||
|
|
||||||
|
|
||||||
|
VERIFIABLE_CASES = [
|
||||||
|
("Datenschutzerklaerung nennt Kontaktdaten des Verantwortlichen",
|
||||||
|
"Steht im Impressum / DSE der Name + Adresse + Email?"),
|
||||||
|
("Cookie-Banner enthaelt Ablehnen-Button",
|
||||||
|
"Ist im Cookie-Banner ein 'Ablehnen' Button sichtbar?"),
|
||||||
|
("Datenschutzerklaerung verweist auf Recht auf Auskunft",
|
||||||
|
"Wird in der Datenschutzerklaerung Art. 15 DSGVO erwaehnt?"),
|
||||||
|
("Cookie-Richtlinie listet alle Drittanbieter",
|
||||||
|
"Sind alle Vendors in der Cookie-Richtlinie aufgefuehrt?"),
|
||||||
|
("Impressum nennt Telefonnummer",
|
||||||
|
"Steht im Impressum eine Kontakttelefonnummer?"),
|
||||||
|
("Datenschutzerklaerung nennt Speicherdauer",
|
||||||
|
"Sind in der DSE die Aufbewahrungsfristen angegeben?"),
|
||||||
|
("Cookie-Banner Save-Label entspricht UWG-Vorgabe",
|
||||||
|
"Ist der Speichern-Button im Banner korrekt beschriftet?"),
|
||||||
|
]
|
||||||
|
|
||||||
|
PROCESS_INTERNAL_CASES = [
|
||||||
|
("Mitarbeiter-Schulung zu Datenschutz durchgefuehrt",
|
||||||
|
"Wurden alle Mitarbeiter zum Thema Datenschutz geschult?"),
|
||||||
|
("AVV mit allen Auftragsverarbeitern abgeschlossen",
|
||||||
|
"Liegt fuer jeden Dienst ein Auftragsverarbeitungsvertrag vor?"),
|
||||||
|
("TOM dokumentiert und implementiert",
|
||||||
|
"Sind die technisch-organisatorischen Massnahmen umgesetzt?"),
|
||||||
|
("DSFA fuer Hochrisiko-Verarbeitung durchgefuehrt",
|
||||||
|
"Wurde eine DSFA durchgefuehrt und dokumentiert?"),
|
||||||
|
("Rollenkonzept eingerichtet",
|
||||||
|
"Existiert ein Berechtigungskonzept mit klaren Rollen?"),
|
||||||
|
("Pseudonymisierung von Daten implementiert",
|
||||||
|
"Wird Pseudonymisierung aktiv eingesetzt?"),
|
||||||
|
("Backup-Strategie eingerichtet",
|
||||||
|
"Sind Backups vorhanden und werden regelmaessig getestet?"),
|
||||||
|
("Abschaltung der Standortdatenverarbeitung kostenfrei ermoeglichen",
|
||||||
|
"Kann der Nutzer die Standortdatenverarbeitung kostenfrei abschalten?"),
|
||||||
|
("Ausnahmen bei bereits vorhandenen Informationen dokumentieren",
|
||||||
|
"Werden Ausnahmen intern dokumentiert?"),
|
||||||
|
("Sensibilisierung neue Mitarbeiter",
|
||||||
|
"Erhalten neue Mitarbeiter eine Awareness-Schulung?"),
|
||||||
|
("72-Stunden-Meldung an Aufsichtsbehoerde eingehalten",
|
||||||
|
"Wird die Meldepflicht innerhalb von 72 Stunden umgesetzt?"),
|
||||||
|
]
|
||||||
|
|
||||||
|
DOC_INTERNAL_CASES = [
|
||||||
|
("Verzeichnis von Verarbeitungstaetigkeiten gefuehrt",
|
||||||
|
"Existiert ein VVT mit allen Pflichtangaben?"),
|
||||||
|
("Subprozessor-Liste aktualisiert",
|
||||||
|
"Wird die Sub-Prozessor-Liste gepflegt?"),
|
||||||
|
("Auftragsverarbeitungsverzeichnis vollstaendig",
|
||||||
|
"Sind alle AVVs im Auftragsverarbeitungsverzeichnis erfasst?"),
|
||||||
|
("Aufbewahrungskonzept dokumentiert",
|
||||||
|
"Existiert ein dokumentiertes Aufbewahrungskonzept?"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title,question", VERIFIABLE_CASES)
|
||||||
|
def test_verifiable_cases(title: str, question: str) -> None:
|
||||||
|
"""These MCs MUST be classified as verifiable — they describe things
|
||||||
|
visible from the outside (website / banner / DSE)."""
|
||||||
|
result = classify_mc_audit_type(title, question)
|
||||||
|
assert result == "verifiable", (
|
||||||
|
f"Expected 'verifiable' for {title!r}, got {result!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title,question", PROCESS_INTERNAL_CASES)
|
||||||
|
def test_process_internal_cases(title: str, question: str) -> None:
|
||||||
|
"""These MCs MUST be classified as process_internal — they describe
|
||||||
|
internal customer processes that we cannot verify from the outside."""
|
||||||
|
result = classify_mc_audit_type(title, question)
|
||||||
|
assert result == "process_internal", (
|
||||||
|
f"Expected 'process_internal' for {title!r}, got {result!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title,question", DOC_INTERNAL_CASES)
|
||||||
|
def test_doc_internal_cases(title: str, question: str) -> None:
|
||||||
|
"""These MCs MUST be classified as doc_internal — they describe
|
||||||
|
internal documentation (VVT, AVV-Verzeichnis) we cannot inspect."""
|
||||||
|
result = classify_mc_audit_type(title, question)
|
||||||
|
assert result == "doc_internal", (
|
||||||
|
f"Expected 'doc_internal' for {title!r}, got {result!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_input_is_ambiguous() -> None:
|
||||||
|
assert classify_mc_audit_type("", "") == "ambiguous"
|
||||||
|
assert classify_mc_audit_type(None, None) == "ambiguous"
|
||||||
@@ -80,21 +80,33 @@ class CMPCapture:
|
|||||||
data = await _parse_json_response(response)
|
data = await _parse_json_response(response)
|
||||||
if data is None:
|
if data is None:
|
||||||
return
|
return
|
||||||
# Skip tiny payloads — real CMP cookie policies are ≥5KB.
|
|
||||||
# A 4KB JSON of cookie-shaped data is almost never the policy.
|
|
||||||
try:
|
try:
|
||||||
size_kb = len(json.dumps(data)) // 1024
|
size_kb = len(json.dumps(data)) // 1024
|
||||||
except Exception:
|
except Exception:
|
||||||
size_kb = 0
|
size_kb = 0
|
||||||
if size_kb < 5:
|
|
||||||
return
|
|
||||||
from services.cmp_heuristic import looks_like_cookie_policy
|
from services.cmp_heuristic import looks_like_cookie_policy
|
||||||
if looks_like_cookie_policy(data):
|
matched = looks_like_cookie_policy(data)
|
||||||
|
if matched and size_kb >= 5:
|
||||||
self.payloads.append(("_heuristic", data))
|
self.payloads.append(("_heuristic", data))
|
||||||
logger.info(
|
logger.info(
|
||||||
"CMP captured: _heuristic (%s, ~%dKB)",
|
"CMP captured: _heuristic (%s, ~%dKB)",
|
||||||
url[:120], size_kb,
|
url[:120], size_kb,
|
||||||
)
|
)
|
||||||
|
elif size_kb >= 3:
|
||||||
|
# Phase-0-Diagnose-Log: JSON-Response die als CMP-Kandidat
|
||||||
|
# ueberlebt hat, aber heuristic OR size-threshold abgelehnt
|
||||||
|
# wurde. Zeigt beim naechsten VW/BMW/... Run welche Endpoints
|
||||||
|
# uebersehen werden — schneller Pattern-Add ohne raten.
|
||||||
|
top_keys = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
top_keys = list(data.keys())[:8]
|
||||||
|
elif isinstance(data, list) and data and isinstance(data[0], dict):
|
||||||
|
top_keys = list(data[0].keys())[:8]
|
||||||
|
logger.info(
|
||||||
|
"CMP candidate skipped: url=%s size=%dKB heuristic=%s "
|
||||||
|
"top_keys=%s",
|
||||||
|
url[:120], size_kb, matched, top_keys,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("CMP listener error: %s", e)
|
logger.debug("CMP listener error: %s", e)
|
||||||
|
|
||||||
|
|||||||
@@ -256,7 +256,8 @@ async def archive_legal_document(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/documents/{cid}/history")
|
@router.get("/api/v1/documents/{cid}/history")
|
||||||
|
@router.get("/documents/{cid}/history") # legacy path, kept for backwards compatibility
|
||||||
async def get_document_history(cid: str):
|
async def get_document_history(cid: str):
|
||||||
"""Follow the parent_cid chain to reconstruct version history."""
|
"""Follow the parent_cid chain to reconstruct version history."""
|
||||||
history = []
|
history = []
|
||||||
@@ -285,3 +286,99 @@ async def get_document_history(cid: str):
|
|||||||
break
|
break
|
||||||
|
|
||||||
return {"cid": cid, "history": history, "depth": len(history)}
|
return {"cid": cid, "history": history, "depth": len(history)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/documents/{cid_a}/diff/{cid_b}")
|
||||||
|
async def diff_documents(cid_a: str, cid_b: str):
|
||||||
|
"""
|
||||||
|
Compare two DSMS document versions by their CIDs.
|
||||||
|
|
||||||
|
Returns a unified diff of the textual content when both documents are
|
||||||
|
text-decodable (UTF-8). For binary documents the response indicates
|
||||||
|
"binary" and returns just the metadata differences. Used by the Audit
|
||||||
|
Timeline UI to render "what changed between V2 and V3 of CE-Akte X".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw_a = await ipfs_cat(cid_a)
|
||||||
|
raw_b = await ipfs_cat(cid_b)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"error": f"could not fetch one of the CIDs: {exc}", "cid_a": cid_a, "cid_b": cid_b}
|
||||||
|
|
||||||
|
try:
|
||||||
|
pkg_a = json.loads(raw_a)
|
||||||
|
pkg_b = json.loads(raw_b)
|
||||||
|
except Exception:
|
||||||
|
# Documents are not the wrapped-package JSON shape — treat as raw.
|
||||||
|
pkg_a = {"metadata": {}, "content_base64": ""}
|
||||||
|
pkg_b = {"metadata": {}, "content_base64": ""}
|
||||||
|
|
||||||
|
meta_a = pkg_a.get("metadata", {}) or {}
|
||||||
|
meta_b = pkg_b.get("metadata", {}) or {}
|
||||||
|
meta_diff = _diff_metadata(meta_a, meta_b)
|
||||||
|
|
||||||
|
# Try to decode the content. The Archive flow stores files as base64 in
|
||||||
|
# `content_base64`; older payloads may use `content` (utf-8 text).
|
||||||
|
text_a, text_b, is_binary = _extract_texts(pkg_a, pkg_b)
|
||||||
|
|
||||||
|
if is_binary:
|
||||||
|
return {
|
||||||
|
"cid_a": cid_a,
|
||||||
|
"cid_b": cid_b,
|
||||||
|
"kind": "binary",
|
||||||
|
"metadata_diff": meta_diff,
|
||||||
|
"note": "Binary payload — text diff omitted. Compare via the rendered tech-file export instead.",
|
||||||
|
}
|
||||||
|
|
||||||
|
diff_lines = list(
|
||||||
|
_unified_diff(text_a.splitlines(), text_b.splitlines(), fromfile=cid_a, tofile=cid_b, lineterm="")
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"cid_a": cid_a,
|
||||||
|
"cid_b": cid_b,
|
||||||
|
"kind": "text",
|
||||||
|
"metadata_diff": meta_diff,
|
||||||
|
"diff": "\n".join(diff_lines),
|
||||||
|
"added_lines": sum(1 for ln in diff_lines if ln.startswith("+") and not ln.startswith("+++")),
|
||||||
|
"removed_lines": sum(1 for ln in diff_lines if ln.startswith("-") and not ln.startswith("---")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _diff_metadata(a: dict, b: dict) -> dict:
|
||||||
|
"""Return per-field change list: {field: {"old": ..., "new": ...}}."""
|
||||||
|
keys = set(a.keys()) | set(b.keys())
|
||||||
|
changes = {}
|
||||||
|
for k in sorted(keys):
|
||||||
|
if a.get(k) != b.get(k):
|
||||||
|
changes[k] = {"old": a.get(k), "new": b.get(k)}
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_texts(pkg_a: dict, pkg_b: dict) -> tuple[str, str, bool]:
|
||||||
|
"""Return (text_a, text_b, is_binary). Falls back to base64-decode."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
def to_text(pkg: dict) -> tuple[str, bool]:
|
||||||
|
if isinstance(pkg.get("content"), str):
|
||||||
|
return pkg["content"], False
|
||||||
|
b64 = pkg.get("content_base64")
|
||||||
|
if not b64:
|
||||||
|
return "", False
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(b64)
|
||||||
|
except Exception:
|
||||||
|
return "", True
|
||||||
|
try:
|
||||||
|
return raw.decode("utf-8"), False
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return "", True
|
||||||
|
|
||||||
|
text_a, bin_a = to_text(pkg_a)
|
||||||
|
text_b, bin_b = to_text(pkg_b)
|
||||||
|
return text_a, text_b, (bin_a or bin_b)
|
||||||
|
|
||||||
|
|
||||||
|
def _unified_diff(a, b, fromfile, tofile, lineterm):
|
||||||
|
"""Tiny shim around difflib.unified_diff so the function reads cleanly."""
|
||||||
|
import difflib
|
||||||
|
|
||||||
|
return difflib.unified_diff(a, b, fromfile=fromfile, tofile=tofile, lineterm=lineterm, n=2)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
Tests for the version-chain diff endpoint added in DSMS Stufe 3.
|
||||||
|
|
||||||
|
Mocks ipfs_cat so the test does not require a running IPFS node.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(metadata: dict, content_text: str) -> str:
|
||||||
|
"""Mimic the JSON envelope that routers.documents.ipfs_cat returns."""
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"metadata": metadata,
|
||||||
|
"content_base64": base64.b64encode(content_text.encode("utf-8")).decode("ascii"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diff_text_documents_returns_unified_diff():
|
||||||
|
pkg_a = _wrap({"version": "1", "document_type": "ce_techfile"}, "alpha\nbeta\ngamma\n")
|
||||||
|
pkg_b = _wrap({"version": "2", "document_type": "ce_techfile"}, "alpha\nDELTA\ngamma\n")
|
||||||
|
|
||||||
|
async def fake_cat(cid: str):
|
||||||
|
return pkg_a if cid == "cidA" else pkg_b
|
||||||
|
|
||||||
|
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||||
|
resp = client.get("/api/v1/documents/cidA/diff/cidB")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["kind"] == "text"
|
||||||
|
assert body["cid_a"] == "cidA"
|
||||||
|
assert body["cid_b"] == "cidB"
|
||||||
|
assert body["added_lines"] >= 1
|
||||||
|
assert body["removed_lines"] >= 1
|
||||||
|
assert "DELTA" in body["diff"]
|
||||||
|
assert body["metadata_diff"] == {"version": {"old": "1", "new": "2"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diff_binary_documents_returns_metadata_only():
|
||||||
|
# Use raw bytes that are not utf-8 decodable
|
||||||
|
invalid_utf8 = b"\xff\xfe\xfd\xfc"
|
||||||
|
pkg_a = json.dumps(
|
||||||
|
{"metadata": {"version": "1"}, "content_base64": base64.b64encode(invalid_utf8).decode()}
|
||||||
|
)
|
||||||
|
pkg_b = json.dumps(
|
||||||
|
{"metadata": {"version": "2"}, "content_base64": base64.b64encode(invalid_utf8 + b"\x00").decode()}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_cat(cid: str):
|
||||||
|
return pkg_a if cid == "cidA" else pkg_b
|
||||||
|
|
||||||
|
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||||
|
resp = client.get("/api/v1/documents/cidA/diff/cidB")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["kind"] == "binary"
|
||||||
|
assert body["metadata_diff"] == {"version": {"old": "1", "new": "2"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_diff_handles_fetch_error():
|
||||||
|
async def fake_cat(cid: str):
|
||||||
|
raise RuntimeError("not pinned")
|
||||||
|
|
||||||
|
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||||
|
resp = client.get("/api/v1/documents/cidA/diff/cidB")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert "error" in body
|
||||||
|
assert body["cid_a"] == "cidA"
|
||||||
|
assert body["cid_b"] == "cidB"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_endpoint_follows_parent_chain():
|
||||||
|
"""Sanity check that the existing history endpoint still works after route alias."""
|
||||||
|
|
||||||
|
chain = {
|
||||||
|
"v3": _wrap({"version": "3", "parent_cid": "v2"}, "x"),
|
||||||
|
"v2": _wrap({"version": "2", "parent_cid": "v1"}, "x"),
|
||||||
|
"v1": _wrap({"version": "1", "parent_cid": None}, "x"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_cat(cid: str):
|
||||||
|
return chain[cid]
|
||||||
|
|
||||||
|
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||||
|
resp = client.get("/api/v1/documents/v3/history")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["depth"] == 3
|
||||||
|
assert [h["version"] for h in body["history"]] == ["3", "2", "1"]
|
||||||
Reference in New Issue
Block a user