fix(orion): correct report numbering toc and first page

This commit is contained in:
Schubert Ferenc 2026-07-11 12:48:01 +02:00
parent 503b343070
commit 47f54d3461
7 changed files with 283 additions and 137 deletions

View file

@ -27,6 +27,22 @@ def render_template_text(content: str, context: ReportContext) -> str:
return rendered 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): class CoverComponent(ReportComponent):
anchor = "cover" anchor = "cover"
title = "Deckblatt" title = "Deckblatt"
@ -63,7 +79,7 @@ class CoverComponent(ReportComponent):
'<div class="company-address">SCHUBAMED<br>Validation Suite<br>Medizintechnik und Validierung</div>' '<div class="company-address">SCHUBAMED<br>Validation Suite<br>Medizintechnik und Validierung</div>'
f'<img class="cover-logo" src="{logo_uri}" alt="SCHUBAMED Validation Suite">' f'<img class="cover-logo" src="{logo_uri}" alt="SCHUBAMED Validation Suite">'
"</div>" "</div>"
"<h1>PRUEFBERICHT ZUR VALIDIERUNG</h1>" "<h1>PRÜFBERICHT ZUR VALIDIERUNG</h1>"
'<p class="cover-subtitle">Funktions- und Leistungsqualifikation Klein-Sterilisator</p>' '<p class="cover-subtitle">Funktions- und Leistungsqualifikation Klein-Sterilisator</p>'
f'<p class="cover-subtitle">{text(context.customer.name)}</p>' f'<p class="cover-subtitle">{text(context.customer.name)}</p>'
f"{rows}" f"{rows}"
@ -76,17 +92,27 @@ class TocComponent(ReportComponent):
anchor = "toc" anchor = "toc"
title = "Inhaltsverzeichnis" title = "Inhaltsverzeichnis"
def __init__(self, components: list[ReportComponent]) -> None: def __init__(self, sections: list[dict]) -> None:
self.components = components self.sections = sections
def render(self, context: ReportContext) -> str: def render(self, context: ReportContext) -> str:
links = "".join( links = "".join(
f'<li><a href="#{component.anchor}">{text(component.title)}</a></li>' self._toc_item(item)
for component in self.components for item in self.sections
if component.anchor not in {"cover", "toc"} if item.get("toc")
) )
return section(self.anchor, self.title, f'<ol class="toc-list">{links}</ol>') return section(self.anchor, self.title, f'<ol class="toc-list">{links}</ol>')
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'<li class="toc-level-{depth}"><a href="#{text(item.get("key"))}">'
f'<span class="toc-label">{text(label)}</span></a></li>'
)
class SummaryComponent(ReportComponent): class SummaryComponent(ReportComponent):
anchor = "summary" anchor = "summary"
@ -121,17 +147,29 @@ class SummaryComponent(ReportComponent):
class StaticTextComponent(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.anchor = anchor
self.title = title self.title = title
self.body = body self.body = body
self.block_key = block_key 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: def render(self, context: ReportContext) -> str:
if self.block_key and self.block_key in context.text_blocks: if self.block_key and self.block_key in context.text_blocks:
content = render_template_text(context.text_blocks[self.block_key].content, context) content = render_template_text(context.text_blocks[self.block_key].content, context)
return section(self.anchor, self.title, f"<p>{paragraph(content)}</p>") return section(self.anchor, self.title, f"<p>{paragraph(content)}</p>", self.bookmark_label)
return section(self.anchor, self.title, f"<p>{paragraph(self.body)}</p>") return section(self.anchor, self.title, f"<p>{paragraph(self.body)}</p>", self.bookmark_label)
class CustomerComponent(ReportComponent): class CustomerComponent(ReportComponent):
@ -182,14 +220,14 @@ class CustomerComponent(ReportComponent):
return section(self.anchor, self.title, body) return section(self.anchor, self.title, body)
class DeviceComponent(ReportComponent): class DeviceComponent(NumberedComponent):
anchor = "device" anchor = "device"
title = "Geraet" title = "Geraet"
def render(self, context: ReportContext) -> str: def render(self, context: ReportContext) -> str:
device = context.device device = context.device
if device is None: if device is None:
return section(self.anchor, self.title, "") return section(self.anchor, self.title, "<p>nicht erfasst</p>", self.bookmark_label)
body = definition_list( body = definition_list(
[ [
("Hersteller", device.manufacturer), ("Hersteller", device.manufacturer),
@ -212,10 +250,10 @@ class DeviceComponent(ReportComponent):
("Lieferant", device.supplier), ("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" anchor = "equipment"
title = "Pruefmittel" title = "Pruefmittel"
@ -247,10 +285,11 @@ class EquipmentComponent(ReportComponent):
], ],
rows, rows,
), ),
self.bookmark_label,
) )
class EnvironmentComponent(ReportComponent): class EnvironmentComponent(NumberedComponent):
anchor = "environment" anchor = "environment"
title = "Umgebungsbedingungen" title = "Umgebungsbedingungen"
@ -269,10 +308,10 @@ class EnvironmentComponent(ReportComponent):
] ]
if rows: if rows:
body += table(["Pruefpunkt", "Bewertung", "Kommentar"], 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" anchor = "checklists"
title = "Dokumentations- und Leistungschecklisten" title = "Dokumentations- und Leistungschecklisten"
@ -286,7 +325,7 @@ class ChecklistComponent(ReportComponent):
performance = context.validation.performance_checklist or [] performance = context.validation.performance_checklist or []
body = "<h3>Dokumentation</h3>" + self._render_items(documentation) body = "<h3>Dokumentation</h3>" + self._render_items(documentation)
body += "<h3>Leistung</h3>" + self._render_items(performance) body += "<h3>Leistung</h3>" + 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: def _render_items(self, items: list[dict]) -> str:
rows = [ rows = [
@ -296,7 +335,7 @@ class ChecklistComponent(ReportComponent):
return table(["Nr.", "Pruefpunkt", "Bewertung", "Kommentar"], rows) return table(["Nr.", "Pruefpunkt", "Bewertung", "Kommentar"], rows)
class ProgramComponent(ReportComponent): class ProgramComponent(NumberedComponent):
anchor = "programs" anchor = "programs"
title = "Programme" title = "Programme"
@ -306,10 +345,10 @@ class ProgramComponent(ReportComponent):
[index + 1, item.get("name"), "Eigenes Programm" if item.get("custom") else "Standard"] [index + 1, item.get("name"), "Eigenes Programm" if item.get("custom") else "Standard"]
for index, item in enumerate(programs) 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" anchor = "loading"
title = "Beladungsmuster" title = "Beladungsmuster"
@ -327,10 +366,11 @@ class LoadingComponent(ReportComponent):
self.anchor, self.anchor,
self.title, self.title,
table(["Testlauf", "Beladungsmuster", "Beschreibung", "Bilder"], rows), table(["Testlauf", "Beladungsmuster", "Beschreibung", "Bilder"], rows),
self.bookmark_label,
) )
class MeasurementComponent(ReportComponent): class MeasurementComponent(NumberedComponent):
anchor = "measurements" anchor = "measurements"
title = "Messdaten" title = "Messdaten"
@ -351,6 +391,7 @@ class MeasurementComponent(ReportComponent):
self.anchor, self.anchor,
self.title, self.title,
table(["Testlauf", "Messwert", "Wert", "Einheit", "Quelle", "Sicherheit"], rows, "compact"), table(["Testlauf", "Messwert", "Wert", "Einheit", "Quelle", "Sicherheit"], rows, "compact"),
self.bookmark_label,
) )
rows = [ rows = [
[ [
@ -394,10 +435,10 @@ class MeasurementComponent(ReportComponent):
if winlog_rows: if winlog_rows:
body += "<h3>Winlog-Dateien</h3>" + table(["Bereich", "Datei", "Typ"], winlog_rows) body += "<h3>Winlog-Dateien</h3>" + table(["Bereich", "Datei", "Typ"], winlog_rows)
body += "<p>Messdaten noch nicht bestätigt.</p>" body += "<p>Messdaten noch nicht bestätigt.</p>"
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" anchor = "drying"
title = "Trocknung" title = "Trocknung"
@ -412,10 +453,10 @@ class DryingComponent(ReportComponent):
("Bemerkung", data.get("comment")), ("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" anchor = "recommendations"
title = "Empfehlungen und Auflagen" title = "Empfehlungen und Auflagen"
@ -424,10 +465,10 @@ class RecommendationComponent(ReportComponent):
[item.get("number"), item.get("text"), item.get("deadline"), item.get("status")] [item.get("number"), item.get("text"), item.get("deadline"), item.get("status")]
for item in context.validation.recommendations or [] 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" anchor = "attachments"
title = "Bilder und Anlagen" title = "Bilder und Anlagen"
@ -456,7 +497,7 @@ class AttachmentComponent(ReportComponent):
body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows) body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows)
if figures: if figures:
body += '<div class="figure-grid">' + "".join(figures) + "</div>" body += '<div class="figure-grid">' + "".join(figures) + "</div>"
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: def _image_source(self, item: dict) -> str | None:
content_type = str(item.get("content_type") or "") content_type = str(item.get("content_type") or "")

View file

@ -41,5 +41,12 @@ def table(headers: list[str], rows: list[list[Any]], css_class: str = "") -> str
return f"<table class=\"data-table {css_class}\"><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>" return f"<table class=\"data-table {css_class}\"><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
def section(chapter_id: str, title: str, body: str) -> str: def section(chapter_id: str, title: str, body: str, bookmark_label: str | None = None) -> str:
return f"<section class=\"chapter\" id=\"{text(chapter_id)}\"><h2>{text(title)}</h2>{body}</section>" bookmark_attr = (
f' data-bookmark-label="{text(bookmark_label)}"' if bookmark_label is not None else ""
)
heading = bookmark_label or title
return (
f'<section class="chapter" id="{text(chapter_id)}">'
f'<h2 class="chapter-title"{bookmark_attr}>{text(heading)}</h2>{body}</section>'
)

View file

@ -26,6 +26,7 @@ from app.modules.orion.components import (
) )
from app.modules.orion.context import OrionContextBuilder, ReportContext from app.modules.orion.context import OrionContextBuilder, ReportContext
from app.modules.orion.templates.report import render_document from app.modules.orion.templates.report import render_document
from app.modules.orion.template_service import REPORT_SECTIONS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -52,51 +53,35 @@ class OrionReportService:
return output_path return output_path
def _components(self) -> list[ReportComponent]: def _components(self) -> list[ReportComponent]:
chapters: list[ReportComponent] = [ chapters = [self._component_for_section(item) for item in REPORT_SECTIONS]
StaticTextComponent("bq", "1 Funktionsqualifikation (BQ)", "nicht erfasst"), return [CoverComponent(), SummaryComponent(), TocComponent(REPORT_SECTIONS), CustomerComponent(), *chapters]
StaticTextComponent("bq-goal", "1.1 Anlass und Ziel der Prüfung", block_key="bq_goal"),
StaticTextComponent("legal", "1.2 Gesetzliche Grundlagen", block_key="legal"), def _component_for_section(self, item: dict) -> ReportComponent:
DeviceComponent(), component = item.get("component")
StaticTextComponent("performance", "1.4 Leistungsüberprüfung", block_key="performance"), key = str(item["key"])
ChecklistComponent(), title = str(item["title"])
StaticTextComponent("documentation", "1.6 Dokumentation / Kontrolle"), number = item.get("number")
StaticTextComponent("work-instructions", "1.7 Arbeitsanweisungen"), if component == "device":
EnvironmentComponent(), return DeviceComponent(key, title, number)
StaticTextComponent("batch-control", "1.9 Chargenkontrolle"), if component == "checklist":
ProgramComponent(), return ChecklistComponent(key, title, number)
LoadingComponent(), if component == "environment":
StaticTextComponent("reference-load", "1.12 Referenzbeladung Sterilisator"), return EnvironmentComponent(key, title, number)
StaticTextComponent("equipment", "2 Eingesetzte Prüfmittel"), if component == "programs":
EquipmentComponent(), return ProgramComponent(key, title, number)
StaticTextComponent("equipment-thermo", "2.2 Prüfmittel zur thermoelektrischen Untersuchung"), if component == "loading":
StaticTextComponent("configuration", "2.3 Prüfkonfiguration"), return LoadingComponent(key, title, number)
StaticTextComponent("lq", "3 Leistungsqualifikation (LQ)"), if component == "equipment":
StaticTextComponent("thermo-tests", "3.1 Leistungsbeurteilung Thermoelektrische Prüfungen"), return EquipmentComponent(key, title, number)
MeasurementComponent(), if component == "measurements":
StaticTextComponent("run-1", "3.2 Standardbeladung Testlauf 1"), return MeasurementComponent(key, title, number)
StaticTextComponent("test-1", "3.2.1 Test 1"), if component == "drying":
StaticTextComponent("run-2", "3.3 Standardbeladung Testlauf 2"), return DryingComponent(key, title, number)
StaticTextComponent("test-2", "3.3.1 Test 2"), if component == "recommendations":
StaticTextComponent("run-3", "3.4 Standardbeladung Testlauf 3"), return RecommendationComponent(key, title, number)
StaticTextComponent("test-3", "3.4.1 Test 3"), if component == "attachments":
StaticTextComponent("results", "4 Ergebnisse der Validierung"), return AttachmentComponent(key, title, number)
StaticTextComponent("results-vacuum", "4.1 Vakuumtest"), return StaticTextComponent(key, title, block_key=item.get("block_key"), number=number)
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]
def _append_pdf_attachments(self, context: ReportContext, output_path: Path) -> None: def _append_pdf_attachments(self, context: ReportContext, output_path: Path) -> None:
pdf_paths = [] pdf_paths = []

View file

@ -19,48 +19,50 @@ TEMPLATE_NAME = "Erstvalidierung Klein-Sterilisator"
TEMPLATE_VERSION = "1.0" TEMPLATE_VERSION = "1.0"
REFERENCE_PATH = "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx" REFERENCE_PATH = "docs/reference/reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx"
SECTION_DEFINITIONS: list[tuple[str, str | None, str, bool]] = [ REPORT_SECTIONS: list[dict[str, Any]] = [
("bq", "1", "Funktionsqualifikation (BQ)", True), {"key": "functional_qualification", "number": "1", "title": "Funktionsqualifikation (BQ)", "toc": True, "component": "static", "block_key": None},
("bq_goal", "1.1", "Anlass und Ziel der Prüfung", False), {"key": "purpose", "number": "1.1", "title": "Anlass und Ziel der Prüfung", "toc": True, "component": "static", "block_key": "bq_goal"},
("legal", "1.2", "Gesetzliche Grundlagen", False), {"key": "legal", "number": "1.2", "title": "Gesetzliche Grundlagen", "toc": True, "component": "static", "block_key": "legal"},
("device", "1.3", "Angaben zum Gerät", False), {"key": "device", "number": "1.3", "title": "Angaben zum Gerät", "toc": True, "component": "device"},
("performance", "1.4", "Leistungsüberprüfung", False), {"key": "performance", "number": "1.4", "title": "Leistungsüberprüfung", "toc": True, "component": "static", "block_key": "performance"},
("performance_checklist", "1.5", "Checkliste Leistungsanforderung", False), {"key": "performance_checklist", "number": "1.5", "title": "Checkliste Leistungsanforderung", "toc": True, "component": "checklist"},
("documentation", "1.6", "Dokumentation / Kontrolle", False), {"key": "documentation", "number": "1.6", "title": "Dokumentation / Kontrolle", "toc": True, "component": "static"},
("work_instructions", "1.7", "Arbeitsanweisungen", False), {"key": "work_instructions", "number": "1.7", "title": "Arbeitsanweisungen", "toc": True, "component": "static"},
("environment", "1.8", "Umgebungsbedingungen", False), {"key": "environment", "number": "1.8", "title": "Umgebungsbedingungen", "toc": True, "component": "environment"},
("batch_control", "1.9", "Chargenkontrolle", False), {"key": "batch_control", "number": "1.9", "title": "Chargenkontrolle", "toc": True, "component": "static"},
("programs", "1.10", "Beschreibung der verwendeten Programme", False), {"key": "programs", "number": "1.10", "title": "Beschreibung der verwendeten Programme", "toc": True, "component": "programs"},
("loading", "1.11", "Beladungsbeschreibungen", False), {"key": "loading", "number": "1.11", "title": "Beladungsbeschreibungen", "toc": True, "component": "loading"},
("reference_load", "1.12", "Referenzbeladung Sterilisator", False), {"key": "reference_load", "number": "1.12", "title": "Referenzbeladung Sterilisator bei Validierung", "toc": True, "component": "static"},
("equipment", "2", "Eingesetzte Prüfmittel", True), {"key": "equipment_root", "number": "2", "title": "Eingesetzte Prüfmittel", "toc": True, "component": "static"},
("measurement_devices", "2.1", "Beschreibung der Messgeräte", False), {"key": "measurement_devices", "number": "2.1", "title": "Beschreibung der Messgeräte", "toc": True, "component": "equipment"},
("thermo_equipment", "2.2", "Prüfmittel zur thermoelektrischen Untersuchung", False), {"key": "thermo_equipment", "number": "2.2", "title": "Prüfmittel zur thermoelektrischen Untersuchung", "toc": True, "component": "static"},
("configuration", "2.3", "Prüfkonfiguration", False), {"key": "configuration", "number": "2.3", "title": "Prüfkonfiguration", "toc": True, "component": "static"},
("lq", "3", "Leistungsqualifikation (LQ)", True), {"key": "lq", "number": "3", "title": "Leistungsqualifikation (LQ)", "toc": True, "component": "static"},
("thermo_tests", "3.1", "Leistungsbeurteilung Thermoelektrische Prüfungen", False), {"key": "thermo_tests", "number": "3.1", "title": "Leistungsbeurteilung Thermoelektrische Prüfungen", "toc": True, "component": "static"},
("vacuum_test", "3.1.1", "Vakuumtest", False), {"key": "vacuum_test", "number": "3.1.1", "title": "Vakuumtest", "toc": True, "component": "measurements"},
("run_1", "3.2", "Standardbeladung Testlauf 1", False), {"key": "run_1", "number": "3.2", "title": "Standardbeladung (1. Durchlauf)", "toc": True, "component": "static"},
("test_1", "3.2.1", "Test 1", False), {"key": "test_1", "number": "3.2.1", "title": "Test 1", "toc": True, "component": "static"},
("run_2", "3.3", "Standardbeladung Testlauf 2", False), {"key": "run_2", "number": "3.3", "title": "Standardbeladung (2. Durchlauf)", "toc": True, "component": "static"},
("test_2", "3.3.1", "Test 2", False), {"key": "test_2", "number": "3.3.1", "title": "Test 2", "toc": True, "component": "static"},
("run_3", "3.4", "Standardbeladung Testlauf 3", False), {"key": "run_3", "number": "3.4", "title": "Standardbeladung (3. Durchlauf)", "toc": True, "component": "static"},
("test_3", "3.4.1", "Test 3", False), {"key": "test_3", "number": "3.4.1", "title": "Test 3", "toc": True, "component": "static"},
("results", "4", "Ergebnisse der Validierung", True), {"key": "results", "number": "4", "title": "Ergebnisse der Validierung", "toc": True, "component": "static"},
("results_vacuum", "4.1", "Vakuumtest", False), {"key": "results_vacuum", "number": "4.1", "title": "Vakuumtest", "toc": True, "component": "static"},
("results_runs", "4.1.2", "Testläufe 1 bis 3", False), {"key": "results_runs", "number": "4.1.2", "title": "Testläufe 1 bis 3 Programm 134 °C B", "toc": True, "component": "static"},
("drying", None, "Nachweis der Trocknungseigenschaften", False), {"key": "drying", "number": None, "title": "Nachweis der Trocknungseigenschaften Testläufe 1 bis 3", "toc": True, "component": "drying"},
("recommendations", "4.2", "Empfehlungen und Auflagen", False), {"key": "recommendations", "number": "4.2", "title": "Empfehlungen und Auflagen", "toc": True, "component": "recommendations"},
("attachments", "5", "Anhang", True), {"key": "appendix", "number": "5", "title": "Anhang", "toc": True, "component": "static"},
("winlog", "5.1", "Winlog-Auswertungen", False), {"key": "winlog", "number": "5.1", "title": "Vakuumtest / Winlog-Auswertungen", "toc": True, "component": "attachments"},
("release_docs", "5.2", "Freigabedokumentation / Routineprüfungen", False), {"key": "bd_empty_chamber", "number": None, "title": "Testlauf Leerkammerprofil / Bowie-Dick", "toc": True, "component": "static"},
("indicators", "5.3", "Nachweis der umgeschlagenen Indikatoren", False), {"key": "batch_release_docs", "number": None, "title": "Chargen- und Freigabedokumentation", "toc": True, "component": "static"},
("cycles", "6", "Programmabläufe / Zyklen", True), {"key": "release_docs", "number": "5.2", "title": "Freigabedokumentation / Routineprüfungen", "toc": True, "component": "static"},
("practice_certificates", "6.1", "Zertifikate Praxis", False), {"key": "indicators", "number": "5.3", "title": "Nachweis der umgeschlagenen Indikatoren", "toc": True, "component": "static"},
("risk", "7", "Risikoeinstufung nach RKI", True), {"key": "cycles", "number": "6", "title": "Programmabläufe / Zyklen des Sterilisators", "toc": True, "component": "static"},
("closing_talk", "7.1", "Abschlussgespräch", False), {"key": "practice_certificates", "number": "6.1", "title": "Zertifikate Praxis", "toc": True, "component": "static"},
("certificates", "8", "Zertifikate", True), {"key": "risk", "number": "7", "title": "Risikoeinstufung der Praxis nach RKI", "toc": True, "component": "static"},
("calibration_certificates", "9", "Werkskalibrierzertifikate Sensoren", True), {"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 = { TEXT_BLOCK_HEADINGS = {
@ -252,15 +254,15 @@ class ReportTemplateService:
def _create_children(self, template: ReportTemplate) -> None: def _create_children(self, template: ReportTemplate) -> None:
paragraphs = self._read_docx_paragraphs() paragraphs = self._read_docx_paragraphs()
blocks = self._extract_blocks(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( self.session.add(
ReportSection( ReportSection(
template_id=template.id, template_id=template.id,
section_key=section_key, section_key=str(section["key"]),
number=number, number=section["number"],
title=title, title=str(section["title"]),
order_index=index, 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, active=True,
) )
) )

View file

@ -1,16 +1,19 @@
@page { @page {
size: A4; 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; margin: 20mm 18mm 20mm 18mm;
@top-left { content: ""; } @top-left { content: ""; }
@bottom-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; box-sizing: border-box;
} }
@ -27,6 +30,7 @@ body {
} }
.report-meta { .report-meta {
display: none;
string-set: report-number attr(data-report-number), report-version attr(data-report-version), report-date attr(data-report-date); 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 { .cover-page {
page: cover;
min-height: 245mm; min-height: 245mm;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
page-break-after: always; break-after: page;
}
.report-content {
page: report;
} }
.cover-top { .cover-top {
@ -207,6 +216,12 @@ h2 {
line-height: 1.15; line-height: 1.15;
margin: 0 0 7mm 0; margin: 0 0 7mm 0;
padding-bottom: 4mm; padding-bottom: 4mm;
bookmark-level: none;
}
h2[data-bookmark-label] {
bookmark-label: attr(data-bookmark-label);
bookmark-level: 1;
} }
.chapter + .chapter { .chapter + .chapter {
@ -286,9 +301,26 @@ dd {
.toc-list a { .toc-list a {
color: #2E3B40; color: #2E3B40;
display: grid;
gap: 4mm;
grid-template-columns: 1fr 14mm;
text-decoration: none; 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 { .figure-grid {
display: grid; display: grid;
gap: 8mm; gap: 8mm;

View file

@ -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") css = (Path(__file__).resolve().parent / "report.css").read_text(encoding="utf-8")
logo_uri = schubamed_logo_uri() logo_uri = schubamed_logo_uri()
title = f"Validierungsbericht {context.validation.report_number}" 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"""<!doctype html> return f"""<!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
@ -49,9 +50,12 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
</head> </head>
<body> <body>
<article class="report-document"> <article class="report-document">
<div class="report-meta" data-report-number="{text(context.validation.report_number)}" data-report-version="{text(context.validation.version)}" data-report-date="{text(context.validation.updated_at)}"></div> {cover_markup}
{render_report_chrome(context, logo_uri)} <section class="report-content">
{chapter_markup} <div class="report-meta" data-report-number="{text(context.validation.report_number)}" data-report-version="{text(context.validation.version)}" data-report-date="{text(context.validation.updated_at)}"></div>
{render_report_chrome(context, logo_uri)}
{content_markup}
</section>
</article> </article>
</body> </body>
</html>""" </html>"""

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import re
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
@ -16,7 +17,7 @@ from app.services.validation_workflow import ValidationWorkflowService
from app.schemas.domain import ValidationCreate from app.schemas.domain import ValidationCreate
from app.modules.orion.service import OrionReportService from app.modules.orion.service import OrionReportService
from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri 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 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) html = OrionReportService(db, tmp_path).render_html(validation.id)
assert "1 Funktionsqualifikation" in html 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 "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): 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", "Zusammenfassendes Ergebnis der Validierung",
"1.1 Anlass und Ziel der Prüfung", "1.1 Anlass und Ziel der Prüfung",
"1.2 Gesetzliche Grundlagen", "1.2 Gesetzliche Grundlagen",
"3.2 Standardbeladung Testlauf 1", "3.2 Standardbeladung (1. Durchlauf)",
"3.3 Standardbeladung Testlauf 2", "3.3 Standardbeladung (2. Durchlauf)",
"3.4 Standardbeladung Testlauf 3", "3.4 Standardbeladung (3. Durchlauf)",
]: ]:
assert title in html assert title in html
assert "Neutraler Logo-Platzhalter" not in html assert "Neutraler Logo-Platzhalter" not in html
assert "report-header-brand" 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 "<h1>PRÜFBERICHT ZUR VALIDIERUNG</h1>" 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): def test_orion_appends_pdf_attachments(tmp_path):
from pypdf import PdfReader, PdfWriter from pypdf import PdfReader, PdfWriter