From 47f54d346197397e70b038f1c513a9d0fe818742 Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Sat, 11 Jul 2026 12:48:01 +0200 Subject: [PATCH] fix(orion): correct report numbering toc and first page --- .../app/modules/orion/components/chapters.py | 97 +++++++++++++------ .../backend/mercury/app/modules/orion/html.py | 11 ++- .../mercury/app/modules/orion/service.py | 75 ++++++-------- .../app/modules/orion/template_service.py | 96 +++++++++--------- .../app/modules/orion/templates/report.css | 42 +++++++- .../app/modules/orion/templates/report.py | 12 ++- .../mercury/tests/test_validation_workflow.py | 87 +++++++++++++++-- 7 files changed, 283 insertions(+), 137 deletions(-) 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"{head}{body}
    " -def section(chapter_id: str, title: str, body: str) -> str: - return f"

    {text(title)}

    {body}
    " +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