"
- "Neutraler Logo-Platzhalter · Validation Suite
"
- "Pruefbericht zur Validierung "
- "Funktions- und Leistungsqualifikation Klein-Sterilisator
"
- f"{text(context.customer.name)}
"
+ ''
+ ''
+ '
SCHUBAMED Validation Suite Medizintechnik und Validierung
'
+ f'
'
+ "
"
+ "PRUEFBERICHT ZUR VALIDIERUNG "
+ 'Funktions- und Leistungsqualifikation Klein-Sterilisator
'
+ f'{text(context.customer.name)}
'
f"{rows}"
- "Unterschrift technische Validierung
Unterschrift Auftraggeber
"
+ 'Unterschrift technische Validierung
Unterschrift Auftraggeber
'
" "
)
@@ -46,11 +81,11 @@ class TocComponent(ReportComponent):
def render(self, context: ReportContext) -> str:
links = "".join(
- f"{text(component.title)} "
+ f'{text(component.title)} '
for component in self.components
if component.anchor not in {"cover", "toc"}
)
- return section(self.anchor, self.title, f"{links} ")
+ return section(self.anchor, self.title, f'{links} ')
class SummaryComponent(ReportComponent):
@@ -59,28 +94,44 @@ class SummaryComponent(ReportComponent):
def render(self, context: ReportContext) -> str:
validation = context.validation
- body = definition_list(
+ block = context.text_blocks.get("summary")
+ body = f"{paragraph(render_template_text(block.content, context) if block else 'nicht erfasst')}
"
+ body += definition_list(
[
("Kunde", context.customer.name),
("Standort", context.location.name if context.location else None),
- ("Geraet", f"{context.device.manufacturer} {context.device.model}" if context.device else None),
+ (
+ "Geraet",
+ (
+ f"{context.device.manufacturer} {context.device.model}"
+ if context.device
+ else None
+ ),
+ ),
("Pruefmittel", len(context.equipment)),
("Ergebnis", validation.result),
("Mitwirkende Personen", validation.participants),
- ("Hinweis auf naechste Leistungsbeurteilung", validation.next_validation_on or "nicht erfasst"),
+ (
+ "Hinweis auf naechste Leistungsbeurteilung",
+ validation.next_validation_on or "nicht erfasst",
+ ),
]
)
return section(self.anchor, self.title, body)
class StaticTextComponent(ReportComponent):
- def __init__(self, anchor: str, title: str, body: str = "nicht erfasst") -> None:
+ def __init__(self, anchor: str, title: str, body: str = "nicht erfasst", block_key: str | None = None) -> None:
self.anchor = anchor
self.title = title
self.body = body
+ self.block_key = block_key
def render(self, context: ReportContext) -> str:
- return section(self.anchor, self.title, f"{text(self.body)}
")
+ 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)}
")
class CustomerComponent(ReportComponent):
@@ -95,7 +146,10 @@ class CustomerComponent(ReportComponent):
[
("Name", customer.name),
("Typ", customer.customer_type.value),
- ("Adresse", " ".join(filter(None, [customer.street, customer.postal_code, customer.city]))),
+ (
+ "Adresse",
+ " ".join(filter(None, [customer.street, customer.postal_code, customer.city])),
+ ),
("Telefon", customer.phone),
("Mail", customer.email),
("Betreiber", context.validation.operator_name),
@@ -107,7 +161,12 @@ class CustomerComponent(ReportComponent):
body += "Standort " + definition_list(
[
("Name", location.name),
- ("Adresse", " ".join(filter(None, [location.street, location.postal_code, location.city]))),
+ (
+ "Adresse",
+ " ".join(
+ filter(None, [location.street, location.postal_code, location.city])
+ ),
+ ),
("Raum", location.room),
]
)
@@ -139,7 +198,14 @@ class DeviceComponent(ReportComponent):
("Seriennummer", device.serial_number),
("Baujahr", device.year_built),
("Inbetriebnahme", device.commissioned_on),
- ("Kammervolumen", f"{device.chamber_volume_liters} Liter" if device.chamber_volume_liters else None),
+ (
+ "Kammervolumen",
+ (
+ f"{device.chamber_volume_liters} Liter"
+ if device.chamber_volume_liters
+ else None
+ ),
+ ),
("Dampferzeugung", device.steam_generation),
("Wasseraufbereitung", device.water_treatment),
("Dokumentation", device.documentation),
@@ -170,7 +236,15 @@ class EquipmentComponent(ReportComponent):
self.anchor,
self.title,
table(
- ["Art", "Hersteller", "Modell", "Seriennummer", "Kalibriert", "Gueltig bis", "Status"],
+ [
+ "Art",
+ "Hersteller",
+ "Modell",
+ "Seriennummer",
+ "Kalibriert",
+ "Gueltig bis",
+ "Status",
+ ],
rows,
),
)
@@ -189,7 +263,10 @@ class EnvironmentComponent(ReportComponent):
("Pruefzeit", data.get("test_time")),
]
)
- rows = [[item.get("text"), yes_no(item.get("value")), item.get("comment")] for item in data.get("checks", [])]
+ rows = [
+ [item.get("text"), yes_no(item.get("value")), item.get("comment")]
+ for item in data.get("checks", [])
+ ]
if rows:
body += table(["Pruefpunkt", "Bewertung", "Kommentar"], rows)
return section(self.anchor, self.title, body)
@@ -200,10 +277,15 @@ class ChecklistComponent(ReportComponent):
title = "Dokumentations- und Leistungschecklisten"
def render(self, context: ReportContext) -> str:
- documentation = context.validation.documentation_checklist or []
- performance = context.validation.performance_checklist or []
- body = "Dokumentation " + self._render_items(documentation)
- body += "Leistung " + self._render_items(performance)
+ body = ""
+ for checklist in context.checklist_templates:
+ body += f"{text(checklist.title)} "
+ body += self._render_items(checklist.items)
+ if not body:
+ documentation = context.validation.documentation_checklist or []
+ 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)
def _render_items(self, items: list[dict]) -> str:
@@ -220,7 +302,10 @@ class ProgramComponent(ReportComponent):
def render(self, context: ReportContext) -> str:
programs = [item for item in (context.validation.programs or []) if item.get("selected")]
- rows = [[index + 1, item.get("name"), "Eigenes Programm" if item.get("custom") else "Standard"] for index, item in enumerate(programs)]
+ rows = [
+ [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))
@@ -230,10 +315,19 @@ class LoadingComponent(ReportComponent):
def render(self, context: ReportContext) -> str:
rows = [
- [item.get("run"), item.get("pattern"), item.get("description"), len(item.get("images", []))]
+ [
+ item.get("run"),
+ item.get("pattern"),
+ item.get("description"),
+ len(item.get("images", [])),
+ ]
for item in context.validation.loading_patterns or []
]
- return section(self.anchor, self.title, table(["Testlauf", "Beladungsmuster", "Beschreibung", "Bilder"], rows))
+ return section(
+ self.anchor,
+ self.title,
+ table(["Testlauf", "Beladungsmuster", "Beschreibung", "Bilder"], rows),
+ )
class MeasurementComponent(ReportComponent):
@@ -241,6 +335,23 @@ class MeasurementComponent(ReportComponent):
title = "Messdaten"
def render(self, context: ReportContext) -> str:
+ if context.confirmed_measurements:
+ rows = [
+ [
+ item.test_run,
+ item.field_name,
+ item.corrected_value or item.normalized_value or item.raw_value,
+ item.unit,
+ item.source_page,
+ f"{item.confidence} %",
+ ]
+ for item in context.confirmed_measurements
+ ]
+ return section(
+ self.anchor,
+ self.title,
+ table(["Testlauf", "Messwert", "Wert", "Einheit", "Quelle", "Sicherheit"], rows, "compact"),
+ )
rows = [
[
item.get("name"),
@@ -277,9 +388,12 @@ class MeasurementComponent(ReportComponent):
winlog_rows = []
for item in context.validation.measurement_data or []:
for imported in item.get("imports", []):
- winlog_rows.append([item.get("name"), imported.get("filename"), imported.get("content_type")])
+ winlog_rows.append(
+ [item.get("name"), imported.get("filename"), imported.get("content_type")]
+ )
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)
@@ -318,8 +432,51 @@ class AttachmentComponent(ReportComponent):
title = "Bilder und Anlagen"
def render(self, context: ReportContext) -> str:
+ attachments = sorted(
+ context.validation.attachments or [], key=lambda row: row.get("order") or 0
+ )
rows = [
[item.get("order"), item.get("category"), item.get("filename"), item.get("description")]
- for item in sorted(context.validation.attachments or [], key=lambda row: row.get("order") or 0)
+ for item in attachments
]
- return section(self.anchor, self.title, table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows))
+ figures = []
+ for index, item in enumerate(attachments, start=1):
+ src = self._image_source(item)
+ if not src:
+ continue
+ caption = (
+ item.get("description") or item.get("filename") or item.get("category") or "Anlage"
+ )
+ figures.append(
+ ''
+ f' '
+ f"Abbildung {index}: {text(caption)} "
+ " "
+ )
+ body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows)
+ if figures:
+ body += '' + "".join(figures) + "
"
+ return section(self.anchor, self.title, body)
+
+ def _image_source(self, item: dict) -> str | None:
+ content_type = str(item.get("content_type") or "")
+ filename = str(item.get("filename") or "")
+ if not content_type.startswith("image/") and Path(filename).suffix.lower() not in {
+ ".jpg",
+ ".jpeg",
+ ".png",
+ ".webp",
+ ".svg",
+ }:
+ return None
+ url = item.get("url")
+ if isinstance(url, str) and url:
+ if url.startswith(("http://", "https://")):
+ return url
+ return f"{settings.public_base_url.rstrip('/')}{quote(url, safe='/:._-')}"
+ storage_path = item.get("storage_path")
+ if storage_path:
+ path = Path(str(storage_path))
+ if path.exists():
+ return path.resolve().as_uri()
+ return None
diff --git a/validation-suite/backend/mercury/app/modules/orion/context.py b/validation-suite/backend/mercury/app/modules/orion/context.py
index 0c78a49c..6214b5d7 100644
--- a/validation-suite/backend/mercury/app/modules/orion/context.py
+++ b/validation-suite/backend/mercury/app/modules/orion/context.py
@@ -11,8 +11,16 @@ from app.models.contact import Contact
from app.models.customer import Customer
from app.models.device import Device
from app.models.equipment import Equipment
+from app.models.report_template import (
+ ChecklistTemplate,
+ MeasurementImport,
+ MeasurementImportValue,
+ ReportSection,
+ TextBlock,
+)
from app.models.location import Location
from app.models.validation import Validation
+from app.modules.orion.template_service import ReportTemplateService
@dataclass(frozen=True)
@@ -24,6 +32,10 @@ class ReportContext:
device: Device | None
equipment: list[Equipment]
generated_dir: Path
+ report_sections: list[ReportSection]
+ text_blocks: dict[str, TextBlock]
+ checklist_templates: list[ChecklistTemplate]
+ confirmed_measurements: list[MeasurementImportValue]
class OrionContextBuilder:
@@ -50,6 +62,17 @@ class OrionContextBuilder:
select(Equipment).where(Equipment.id.in_(validation.equipment_ids))
)
)
+ template_bundle = ReportTemplateService(self.session).ensure_default_template()
+ confirmed_measurements = list(
+ self.session.scalars(
+ select(MeasurementImportValue)
+ .join_from(MeasurementImportValue, MeasurementImport)
+ .where(
+ MeasurementImport.validation_id == validation.id,
+ MeasurementImportValue.confirmed.is_(True),
+ )
+ )
+ )
self.generated_dir.mkdir(parents=True, exist_ok=True)
return ReportContext(
@@ -60,5 +83,8 @@ class OrionContextBuilder:
device=device,
equipment=equipment,
generated_dir=self.generated_dir,
+ report_sections=template_bundle.sections,
+ text_blocks=template_bundle.text_blocks,
+ checklist_templates=template_bundle.checklists,
+ confirmed_measurements=confirmed_measurements,
)
-
diff --git a/validation-suite/backend/mercury/app/modules/orion/service.py b/validation-suite/backend/mercury/app/modules/orion/service.py
index 05fcdc75..929792cd 100644
--- a/validation-suite/backend/mercury/app/modules/orion/service.py
+++ b/validation-suite/backend/mercury/app/modules/orion/service.py
@@ -1,9 +1,11 @@
from __future__ import annotations
from pathlib import Path
+import logging
from sqlalchemy.orm import Session
+from app.modules.orion.assets import ORION_ASSET_DIR
from app.modules.orion.components import (
AttachmentComponent,
ChecklistComponent,
@@ -25,6 +27,8 @@ from app.modules.orion.components import (
from app.modules.orion.context import OrionContextBuilder, ReportContext
from app.modules.orion.templates.report import render_document
+logger = logging.getLogger(__name__)
+
class OrionReportService:
def __init__(self, session: Session, generated_dir: Path | None = None) -> None:
@@ -43,34 +47,82 @@ class OrionReportService:
context = OrionContextBuilder(self.session, self.generated_dir).build(validation_id)
output_path = context.generated_dir / f"{context.validation.report_number}.pdf"
html = self.render_html(validation_id)
- HTML(string=html, base_url=str(context.generated_dir)).write_pdf(output_path)
+ HTML(string=html, base_url=str(ORION_ASSET_DIR)).write_pdf(output_path)
+ self._append_pdf_attachments(context, output_path)
return output_path
def _components(self) -> list[ReportComponent]:
chapters: list[ReportComponent] = [
- StaticTextComponent("bq", "1. Funktionsqualifikation (BQ)", "Anlass, Ziel und gesetzliche Grundlagen werden anhand der erfassten Validierungsdaten bewertet."),
- StaticTextComponent("bq-goal", "1.1 Anlass und Ziel der Pruefung"),
- StaticTextComponent("legal", "1.2 Gesetzliche Grundlagen"),
+ 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 Leistungsueberpruefung"),
+ StaticTextComponent("performance", "1.4 Leistungsüberprüfung", block_key="performance"),
ChecklistComponent(),
- StaticTextComponent("work-instructions", "Arbeitsanweisungen"),
+ 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"),
+ StaticTextComponent("reference-load", "1.12 Referenzbeladung Sterilisator"),
+ StaticTextComponent("equipment", "2 Eingesetzte Prüfmittel"),
EquipmentComponent(),
- StaticTextComponent("equipment-thermo", "2.2 Pruefmittel zur thermoelektrischen Untersuchung"),
- StaticTextComponent("configuration", "3. Pruefkonfiguration"),
+ 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("results", "4. Ergebnisse der Validierung"),
+ 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("cycles", "6. Programmablaeufe / Zyklen"),
- StaticTextComponent("risk", "7. Risikoeinstufung und Abschlussgespraech"),
+ 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. Kalibrierzertifikate"),
+ StaticTextComponent("calibration-certificates", "9. Werkskalibrierzertifikate Sensoren"),
]
return [CoverComponent(), SummaryComponent(), TocComponent(chapters), CustomerComponent(), *chapters]
+
+ def _append_pdf_attachments(self, context: ReportContext, output_path: Path) -> None:
+ pdf_paths = []
+ for item in context.validation.attachments or []:
+ filename = str(item.get("filename") or "")
+ content_type = str(item.get("content_type") or "")
+ if not filename.lower().endswith(".pdf") and content_type != "application/pdf":
+ continue
+ storage_path = item.get("storage_path")
+ if storage_path and Path(str(storage_path)).exists():
+ pdf_paths.append(Path(str(storage_path)))
+ if not pdf_paths:
+ return
+ try:
+ from pypdf import PdfReader, PdfWriter
+
+ writer = PdfWriter()
+ for page in PdfReader(str(output_path)).pages:
+ writer.add_page(page)
+ for pdf_path in pdf_paths:
+ try:
+ for page in PdfReader(str(pdf_path)).pages:
+ writer.add_page(page)
+ except Exception:
+ logger.exception("Could not append attachment PDF %s", pdf_path)
+ with output_path.open("wb") as handle:
+ writer.write(handle)
+ except Exception:
+ logger.exception("Could not merge Orion PDF attachments for validation %s", context.validation.id)
diff --git a/validation-suite/backend/mercury/app/modules/orion/template_service.py b/validation-suite/backend/mercury/app/modules/orion/template_service.py
new file mode 100644
index 00000000..31eea7c2
--- /dev/null
+++ b/validation-suite/backend/mercury/app/modules/orion/template_service.py
@@ -0,0 +1,354 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+from zipfile import ZipFile
+from xml.etree import ElementTree as ET
+
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from app.models.report_template import ChecklistTemplate, ReportSection, ReportTemplate, TextBlock
+from app.modules.orion.html import text
+
+TEMPLATE_KEY = "small_steam_sterilizer_initial_validation"
+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),
+]
+
+TEXT_BLOCK_HEADINGS = {
+ "summary": "Zusammenfassendes Ergebnis der Validierung",
+ "bq_goal": "Anlass und Ziel der Prüfung",
+ "legal": "Gesetzliche Grundlagen",
+ "performance": "Leistungsüberprüfung",
+}
+
+CHECKLIST_DEFINITIONS = [
+ (
+ "sterilizer_description",
+ "Beschreibung Sterilisator",
+ ["Beschreibung", "Ja", "Nein", "Nicht anwendbar", "Kommentar"],
+ [
+ "Trennung von Controlling und Monitoring Schaltkreisen",
+ "Temperaturüberwachung Sterilisationsprozess",
+ "Drucküberwachung Sterilisationsprozess",
+ "Wasserzulauf - Ablauf / manuelle Überwachung",
+ "Automatische Aufzeichnung der Sterilisationsergebnisse",
+ "Überwachung Wasserqualität",
+ "Separater Netzanschluss",
+ "Sichtkontrolle: Kessel, Sensoren, Türbereich",
+ ],
+ ),
+ (
+ "documentation_control",
+ "Dokumentation / Kontrolle",
+ ["Vorliegende Dokumente / Beschreibungen", "vorhanden", "eingesehen", "Kommentar"],
+ [
+ "Bedienungshandbuch / Installationsprotokoll",
+ "Wartungshandbuch",
+ "Arbeitsanweisungen / Checklisten / reale Beladungsmuster",
+ "Schulungsnachweise Mitarbeiter zur Aufbereitung",
+ "Risikoeinstufung der Medizinprodukte nach RKI",
+ "Protokoll mit Freigabe der Sterilisation durch Mitarbeiter",
+ "Chargenkontrolle / Indikator für jede Charge",
+ "Chargendokumentation der letzten 6 Wochen",
+ "Dokumentation / Bilder der schwierigsten repräsentativen Beladung",
+ ],
+ ),
+ (
+ "work_instructions",
+ "Arbeitsanweisungen",
+ ["Arbeitsanweisung", "vorhanden", "eingesehen", "Prüfintervall", "Kommentar"],
+ [
+ "Aufbereitung von Medizinprodukten",
+ "Beladung Sterilisator",
+ "Routinekontrollen",
+ "Freigabe von Chargen",
+ "Umgang mit Abweichungen",
+ ],
+ ),
+ (
+ "environment_conditions",
+ "Umgebungsbedingungen",
+ ["Prüfpunkt", "Ja", "Nein", "Nicht anwendbar", "Kommentar"],
+ ["Raumbedingungen stabil", "Aufstellort frei zugänglich", "Medienversorgung verfügbar"],
+ ),
+ (
+ "batch_documentation",
+ "Chargendokumentation",
+ ["Prüfpunkt", "vorhanden", "eingesehen", "Kommentar"],
+ ["Vakuumtest", "Testlauf 1", "Testlauf 2", "Testlauf 3", "Freigabedokumentation"],
+ ),
+ (
+ "batch_control",
+ "Chargenkontrolle",
+ ["Prüfpunkt", "Ja", "Nein", "Prüfkörper", "Kommentar"],
+ ["Bowie-Dick / Leerkammerprofil", "Helix-Test", "Indikatoren umgeschlagen"],
+ ),
+]
+
+
+@dataclass(frozen=True)
+class TemplateBundle:
+ template: ReportTemplate
+ sections: list[ReportSection]
+ text_blocks: dict[str, TextBlock]
+ checklists: list[ChecklistTemplate]
+
+
+class ReportTemplateService:
+ def __init__(self, session: Session, project_root: Path | None = None) -> None:
+ self.session = session
+ self.project_root = project_root or self._discover_project_root()
+
+ @property
+ def reference_path(self) -> Path:
+ return self.project_root / REFERENCE_PATH
+
+ def _discover_project_root(self) -> Path:
+ current = Path(__file__).resolve()
+ for parent in current.parents:
+ if (parent / REFERENCE_PATH).exists():
+ return parent
+ return Path("/app")
+
+ def ensure_default_template(self) -> TemplateBundle:
+ if not self.reference_path.exists():
+ raise FileNotFoundError(f"Required reference report is missing: {self.reference_path}")
+ template = self.session.scalar(
+ select(ReportTemplate).where(ReportTemplate.template_key == TEMPLATE_KEY)
+ )
+ if template is None:
+ template = ReportTemplate(
+ template_key=TEMPLATE_KEY,
+ name=TEMPLATE_NAME,
+ version=TEMPLATE_VERSION,
+ validation_type="Erstvalidierung",
+ reference_path=REFERENCE_PATH,
+ active=True,
+ )
+ self.session.add(template)
+ try:
+ self.session.flush()
+ except IntegrityError:
+ self.session.rollback()
+ template = self.session.scalar(
+ select(ReportTemplate).where(ReportTemplate.template_key == TEMPLATE_KEY)
+ )
+ if template is None:
+ raise
+ if not self._has_children(template):
+ self._create_children(template)
+ self.session.flush()
+ return self.load_bundle()
+
+ def load_bundle(self) -> TemplateBundle:
+ template = self.session.scalar(
+ select(ReportTemplate).where(ReportTemplate.template_key == TEMPLATE_KEY)
+ )
+ if template is None:
+ return self.ensure_default_template()
+ sections = list(
+ self.session.scalars(
+ select(ReportSection)
+ .where(ReportSection.template_id == template.id, ReportSection.active.is_(True))
+ .order_by(ReportSection.order_index)
+ )
+ )
+ text_blocks = {
+ block.block_key: block
+ for block in self.session.scalars(
+ select(TextBlock)
+ .where(TextBlock.template_id == template.id, TextBlock.active.is_(True))
+ .order_by(TextBlock.order_index)
+ )
+ }
+ checklists = list(
+ self.session.scalars(
+ select(ChecklistTemplate)
+ .where(ChecklistTemplate.template_id == template.id, ChecklistTemplate.active.is_(True))
+ .order_by(ChecklistTemplate.order_index)
+ )
+ )
+ return TemplateBundle(template=template, sections=sections, text_blocks=text_blocks, checklists=checklists)
+
+ def render_block(self, block_key: str, context: Any) -> str:
+ bundle = self.load_bundle()
+ block = bundle.text_blocks.get(block_key)
+ if block is None:
+ return "nicht erfasst"
+ return self.render_text(block.content, context)
+
+ def render_text(self, content: str, context: Any) -> str:
+ values = {
+ "device.manufacturer": context.device.manufacturer if context.device else None,
+ "device.model": context.device.model if context.device else None,
+ "device.serial_number": context.device.serial_number if context.device else None,
+ "customer.name": context.customer.name,
+ "location.city": context.location.city if context.location else None,
+ "validation.performed_on": context.validation.performed_on,
+ "validation.next_validation_on": context.validation.next_validation_on,
+ "validation.result": context.validation.result,
+ }
+ rendered = content
+ for key, value in values.items():
+ rendered = rendered.replace("{{ " + key + " }}", text(value))
+ return rendered
+
+ def _has_children(self, template: ReportTemplate) -> bool:
+ return bool(
+ self.session.scalar(
+ select(TextBlock.id).where(TextBlock.template_id == template.id).limit(1)
+ )
+ )
+
+ 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):
+ self.session.add(
+ ReportSection(
+ template_id=template.id,
+ section_key=section_key,
+ number=number,
+ title=title,
+ order_index=index,
+ page_break_before=page_break,
+ active=True,
+ )
+ )
+ for index, (block_key, title) in enumerate(TEXT_BLOCK_HEADINGS.items(), start=1):
+ self.session.add(
+ TextBlock(
+ template_id=template.id,
+ block_key=block_key,
+ title=title,
+ content=self._sanitize_reference_text(blocks.get(block_key, "nicht erfasst")),
+ order_index=index,
+ version=TEMPLATE_VERSION,
+ active=True,
+ )
+ )
+ for index, (key, title, columns, items) in enumerate(CHECKLIST_DEFINITIONS, start=1):
+ self.session.add(
+ ChecklistTemplate(
+ template_id=template.id,
+ checklist_key=key,
+ title=title,
+ columns=columns,
+ items=[
+ {"number": item_index + 1, "text": item, "value": "na", "comment": ""}
+ for item_index, item in enumerate(items)
+ ],
+ order_index=index,
+ active=True,
+ )
+ )
+
+ def _read_docx_paragraphs(self) -> list[str]:
+ ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
+ with ZipFile(self.reference_path) as archive:
+ root = ET.fromstring(archive.read("word/document.xml"))
+ paragraphs: list[str] = []
+ for paragraph in root.findall(".//w:p", ns):
+ line = "".join(
+ node.text or "" for node in paragraph.findall(".//w:t", ns)
+ ).strip()
+ if line:
+ paragraphs.append(line)
+ return paragraphs
+
+ def _extract_blocks(self, paragraphs: list[str]) -> dict[str, str]:
+ blocks: dict[str, str] = {}
+ heading_by_normalized = {self._normalize(value): key for key, value in TEXT_BLOCK_HEADINGS.items()}
+ active_key: str | None = None
+ buffer: list[str] = []
+ for paragraph in paragraphs:
+ normalized = self._normalize(paragraph)
+ if normalized in heading_by_normalized:
+ if active_key:
+ blocks[active_key] = "\n\n".join(buffer).strip()
+ active_key = heading_by_normalized[normalized]
+ buffer = []
+ continue
+ if active_key and self._looks_like_next_section(paragraph):
+ blocks[active_key] = "\n\n".join(buffer).strip()
+ active_key = None
+ buffer = []
+ continue
+ if active_key:
+ buffer.append(paragraph)
+ if active_key:
+ blocks[active_key] = "\n\n".join(buffer).strip()
+ return blocks
+
+ def _sanitize_reference_text(self, content: str) -> str:
+ replacements = {
+ "Euronda E10.7": "{{ device.manufacturer }} {{ device.model }}",
+ "Euronda / E10.7": "{{ device.manufacturer }} / {{ device.model }}",
+ "EXN250688": "{{ device.serial_number }}",
+ "Dr.Durmaz": "{{ customer.name }}",
+ "Nürnberg": "{{ location.city }}",
+ "05.12.2025": "{{ validation.performed_on }}",
+ "November 2027": "{{ validation.next_validation_on }}",
+ "bestanden": "{{ validation.result }}",
+ }
+ sanitized = content
+ for old, new in replacements.items():
+ sanitized = sanitized.replace(old, new)
+ return sanitized or "nicht erfasst"
+
+ def _normalize(self, value: str) -> str:
+ return re.sub(r"[^a-z0-9]+", "", value.lower())
+
+ def _looks_like_next_section(self, value: str) -> bool:
+ if value in {"Inhaltsverzeichnis", "1.3 Angaben zum Gerät"}:
+ return True
+ return bool(re.match(r"^\d+(\.\d+)*\s", value))
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 b74117f8..5144b649 100644
--- a/validation-suite/backend/mercury/app/modules/orion/templates/report.css
+++ b/validation-suite/backend/mercury/app/modules/orion/templates/report.css
@@ -1,33 +1,14 @@
@page {
size: A4;
- margin: 24mm 16mm 22mm 16mm;
- @top-left {
- content: "Validation Suite";
- color: #4F6A74;
- font-size: 9pt;
- font-weight: 700;
- }
- @top-right {
- content: string(report-number);
- color: #6B7C85;
- font-size: 9pt;
- }
- @bottom-left {
- content: "Schubamed";
- color: #6B7C85;
- font-size: 8pt;
- }
- @bottom-right {
- content: "Seite " counter(page) " von " counter(pages);
- color: #6B7C85;
- font-size: 8pt;
- }
+ margin: 34mm 18mm 24mm 18mm;
+ @top-left { content: element(report-header); }
+ @bottom-left { content: element(report-footer); }
}
@page:first {
- margin: 20mm 16mm 18mm 16mm;
+ margin: 20mm 18mm 20mm 18mm;
@top-left { content: ""; }
- @top-right { content: ""; }
+ @bottom-left { content: ""; }
}
* {
@@ -46,17 +27,137 @@ body {
}
.report-meta {
- string-set: report-number attr(data-report-number);
+ string-set: report-number attr(data-report-number), report-version attr(data-report-version), report-date attr(data-report-date);
+}
+
+.report-header {
+ align-items: center;
+ border-bottom: .25mm solid #DCE3E3;
+ display: grid;
+ grid-template-columns: 1fr 54mm;
+ height: 24mm;
+ left: 0;
+ padding-bottom: 3mm;
+ position: running(report-header);
+ top: 0;
+ width: 174mm;
+}
+
+.report-header-brand {
+ align-items: center;
+ display: flex;
+ gap: 4mm;
+ min-width: 0;
+}
+
+.report-header-brand img {
+ display: block;
+ height: 20mm;
+ width: auto;
+}
+
+.report-header-brand strong {
+ color: #2E3B40;
+ display: block;
+ font-size: 15pt;
+ letter-spacing: 0;
+ line-height: 1.05;
+}
+
+.report-header-brand span {
+ color: #6B7C85;
+ display: block;
+ font-size: 9pt;
+ line-height: 1.35;
+ margin-top: 1mm;
+}
+
+.report-header-meta {
+ color: #4F5E63;
+ display: grid;
+ gap: 1.2mm;
+ margin: 0;
+ text-align: right;
+}
+
+.report-header-meta div,
+.report-footer {
+ display: grid;
+ grid-template-columns: 1fr;
+}
+
+.report-header-meta dt {
+ display: none;
+}
+
+.report-header-meta dd {
+ font-size: 9pt;
+ margin: 0;
+}
+
+.report-header-meta div:first-child dd {
+ color: #2E3B40;
+ font-size: 10pt;
+ font-weight: 700;
+}
+
+.report-footer {
+ align-items: center;
+ border-top: .25mm solid #DCE3E3;
+ color: #6B7C85;
+ font-size: 8pt;
+ grid-template-columns: 1fr 1fr 1fr;
+ height: 10mm;
+ padding-top: 2.5mm;
+ position: running(report-footer);
+ width: 174mm;
+}
+
+.report-footer span:nth-child(2) {
+ text-align: center;
+}
+
+.report-footer span:nth-child(3) {
+ text-align: right;
+}
+
+.page-number::before {
+ content: counter(page);
+}
+
+.page-count::before {
+ content: counter(pages);
}
.cover-page {
min-height: 245mm;
display: flex;
flex-direction: column;
- justify-content: center;
+ justify-content: flex-start;
page-break-after: always;
}
+.cover-top {
+ align-items: flex-start;
+ display: grid;
+ gap: 18mm;
+ grid-template-columns: 1fr 55mm;
+ margin-bottom: 32mm;
+}
+
+.cover-logo {
+ height: 16mm;
+ justify-self: end;
+ width: auto;
+}
+
+.company-address {
+ color: #6B7C85;
+ font-size: 9pt;
+ line-height: 1.5;
+ text-align: right;
+}
+
.cover-kicker {
color: #6C8A96;
font-size: 11pt;
@@ -93,17 +194,29 @@ h1 {
}
.chapter {
- break-before: page;
+ break-before: auto;
+ margin-top: 9mm;
}
h2 {
- border-bottom: 1px solid #E6EAEA;
+ border-bottom: .25mm solid #DCE3E3;
color: #2E3B40;
- font-size: 18pt;
- margin: 0 0 8mm 0;
+ font-size: 22pt;
+ font-weight: 700;
+ letter-spacing: 0;
+ line-height: 1.15;
+ margin: 0 0 7mm 0;
padding-bottom: 4mm;
}
+.chapter + .chapter {
+ margin-top: 12mm;
+}
+
+.chapter > p:only-child {
+ margin-bottom: 0;
+}
+
h3 {
color: #4F6A74;
font-size: 12pt;
@@ -176,6 +289,32 @@ dd {
text-decoration: none;
}
+.figure-grid {
+ display: grid;
+ gap: 8mm;
+ grid-template-columns: 1fr 1fr;
+ margin-top: 8mm;
+}
+
+.report-figure {
+ break-inside: avoid;
+ margin: 0;
+}
+
+.report-image {
+ border: 1px solid #E6EAEA;
+ display: block;
+ max-height: 90mm;
+ object-fit: contain;
+ width: 100%;
+}
+
+figcaption {
+ color: #6B7C85;
+ font-size: 8.5pt;
+ margin-top: 2mm;
+}
+
@media screen {
body {
background: #F7F8F8;
@@ -190,6 +329,14 @@ dd {
padding: 48px;
}
+ .report-header,
+ .report-footer {
+ left: auto;
+ margin: 0 auto 32px auto;
+ position: static;
+ width: 100%;
+ }
+
.cover-page {
min-height: auto;
}
@@ -198,4 +345,12 @@ dd {
break-before: auto;
margin-top: 48px;
}
+
+ .report-footer {
+ margin: 48px auto 0 auto;
+ }
+
+ .figure-grid {
+ gap: 24px;
+ }
}
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 f28c9aca..a850608e 100644
--- a/validation-suite/backend/mercury/app/modules/orion/templates/report.py
+++ b/validation-suite/backend/mercury/app/modules/orion/templates/report.py
@@ -2,12 +2,41 @@ from __future__ import annotations
from pathlib import Path
+from app.modules.orion.assets import schubamed_logo_uri
from app.modules.orion.context import ReportContext
from app.modules.orion.html import text
+def render_report_chrome(context: ReportContext, logo_uri: str) -> str:
+ report_number = text(context.validation.report_number)
+ version = text(context.validation.version)
+ report_date = text(context.validation.updated_at)
+ return f"""
+
+
+ """
+
+
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)
return f"""
@@ -20,9 +49,9 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
-
+
+ {render_report_chrome(context, logo_uri)}
{chapter_markup}