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
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):
'<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">'
"</div>"
"<h1>PRUEFBERICHT ZUR VALIDIERUNG</h1>"
"<h1>PRÜFBERICHT ZUR VALIDIERUNG</h1>"
'<p class="cover-subtitle">Funktions- und Leistungsqualifikation Klein-Sterilisator</p>'
f'<p class="cover-subtitle">{text(context.customer.name)}</p>'
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'<li><a href="#{component.anchor}">{text(component.title)}</a></li>'
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'<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):
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"<p>{paragraph(content)}</p>")
return section(self.anchor, self.title, f"<p>{paragraph(self.body)}</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>", 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, "<p>nicht erfasst</p>", 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 = "<h3>Dokumentation</h3>" + self._render_items(documentation)
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:
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 += "<h3>Winlog-Dateien</h3>" + table(["Bereich", "Datei", "Typ"], winlog_rows)
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"
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 += '<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:
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>"
def section(chapter_id: str, title: str, body: str) -> str:
return f"<section class=\"chapter\" id=\"{text(chapter_id)}\"><h2>{text(title)}</h2>{body}</section>"
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'<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.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 = []

View file

@ -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,
)
)

View file

@ -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;

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")
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"""<!doctype html>
<html lang="de">
<head>
@ -49,9 +50,12 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
</head>
<body>
<article class="report-document">
{cover_markup}
<section class="report-content">
<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)}
{chapter_markup}
{content_markup}
</section>
</article>
</body>
</html>"""

View file

@ -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 "<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):
from pypdf import PdfReader, PdfWriter