diff --git a/validation-suite/backend/mercury/app/modules/orion/components/chapters.py b/validation-suite/backend/mercury/app/modules/orion/components/chapters.py
index 131a4fea..fbf85cf5 100644
--- a/validation-suite/backend/mercury/app/modules/orion/components/chapters.py
+++ b/validation-suite/backend/mercury/app/modules/orion/components/chapters.py
@@ -27,6 +27,22 @@ def render_template_text(content: str, context: ReportContext) -> str:
return rendered
+class NumberedComponent(ReportComponent):
+ anchor = ""
+ title = ""
+
+ def __init__(self, anchor: str | None = None, title: str | None = None, number: str | None = None) -> None:
+ if anchor is not None:
+ self.anchor = anchor
+ if title is not None:
+ self.title = title
+ self.number = number
+
+ @property
+ def bookmark_label(self) -> str:
+ return f"{self.number} {self.title}" if self.number else self.title
+
+
class CoverComponent(ReportComponent):
anchor = "cover"
title = "Deckblatt"
@@ -63,7 +79,7 @@ class CoverComponent(ReportComponent):
'
SCHUBAMED
Validation Suite
Medizintechnik und Validierung
'
f'
'
""
- "PRUEFBERICHT ZUR VALIDIERUNG
"
+ "PRÜFBERICHT ZUR VALIDIERUNG
"
'Funktions- und Leistungsqualifikation Klein-Sterilisator
'
f'{text(context.customer.name)}
'
f"{rows}"
@@ -76,17 +92,27 @@ class TocComponent(ReportComponent):
anchor = "toc"
title = "Inhaltsverzeichnis"
- def __init__(self, components: list[ReportComponent]) -> None:
- self.components = components
+ def __init__(self, sections: list[dict]) -> None:
+ self.sections = sections
def render(self, context: ReportContext) -> str:
links = "".join(
- f'{text(component.title)}'
- for component in self.components
- if component.anchor not in {"cover", "toc"}
+ self._toc_item(item)
+ for item in self.sections
+ if item.get("toc")
)
return section(self.anchor, self.title, f'{links}
')
+ def _toc_item(self, item: dict) -> str:
+ number = item.get("number")
+ title = item.get("title")
+ label = f"{number} {title}" if number else str(title)
+ depth = str(number).count(".") + 1 if number else 2
+ return (
+ f''
+ f'{text(label)}'
+ )
+
class SummaryComponent(ReportComponent):
anchor = "summary"
@@ -121,17 +147,29 @@ class SummaryComponent(ReportComponent):
class StaticTextComponent(ReportComponent):
- def __init__(self, anchor: str, title: str, body: str = "nicht erfasst", block_key: str | None = None) -> None:
+ def __init__(
+ self,
+ anchor: str,
+ title: str,
+ body: str = "nicht erfasst",
+ block_key: str | None = None,
+ number: str | None = None,
+ ) -> None:
self.anchor = anchor
self.title = title
self.body = body
self.block_key = block_key
+ self.number = number
+
+ @property
+ def bookmark_label(self) -> str:
+ return f"{self.number} {self.title}" if self.number else self.title
def render(self, context: ReportContext) -> str:
if self.block_key and self.block_key in context.text_blocks:
content = render_template_text(context.text_blocks[self.block_key].content, context)
- return section(self.anchor, self.title, f"{paragraph(content)}
")
- return section(self.anchor, self.title, f"{paragraph(self.body)}
")
+ return section(self.anchor, self.title, f"{paragraph(content)}
", self.bookmark_label)
+ return section(self.anchor, self.title, f"{paragraph(self.body)}
", self.bookmark_label)
class CustomerComponent(ReportComponent):
@@ -182,14 +220,14 @@ class CustomerComponent(ReportComponent):
return section(self.anchor, self.title, body)
-class DeviceComponent(ReportComponent):
+class DeviceComponent(NumberedComponent):
anchor = "device"
title = "Geraet"
def render(self, context: ReportContext) -> str:
device = context.device
if device is None:
- return section(self.anchor, self.title, "")
+ return section(self.anchor, self.title, "nicht erfasst
", self.bookmark_label)
body = definition_list(
[
("Hersteller", device.manufacturer),
@@ -212,10 +250,10 @@ class DeviceComponent(ReportComponent):
("Lieferant", device.supplier),
]
)
- return section(self.anchor, self.title, body)
+ return section(self.anchor, self.title, body, self.bookmark_label)
-class EquipmentComponent(ReportComponent):
+class EquipmentComponent(NumberedComponent):
anchor = "equipment"
title = "Pruefmittel"
@@ -247,10 +285,11 @@ class EquipmentComponent(ReportComponent):
],
rows,
),
+ self.bookmark_label,
)
-class EnvironmentComponent(ReportComponent):
+class EnvironmentComponent(NumberedComponent):
anchor = "environment"
title = "Umgebungsbedingungen"
@@ -269,10 +308,10 @@ class EnvironmentComponent(ReportComponent):
]
if rows:
body += table(["Pruefpunkt", "Bewertung", "Kommentar"], rows)
- return section(self.anchor, self.title, body)
+ return section(self.anchor, self.title, body, self.bookmark_label)
-class ChecklistComponent(ReportComponent):
+class ChecklistComponent(NumberedComponent):
anchor = "checklists"
title = "Dokumentations- und Leistungschecklisten"
@@ -286,7 +325,7 @@ class ChecklistComponent(ReportComponent):
performance = context.validation.performance_checklist or []
body = "Dokumentation
" + self._render_items(documentation)
body += "Leistung
" + self._render_items(performance)
- return section(self.anchor, self.title, body)
+ return section(self.anchor, self.title, body, self.bookmark_label)
def _render_items(self, items: list[dict]) -> str:
rows = [
@@ -296,7 +335,7 @@ class ChecklistComponent(ReportComponent):
return table(["Nr.", "Pruefpunkt", "Bewertung", "Kommentar"], rows)
-class ProgramComponent(ReportComponent):
+class ProgramComponent(NumberedComponent):
anchor = "programs"
title = "Programme"
@@ -306,10 +345,10 @@ class ProgramComponent(ReportComponent):
[index + 1, item.get("name"), "Eigenes Programm" if item.get("custom") else "Standard"]
for index, item in enumerate(programs)
]
- return section(self.anchor, self.title, table(["Nr.", "Programm", "Typ"], rows))
+ return section(self.anchor, self.title, table(["Nr.", "Programm", "Typ"], rows), self.bookmark_label)
-class LoadingComponent(ReportComponent):
+class LoadingComponent(NumberedComponent):
anchor = "loading"
title = "Beladungsmuster"
@@ -327,10 +366,11 @@ class LoadingComponent(ReportComponent):
self.anchor,
self.title,
table(["Testlauf", "Beladungsmuster", "Beschreibung", "Bilder"], rows),
+ self.bookmark_label,
)
-class MeasurementComponent(ReportComponent):
+class MeasurementComponent(NumberedComponent):
anchor = "measurements"
title = "Messdaten"
@@ -351,6 +391,7 @@ class MeasurementComponent(ReportComponent):
self.anchor,
self.title,
table(["Testlauf", "Messwert", "Wert", "Einheit", "Quelle", "Sicherheit"], rows, "compact"),
+ self.bookmark_label,
)
rows = [
[
@@ -394,10 +435,10 @@ class MeasurementComponent(ReportComponent):
if winlog_rows:
body += "Winlog-Dateien
" + table(["Bereich", "Datei", "Typ"], winlog_rows)
body += "Messdaten noch nicht bestätigt.
"
- return section(self.anchor, self.title, body)
+ return section(self.anchor, self.title, body, self.bookmark_label)
-class DryingComponent(ReportComponent):
+class DryingComponent(NumberedComponent):
anchor = "drying"
title = "Trocknung"
@@ -412,10 +453,10 @@ class DryingComponent(ReportComponent):
("Bemerkung", data.get("comment")),
]
)
- return section(self.anchor, self.title, body)
+ return section(self.anchor, self.title, body, self.bookmark_label)
-class RecommendationComponent(ReportComponent):
+class RecommendationComponent(NumberedComponent):
anchor = "recommendations"
title = "Empfehlungen und Auflagen"
@@ -424,10 +465,10 @@ class RecommendationComponent(ReportComponent):
[item.get("number"), item.get("text"), item.get("deadline"), item.get("status")]
for item in context.validation.recommendations or []
]
- return section(self.anchor, self.title, table(["Nr.", "Text", "Frist", "Status"], rows))
+ return section(self.anchor, self.title, table(["Nr.", "Text", "Frist", "Status"], rows), self.bookmark_label)
-class AttachmentComponent(ReportComponent):
+class AttachmentComponent(NumberedComponent):
anchor = "attachments"
title = "Bilder und Anlagen"
@@ -456,7 +497,7 @@ class AttachmentComponent(ReportComponent):
body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows)
if figures:
body += '' + "".join(figures) + "
"
- return section(self.anchor, self.title, body)
+ return section(self.anchor, self.title, body, self.bookmark_label)
def _image_source(self, item: dict) -> str | None:
content_type = str(item.get("content_type") or "")
diff --git a/validation-suite/backend/mercury/app/modules/orion/html.py b/validation-suite/backend/mercury/app/modules/orion/html.py
index 052d59c4..3c31c0e7 100644
--- a/validation-suite/backend/mercury/app/modules/orion/html.py
+++ b/validation-suite/backend/mercury/app/modules/orion/html.py
@@ -41,5 +41,12 @@ def table(headers: list[str], rows: list[list[Any]], css_class: str = "") -> str
return f""
-def section(chapter_id: str, title: str, body: str) -> str:
- return f""
+def section(chapter_id: str, title: str, body: str, bookmark_label: str | None = None) -> str:
+ bookmark_attr = (
+ f' data-bookmark-label="{text(bookmark_label)}"' if bookmark_label is not None else ""
+ )
+ heading = bookmark_label or title
+ return (
+ f''
+ f'{text(heading)}
{body}'
+ )
diff --git a/validation-suite/backend/mercury/app/modules/orion/service.py b/validation-suite/backend/mercury/app/modules/orion/service.py
index 929792cd..8b9720e2 100644
--- a/validation-suite/backend/mercury/app/modules/orion/service.py
+++ b/validation-suite/backend/mercury/app/modules/orion/service.py
@@ -26,6 +26,7 @@ from app.modules.orion.components import (
)
from app.modules.orion.context import OrionContextBuilder, ReportContext
from app.modules.orion.templates.report import render_document
+from app.modules.orion.template_service import REPORT_SECTIONS
logger = logging.getLogger(__name__)
@@ -52,51 +53,35 @@ class OrionReportService:
return output_path
def _components(self) -> list[ReportComponent]:
- chapters: list[ReportComponent] = [
- StaticTextComponent("bq", "1 Funktionsqualifikation (BQ)", "nicht erfasst"),
- StaticTextComponent("bq-goal", "1.1 Anlass und Ziel der Prüfung", block_key="bq_goal"),
- StaticTextComponent("legal", "1.2 Gesetzliche Grundlagen", block_key="legal"),
- DeviceComponent(),
- StaticTextComponent("performance", "1.4 Leistungsüberprüfung", block_key="performance"),
- ChecklistComponent(),
- StaticTextComponent("documentation", "1.6 Dokumentation / Kontrolle"),
- StaticTextComponent("work-instructions", "1.7 Arbeitsanweisungen"),
- EnvironmentComponent(),
- StaticTextComponent("batch-control", "1.9 Chargenkontrolle"),
- ProgramComponent(),
- LoadingComponent(),
- StaticTextComponent("reference-load", "1.12 Referenzbeladung Sterilisator"),
- StaticTextComponent("equipment", "2 Eingesetzte Prüfmittel"),
- EquipmentComponent(),
- StaticTextComponent("equipment-thermo", "2.2 Prüfmittel zur thermoelektrischen Untersuchung"),
- StaticTextComponent("configuration", "2.3 Prüfkonfiguration"),
- StaticTextComponent("lq", "3 Leistungsqualifikation (LQ)"),
- StaticTextComponent("thermo-tests", "3.1 Leistungsbeurteilung – Thermoelektrische Prüfungen"),
- MeasurementComponent(),
- StaticTextComponent("run-1", "3.2 Standardbeladung – Testlauf 1"),
- StaticTextComponent("test-1", "3.2.1 Test 1"),
- StaticTextComponent("run-2", "3.3 Standardbeladung – Testlauf 2"),
- StaticTextComponent("test-2", "3.3.1 Test 2"),
- StaticTextComponent("run-3", "3.4 Standardbeladung – Testlauf 3"),
- StaticTextComponent("test-3", "3.4.1 Test 3"),
- StaticTextComponent("results", "4 Ergebnisse der Validierung"),
- StaticTextComponent("results-vacuum", "4.1 Vakuumtest"),
- StaticTextComponent("results-runs", "4.1.2 Testläufe 1 bis 3"),
- DryingComponent(),
- RecommendationComponent(),
- StaticTextComponent("appendix", "5 Anhang"),
- AttachmentComponent(),
- StaticTextComponent("winlog", "5.1 Winlog-Auswertungen"),
- StaticTextComponent("release-docs", "5.2 Freigabedokumentation / Routineprüfungen"),
- StaticTextComponent("indicators", "5.3 Nachweis der umgeschlagenen Indikatoren"),
- StaticTextComponent("cycles", "6 Programmabläufe / Zyklen"),
- StaticTextComponent("practice-certificates", "6.1 Zertifikate Praxis"),
- StaticTextComponent("risk", "7 Risikoeinstufung nach RKI"),
- StaticTextComponent("closing-talk", "7.1 Abschlussgespräch"),
- StaticTextComponent("certificates", "8. Zertifikate"),
- StaticTextComponent("calibration-certificates", "9. Werkskalibrierzertifikate Sensoren"),
- ]
- return [CoverComponent(), SummaryComponent(), TocComponent(chapters), CustomerComponent(), *chapters]
+ chapters = [self._component_for_section(item) for item in REPORT_SECTIONS]
+ return [CoverComponent(), SummaryComponent(), TocComponent(REPORT_SECTIONS), CustomerComponent(), *chapters]
+
+ def _component_for_section(self, item: dict) -> ReportComponent:
+ component = item.get("component")
+ key = str(item["key"])
+ title = str(item["title"])
+ number = item.get("number")
+ if component == "device":
+ return DeviceComponent(key, title, number)
+ if component == "checklist":
+ return ChecklistComponent(key, title, number)
+ if component == "environment":
+ return EnvironmentComponent(key, title, number)
+ if component == "programs":
+ return ProgramComponent(key, title, number)
+ if component == "loading":
+ return LoadingComponent(key, title, number)
+ if component == "equipment":
+ return EquipmentComponent(key, title, number)
+ if component == "measurements":
+ return MeasurementComponent(key, title, number)
+ if component == "drying":
+ return DryingComponent(key, title, number)
+ if component == "recommendations":
+ return RecommendationComponent(key, title, number)
+ if component == "attachments":
+ return AttachmentComponent(key, title, number)
+ return StaticTextComponent(key, title, block_key=item.get("block_key"), number=number)
def _append_pdf_attachments(self, context: ReportContext, output_path: Path) -> None:
pdf_paths = []
diff --git a/validation-suite/backend/mercury/app/modules/orion/template_service.py b/validation-suite/backend/mercury/app/modules/orion/template_service.py
index 31eea7c2..5896097f 100644
--- a/validation-suite/backend/mercury/app/modules/orion/template_service.py
+++ b/validation-suite/backend/mercury/app/modules/orion/template_service.py
@@ -19,48 +19,50 @@ TEMPLATE_NAME = "Erstvalidierung Klein-Sterilisator"
TEMPLATE_VERSION = "1.0"
REFERENCE_PATH = "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx"
-SECTION_DEFINITIONS: list[tuple[str, str | None, str, bool]] = [
- ("bq", "1", "Funktionsqualifikation (BQ)", True),
- ("bq_goal", "1.1", "Anlass und Ziel der Prüfung", False),
- ("legal", "1.2", "Gesetzliche Grundlagen", False),
- ("device", "1.3", "Angaben zum Gerät", False),
- ("performance", "1.4", "Leistungsüberprüfung", False),
- ("performance_checklist", "1.5", "Checkliste Leistungsanforderung", False),
- ("documentation", "1.6", "Dokumentation / Kontrolle", False),
- ("work_instructions", "1.7", "Arbeitsanweisungen", False),
- ("environment", "1.8", "Umgebungsbedingungen", False),
- ("batch_control", "1.9", "Chargenkontrolle", False),
- ("programs", "1.10", "Beschreibung der verwendeten Programme", False),
- ("loading", "1.11", "Beladungsbeschreibungen", False),
- ("reference_load", "1.12", "Referenzbeladung Sterilisator", False),
- ("equipment", "2", "Eingesetzte Prüfmittel", True),
- ("measurement_devices", "2.1", "Beschreibung der Messgeräte", False),
- ("thermo_equipment", "2.2", "Prüfmittel zur thermoelektrischen Untersuchung", False),
- ("configuration", "2.3", "Prüfkonfiguration", False),
- ("lq", "3", "Leistungsqualifikation (LQ)", True),
- ("thermo_tests", "3.1", "Leistungsbeurteilung – Thermoelektrische Prüfungen", False),
- ("vacuum_test", "3.1.1", "Vakuumtest", False),
- ("run_1", "3.2", "Standardbeladung – Testlauf 1", False),
- ("test_1", "3.2.1", "Test 1", False),
- ("run_2", "3.3", "Standardbeladung – Testlauf 2", False),
- ("test_2", "3.3.1", "Test 2", False),
- ("run_3", "3.4", "Standardbeladung – Testlauf 3", False),
- ("test_3", "3.4.1", "Test 3", False),
- ("results", "4", "Ergebnisse der Validierung", True),
- ("results_vacuum", "4.1", "Vakuumtest", False),
- ("results_runs", "4.1.2", "Testläufe 1 bis 3", False),
- ("drying", None, "Nachweis der Trocknungseigenschaften", False),
- ("recommendations", "4.2", "Empfehlungen und Auflagen", False),
- ("attachments", "5", "Anhang", True),
- ("winlog", "5.1", "Winlog-Auswertungen", False),
- ("release_docs", "5.2", "Freigabedokumentation / Routineprüfungen", False),
- ("indicators", "5.3", "Nachweis der umgeschlagenen Indikatoren", False),
- ("cycles", "6", "Programmabläufe / Zyklen", True),
- ("practice_certificates", "6.1", "Zertifikate Praxis", False),
- ("risk", "7", "Risikoeinstufung nach RKI", True),
- ("closing_talk", "7.1", "Abschlussgespräch", False),
- ("certificates", "8", "Zertifikate", True),
- ("calibration_certificates", "9", "Werkskalibrierzertifikate Sensoren", True),
+REPORT_SECTIONS: list[dict[str, Any]] = [
+ {"key": "functional_qualification", "number": "1", "title": "Funktionsqualifikation (BQ)", "toc": True, "component": "static", "block_key": None},
+ {"key": "purpose", "number": "1.1", "title": "Anlass und Ziel der Prüfung", "toc": True, "component": "static", "block_key": "bq_goal"},
+ {"key": "legal", "number": "1.2", "title": "Gesetzliche Grundlagen", "toc": True, "component": "static", "block_key": "legal"},
+ {"key": "device", "number": "1.3", "title": "Angaben zum Gerät", "toc": True, "component": "device"},
+ {"key": "performance", "number": "1.4", "title": "Leistungsüberprüfung", "toc": True, "component": "static", "block_key": "performance"},
+ {"key": "performance_checklist", "number": "1.5", "title": "Checkliste Leistungsanforderung", "toc": True, "component": "checklist"},
+ {"key": "documentation", "number": "1.6", "title": "Dokumentation / Kontrolle", "toc": True, "component": "static"},
+ {"key": "work_instructions", "number": "1.7", "title": "Arbeitsanweisungen", "toc": True, "component": "static"},
+ {"key": "environment", "number": "1.8", "title": "Umgebungsbedingungen", "toc": True, "component": "environment"},
+ {"key": "batch_control", "number": "1.9", "title": "Chargenkontrolle", "toc": True, "component": "static"},
+ {"key": "programs", "number": "1.10", "title": "Beschreibung der verwendeten Programme", "toc": True, "component": "programs"},
+ {"key": "loading", "number": "1.11", "title": "Beladungsbeschreibungen", "toc": True, "component": "loading"},
+ {"key": "reference_load", "number": "1.12", "title": "Referenzbeladung Sterilisator bei Validierung", "toc": True, "component": "static"},
+ {"key": "equipment_root", "number": "2", "title": "Eingesetzte Prüfmittel", "toc": True, "component": "static"},
+ {"key": "measurement_devices", "number": "2.1", "title": "Beschreibung der Messgeräte", "toc": True, "component": "equipment"},
+ {"key": "thermo_equipment", "number": "2.2", "title": "Prüfmittel zur thermoelektrischen Untersuchung", "toc": True, "component": "static"},
+ {"key": "configuration", "number": "2.3", "title": "Prüfkonfiguration", "toc": True, "component": "static"},
+ {"key": "lq", "number": "3", "title": "Leistungsqualifikation (LQ)", "toc": True, "component": "static"},
+ {"key": "thermo_tests", "number": "3.1", "title": "Leistungsbeurteilung – Thermoelektrische Prüfungen", "toc": True, "component": "static"},
+ {"key": "vacuum_test", "number": "3.1.1", "title": "Vakuumtest", "toc": True, "component": "measurements"},
+ {"key": "run_1", "number": "3.2", "title": "Standardbeladung (1. Durchlauf)", "toc": True, "component": "static"},
+ {"key": "test_1", "number": "3.2.1", "title": "Test 1", "toc": True, "component": "static"},
+ {"key": "run_2", "number": "3.3", "title": "Standardbeladung (2. Durchlauf)", "toc": True, "component": "static"},
+ {"key": "test_2", "number": "3.3.1", "title": "Test 2", "toc": True, "component": "static"},
+ {"key": "run_3", "number": "3.4", "title": "Standardbeladung (3. Durchlauf)", "toc": True, "component": "static"},
+ {"key": "test_3", "number": "3.4.1", "title": "Test 3", "toc": True, "component": "static"},
+ {"key": "results", "number": "4", "title": "Ergebnisse der Validierung", "toc": True, "component": "static"},
+ {"key": "results_vacuum", "number": "4.1", "title": "Vakuumtest", "toc": True, "component": "static"},
+ {"key": "results_runs", "number": "4.1.2", "title": "Testläufe 1 bis 3 Programm 134 °C B", "toc": True, "component": "static"},
+ {"key": "drying", "number": None, "title": "Nachweis der Trocknungseigenschaften Testläufe 1 bis 3", "toc": True, "component": "drying"},
+ {"key": "recommendations", "number": "4.2", "title": "Empfehlungen und Auflagen", "toc": True, "component": "recommendations"},
+ {"key": "appendix", "number": "5", "title": "Anhang", "toc": True, "component": "static"},
+ {"key": "winlog", "number": "5.1", "title": "Vakuumtest / Winlog-Auswertungen", "toc": True, "component": "attachments"},
+ {"key": "bd_empty_chamber", "number": None, "title": "Testlauf Leerkammerprofil / Bowie-Dick", "toc": True, "component": "static"},
+ {"key": "batch_release_docs", "number": None, "title": "Chargen- und Freigabedokumentation", "toc": True, "component": "static"},
+ {"key": "release_docs", "number": "5.2", "title": "Freigabedokumentation / Routineprüfungen", "toc": True, "component": "static"},
+ {"key": "indicators", "number": "5.3", "title": "Nachweis der umgeschlagenen Indikatoren", "toc": True, "component": "static"},
+ {"key": "cycles", "number": "6", "title": "Programmabläufe / Zyklen des Sterilisators", "toc": True, "component": "static"},
+ {"key": "practice_certificates", "number": "6.1", "title": "Zertifikate Praxis", "toc": True, "component": "static"},
+ {"key": "risk", "number": "7", "title": "Risikoeinstufung der Praxis nach RKI", "toc": True, "component": "static"},
+ {"key": "closing_talk", "number": "7.1", "title": "Abschlussgespräch zur Validierung", "toc": True, "component": "static"},
+ {"key": "certificates", "number": "8", "title": "Zertifikate", "toc": True, "component": "static"},
+ {"key": "calibration_certificates", "number": "9", "title": "Werkskalibrierzertifikate Sensoren", "toc": True, "component": "static"},
]
TEXT_BLOCK_HEADINGS = {
@@ -252,15 +254,15 @@ class ReportTemplateService:
def _create_children(self, template: ReportTemplate) -> None:
paragraphs = self._read_docx_paragraphs()
blocks = self._extract_blocks(paragraphs)
- for index, (section_key, number, title, page_break) in enumerate(SECTION_DEFINITIONS, start=1):
+ for index, section in enumerate(REPORT_SECTIONS, start=1):
self.session.add(
ReportSection(
template_id=template.id,
- section_key=section_key,
- number=number,
- title=title,
+ section_key=str(section["key"]),
+ number=section["number"],
+ title=str(section["title"]),
order_index=index,
- page_break_before=page_break,
+ page_break_before=section["number"] in {"1", "2", "3", "4", "5", "6", "7", "8", "9"},
active=True,
)
)
diff --git a/validation-suite/backend/mercury/app/modules/orion/templates/report.css b/validation-suite/backend/mercury/app/modules/orion/templates/report.css
index 5144b649..d459d074 100644
--- a/validation-suite/backend/mercury/app/modules/orion/templates/report.css
+++ b/validation-suite/backend/mercury/app/modules/orion/templates/report.css
@@ -1,16 +1,19 @@
@page {
size: A4;
- margin: 34mm 18mm 24mm 18mm;
- @top-left { content: element(report-header); }
- @bottom-left { content: element(report-footer); }
}
-@page:first {
+@page cover {
margin: 20mm 18mm 20mm 18mm;
@top-left { content: ""; }
@bottom-left { content: ""; }
}
+@page report {
+ margin: 34mm 18mm 24mm 18mm;
+ @top-left { content: element(report-header); }
+ @bottom-left { content: element(report-footer); }
+}
+
* {
box-sizing: border-box;
}
@@ -27,6 +30,7 @@ body {
}
.report-meta {
+ display: none;
string-set: report-number attr(data-report-number), report-version attr(data-report-version), report-date attr(data-report-date);
}
@@ -130,11 +134,16 @@ body {
}
.cover-page {
+ page: cover;
min-height: 245mm;
display: flex;
flex-direction: column;
justify-content: flex-start;
- page-break-after: always;
+ break-after: page;
+}
+
+.report-content {
+ page: report;
}
.cover-top {
@@ -207,6 +216,12 @@ h2 {
line-height: 1.15;
margin: 0 0 7mm 0;
padding-bottom: 4mm;
+ bookmark-level: none;
+}
+
+h2[data-bookmark-label] {
+ bookmark-label: attr(data-bookmark-label);
+ bookmark-level: 1;
}
.chapter + .chapter {
@@ -286,9 +301,26 @@ dd {
.toc-list a {
color: #2E3B40;
+ display: grid;
+ gap: 4mm;
+ grid-template-columns: 1fr 14mm;
text-decoration: none;
}
+.toc-level-2 {
+ padding-left: 6mm;
+}
+
+.toc-level-3,
+.toc-level-4 {
+ padding-left: 12mm;
+}
+
+.toc-list a::after {
+ content: target-counter(attr(href), page);
+ text-align: right;
+}
+
.figure-grid {
display: grid;
gap: 8mm;
diff --git a/validation-suite/backend/mercury/app/modules/orion/templates/report.py b/validation-suite/backend/mercury/app/modules/orion/templates/report.py
index a850608e..f0c95373 100644
--- a/validation-suite/backend/mercury/app/modules/orion/templates/report.py
+++ b/validation-suite/backend/mercury/app/modules/orion/templates/report.py
@@ -38,7 +38,8 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
css = (Path(__file__).resolve().parent / "report.css").read_text(encoding="utf-8")
logo_uri = schubamed_logo_uri()
title = f"Validierungsbericht {context.validation.report_number}"
- chapter_markup = "\n".join(chapters)
+ cover_markup = chapters[0] if chapters else ""
+ content_markup = "\n".join(chapters[1:])
return f"""
@@ -49,9 +50,12 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
-
- {render_report_chrome(context, logo_uri)}
- {chapter_markup}
+ {cover_markup}
+
+
+ {render_report_chrome(context, logo_uri)}
+ {content_markup}
+
"""
diff --git a/validation-suite/backend/mercury/tests/test_validation_workflow.py b/validation-suite/backend/mercury/tests/test_validation_workflow.py
index 2537ac57..c7bbb6d2 100644
--- a/validation-suite/backend/mercury/tests/test_validation_workflow.py
+++ b/validation-suite/backend/mercury/tests/test_validation_workflow.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import re
from datetime import date
from pathlib import Path
@@ -16,7 +17,7 @@ from app.services.validation_workflow import ValidationWorkflowService
from app.schemas.domain import ValidationCreate
from app.modules.orion.service import OrionReportService
from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri
-from app.modules.orion.template_service import ReportTemplateService
+from app.modules.orion.template_service import REPORT_SECTIONS, ReportTemplateService
from app.modules.helios.service import HeliosImportService
@@ -224,9 +225,9 @@ def test_orion_renders_reference_main_chapters(tmp_path):
html = OrionReportService(db, tmp_path).render_html(validation.id)
assert "1 Funktionsqualifikation" in html
- assert "2.2 Prüfmittel" in html
+ assert "2.2 Prüfmittel zur thermoelektrischen Untersuchung" in html
assert "4 Ergebnisse der Validierung" in html
- assert "9. Werkskalibrierzertifikate Sensoren" in html
+ assert "9 Werkskalibrierzertifikate Sensoren" in html
def test_orion_renders_uploaded_images_as_real_images(tmp_path):
@@ -339,15 +340,89 @@ def test_orion_contains_required_reference_sections_and_three_runs(tmp_path):
"Zusammenfassendes Ergebnis der Validierung",
"1.1 Anlass und Ziel der Prüfung",
"1.2 Gesetzliche Grundlagen",
- "3.2 Standardbeladung – Testlauf 1",
- "3.3 Standardbeladung – Testlauf 2",
- "3.4 Standardbeladung – Testlauf 3",
+ "3.2 Standardbeladung (1. Durchlauf)",
+ "3.3 Standardbeladung (2. Durchlauf)",
+ "3.4 Standardbeladung (3. Durchlauf)",
]:
assert title in html
assert "Neutraler Logo-Platzhalter" not in html
assert "report-header-brand" in html
+def test_orion_toc_uses_central_section_numbers_and_titles(tmp_path):
+ db = session()
+ customer, location, device = seed(db)
+ validation = valid_validation(customer, location, device)
+ db.add(validation)
+ db.flush()
+
+ html = OrionReportService(db, tmp_path).render_html(validation.id)
+
+ expected = [
+ "1.3 Angaben zum Gerät",
+ "1.8 Umgebungsbedingungen",
+ "2.3 Prüfkonfiguration",
+ "3.1.1 Vakuumtest",
+ "4.2 Empfehlungen und Auflagen",
+ "9 Werkskalibrierzertifikate Sensoren",
+ ]
+ for label in expected:
+ assert label in html
+ assert "3.1.1 Leistungsqualifikation" not in html
+ assert "4.2 Trocknung" not in html
+ assert html.index("1 Funktionsqualifikation") < html.index("9 Werkskalibrierzertifikate")
+
+
+def test_orion_html_has_single_cover_before_report_content(tmp_path):
+ db = session()
+ customer, location, device = seed(db)
+ validation = valid_validation(customer, location, device)
+ db.add(validation)
+ db.flush()
+
+ html = OrionReportService(db, tmp_path).render_html(validation.id)
+
+ assert html.count('class="cover-page"') == 1
+ assert html.index('class="cover-page"') < html.index('class="report-content"')
+ assert html.index('class="cover-page"') < html.index('class="report-meta"')
+ assert "PRÜFBERICHT ZUR VALIDIERUNG
" in html
+
+
+def test_orion_pdf_first_page_is_cover_not_blank(tmp_path):
+ from pypdf import PdfReader
+
+ db = session()
+ customer, location, device = seed(db)
+ validation = valid_validation(customer, location, device)
+ db.add(validation)
+ db.flush()
+
+ pdf_path = OrionReportService(db, tmp_path).render_pdf(validation.id)
+ first_page_text = PdfReader(str(pdf_path)).pages[0].extract_text() or ""
+ normalized_text = re.sub(r"\s+", "", first_page_text.upper())
+
+ assert "PRÜFBERICHTZURVALIDIERUNG" in normalized_text
+ assert "FUNKTIONS-UNDLEISTUNGSQUALIFIKATION" in normalized_text
+
+
+def test_orion_bookmark_labels_are_created_from_central_sections(tmp_path):
+ db = session()
+ customer, location, device = seed(db)
+ validation = valid_validation(customer, location, device)
+ db.add(validation)
+ db.flush()
+
+ html = OrionReportService(db, tmp_path).render_html(validation.id)
+ bookmark_count = html.count("data-bookmark-label=")
+ toc_count = len([item for item in REPORT_SECTIONS if item.get("toc")])
+
+ assert bookmark_count == toc_count
+ for item in REPORT_SECTIONS:
+ number = item.get("number")
+ label = f"{number} {item['title']}" if number else item["title"]
+ assert f'data-bookmark-label="{label}"' in html
+
+
def test_orion_appends_pdf_attachments(tmp_path):
from pypdf import PdfReader, PdfWriter