from __future__ import annotations from pathlib import Path from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER, TA_LEFT from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import mm from reportlab.platypus import ( BaseDocTemplate, Frame, PageTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, KeepTogether ) PAGE_W, PAGE_H = A4 def _header_footer(canvas, doc): canvas.saveState() canvas.setStrokeColor(colors.HexColor("#D0D5DD")) canvas.line(18*mm, PAGE_H-16*mm, PAGE_W-18*mm, PAGE_H-16*mm) canvas.setFont("Helvetica-Bold", 8) canvas.setFillColor(colors.HexColor("#344054")) canvas.drawString(18*mm, PAGE_H-12*mm, "VALIDATION SUITE - PRÜFBERICHT") canvas.setFont("Helvetica", 8) canvas.setFillColor(colors.HexColor("#667085")) canvas.drawRightString(PAGE_W-18*mm, 10*mm, f"Seite {doc.page}") canvas.restoreState() def _table(data, widths=None, header=True): t = Table(data, colWidths=widths, repeatRows=1 if header else 0) style = [ ("GRID", (0,0), (-1,-1), 0.5, colors.HexColor("#D0D5DD")), ("VALIGN", (0,0), (-1,-1), "TOP"), ("LEFTPADDING", (0,0), (-1,-1), 6), ("RIGHTPADDING", (0,0), (-1,-1), 6), ("TOPPADDING", (0,0), (-1,-1), 6), ("BOTTOMPADDING", (0,0), (-1,-1), 6), ] if header: style += [ ("BACKGROUND", (0,0), (-1,0), colors.HexColor("#EEF2FF")), ("TEXTCOLOR", (0,0), (-1,0), colors.HexColor("#3730A3")), ("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"), ] t.setStyle(TableStyle(style)) return t def _placeholder(title, height=85): data = [[Paragraph(f"{title}
Platzhalter - wird aus der jeweiligen Validierung ersetzt.", getSampleStyleSheet()["BodyText"])]] t = Table(data, colWidths=[165*mm], rowHeights=[height*mm]) t.setStyle(TableStyle([ ("BOX",(0,0),(-1,-1),1,colors.HexColor("#98A2B3")), ("BACKGROUND",(0,0),(-1,-1),colors.HexColor("#F8FAFC")), ("VALIGN",(0,0),(-1,-1),"MIDDLE"), ("ALIGN",(0,0),(-1,-1),"CENTER"), ])) return t def build_validation_report(validation, equipment, output_dir: Path) -> str: output_dir.mkdir(parents=True, exist_ok=True) filename = f"{validation.report_number}_V2_Vorschau.pdf" path = output_dir / filename styles = getSampleStyleSheet() styles.add(ParagraphStyle(name="CoverTitle", parent=styles["Title"], fontSize=24, leading=29, alignment=TA_CENTER, textColor=colors.HexColor("#172033"))) styles.add(ParagraphStyle(name="Chapter", parent=styles["Heading1"], fontSize=17, leading=21, spaceBefore=8, spaceAfter=10, textColor=colors.HexColor("#172033"))) styles.add(ParagraphStyle(name="Section", parent=styles["Heading2"], fontSize=13, leading=17, spaceBefore=8, spaceAfter=7, textColor=colors.HexColor("#344054"))) styles.add(ParagraphStyle(name="Small", parent=styles["BodyText"], fontSize=8, leading=11, textColor=colors.HexColor("#667085"))) styles.add(ParagraphStyle(name="BodyV2", parent=styles["BodyText"], fontSize=9.3, leading=13.5, spaceAfter=6)) frame = Frame(18*mm, 17*mm, PAGE_W-36*mm, PAGE_H-36*mm, id="normal") doc = BaseDocTemplate(str(path), pagesize=A4, leftMargin=18*mm, rightMargin=18*mm, topMargin=20*mm, bottomMargin=18*mm) doc.addPageTemplates([PageTemplate(id="main", frames=frame, onPage=_header_footer)]) story = [] # Cover story += [ Spacer(1, 24*mm), _placeholder("GESCHÜTZTES ORIGINALLOGO", 25), Spacer(1, 14*mm), Paragraph("Prüfbericht zur Validierung", styles["CoverTitle"]), Paragraph("Funktions- und Leistungsqualifikation Klein-Sterilisator", styles["Heading2"]), Spacer(1, 10*mm), _table([ ["Hersteller / Gerät", f"{validation.device.manufacturer} / {validation.device.model}"], ["Seriennummer", validation.device.serial_number], ["Projekt", validation.project], ["Auftraggeber / Standort", f"{validation.customer.practice_name}
{validation.customer.operator_name}
{validation.customer.address}"], ["Ort und Datum der Prüfung", f"{validation.test_location}, {validation.test_date.strftime('%d.%m.%Y')}"], ["Technische Validierung durchgeführt", validation.examiner], ["Mitwirkende Personen", validation.participants or "–"], ["Berichtsnummer", validation.report_number], ], widths=[55*mm, 110*mm], header=False), Spacer(1, 12*mm), Paragraph("Nur gültig mit Unterschrift des verantwortlichen Betreibers und des Prüfers.", styles["Small"]), Spacer(1, 12*mm), _table([ ["Ort / Datum", "Unterschrift verantwortlicher Betreiber"], ["", ""], ["Ort / Datum", "Unterschrift Prüfer / Berichtsersteller"], ["", ""], ], widths=[55*mm, 110*mm], header=False), PageBreak(), ] # Summary story += [ Paragraph("Zusammenfassendes Ergebnis der Validierung", styles["Chapter"]), Paragraph( f"Es wurde die Validierung (Leistungsbeurteilung) eines sich im Betrieb befindlichen " f"Klein-Sterilisators (< 1 STE / 60 Liter) durchgeführt. Im Rahmen der Leistungsqualifikation " f"wurden mechanische, thermoelektrische und verfahrenstechnische Leistungsanforderungen überprüft.", styles["BodyV2"] ), Paragraph( "Die geforderten Spezifikationen, Prozesse und Umgebungsbedingungen wurden überprüft. " "Die Bewertung der Messdaten wird nach dem Import der Winlog-Validation-Daten automatisch " "in die Tabellen und Diagramme dieses Berichts übernommen.", styles["BodyV2"] ), _table([ ["Prüfung", "Ergebnis"], ["Vakuumtest", validation.result], ["Bowie-Dick / Leerkammerprofil", validation.result], ["Testlauf 1 - 134 °C hohl verpackt", validation.result], ["Testlauf 2 - 134 °C hohl verpackt", validation.result], ["Testlauf 3 - 134 °C hohl verpackt", validation.result], ["Gesamtergebnis", validation.result], ], widths=[120*mm, 45*mm]), Spacer(1, 8*mm), Paragraph( f"Die nächste Leistungsbeurteilung / Validierung ist vorgesehen für " f"{validation.next_validation.strftime('%m/%Y') if validation.next_validation else 'noch festzulegen'}. " "Prozessrelevante Änderungen können eine erneute Leistungsüberprüfung notwendig machen.", styles["BodyV2"] ), PageBreak(), ] # TOC-style overview toc = [ ("1.", "Funktionsqualifikation (BQ)"), ("1.1", "Anlass und Ziel der Prüfung"), ("1.2", "Gesetzliche Grundlagen"), ("1.3", "Angaben zum Gerät"), ("1.4", "Leistungsüberprüfung"), ("1.5", "Checkliste Leistungsanforderung"), ("1.8", "Umgebungsbedingungen"), ("1.9", "Chargenkontrolle"), ("1.10", "Beschreibung der verwendeten Programme"), ("1.11", "Beladungsbeschreibungen"), ("2.", "Eingesetzte Prüfmittel"), ("2.1", "Beschreibung der Messgeräte"), ("2.2", "Prüfmittel zur thermoelektrischen Untersuchung"), ("3.", "Prüfkonfiguration"), ("3.1", "Leistungsbeurteilung - Thermoelektrische Prüfungen"), ("4.", "Ergebnisse der Validierung"), ("4.2", "Empfehlungen und Auflagen"), ("5.", "Anhang"), ("6.", "Programmabläufe / Zyklen"), ("7.", "Risikoeinstufung / Abschlussgespräch"), ("8.", "Zertifikate"), ("9.", "Werkskalibrierzertifikate Sensoren"), ] story += [Paragraph("Inhaltsverzeichnis", styles["Chapter"]), _table([["Kapitel","Inhalt"]] + toc, widths=[25*mm,140*mm]), PageBreak()] # Static chapters story += [ Paragraph("1. Funktionsqualifikation (BQ)", styles["Chapter"]), Paragraph("1.1 Anlass und Ziel der Prüfung", styles["Section"]), Paragraph( f"Es wurde die Validierung eines sich im Betrieb befindlichen {validation.device.manufacturer} " f"{validation.device.model} Klein-Sterilisators durchgeführt. Die Validierung gilt nur für das " "zum Zeitpunkt der Prüfung berücksichtigte Gerät, die geprüfte Beladung und die verwendeten Programme.", styles["BodyV2"] ), Paragraph( "Mit der Validierung wird der dokumentierte Nachweis erbracht, dass ein Verfahren die vorher " "spezifizierten Anforderungen im praktischen Einsatz reproduzierbar erfüllt.", styles["BodyV2"] ), Paragraph("1.2 Gesetzliche Grundlagen", styles["Section"]), Paragraph( "Die Validierung erfolgt in Anlehnung an die für den Anwendungsbereich maßgeblichen Regelwerke, " "Normen, Leitlinien und Empfehlungen. Die in der Originalvorlage enthaltenen fachlichen Textbausteine " "werden in der finalen Berichtsversion unverändert versioniert übernommen.", styles["BodyV2"] ), _table([ ["Regelwerk / Grundlage"], ["DIN EN ISO 17665 - Sterilisation von Produkten für die Gesundheitsfürsorge"], ["DIN EN 13060 - Dampf-Kleinsterilisatoren"], ["Leitlinien der DGKH, DGSV und AKI"], ["Empfehlungen von RKI und BfArM zur Aufbereitung von Medizinprodukten"], ], widths=[165*mm]), PageBreak(), ] story += [ Paragraph("1.3 Angaben zum Gerät", styles["Chapter"]), _table([ ["Merkmal", "Angabe"], ["Sterilisator-Typ", f"{validation.device.manufacturer} {validation.device.model}"], ["Seriennummer", validation.device.serial_number], ["Baujahr", validation.device.year_built or "–"], ["Erstinbetriebnahme", validation.device.commissioning_date.strftime("%d.%m.%Y") if validation.device.commissioning_date else "–"], ["Kammervolumen", validation.device.chamber_volume or "–"], ["Art der Dampferzeugung", validation.device.steam_generation or "–"], ["Wasserqualität / Versorgung", validation.device.water_supply or "–"], ["Chargendokumentation", validation.device.documentation or "–"], ["Lieferant", validation.device.supplier or "–"], ["Betreiber", validation.customer.operator_name], ["QM / Hygienebeauftragte", validation.customer.hygiene_contact or "–"], ], widths=[60*mm,105*mm]), Spacer(1, 5*mm), _placeholder("Gerätefoto - aktueller Sterilisator", 80), PageBreak(), ] # Checklists checklist_rows = [ ["Prüfkriterium", "Ja", "Nein", "Kommentar"], ["Trennung von Controlling- und Monitoring-Schaltkreisen", "X", "", ""], ["Temperaturüberwachung Sterilisationsprozess", "X", "", ""], ["Drucküberwachung Sterilisationsprozess", "X", "", ""], ["Wasserzulauf / Ablauf", "X", "", "Automatische Versorgung"], ["Automatische Aufzeichnung der Sterilisationsergebnisse", "X", "", validation.device.documentation or ""], ["Überwachung Wasserqualität", "X", "", "Interne Überwachung"], ["Separater Netzanschluss", "X", "", ""], ["Sichtkontrolle Kessel, Sensoren, Türbereich", "X", "", ""], ["Bedienungshandbuch / Installationsprotokoll", "X", "", "Vorhanden"], ["Wartungshandbuch", "X", "", "Vorhanden"], ["Arbeitsanweisungen / Checklisten / Beladungsmuster", "X", "", "Werden im System dokumentiert"], ["Schulungsnachweise der Mitarbeiter", "X", "", ""], ["Risikoeinstufung der Medizinprodukte", "X", "", ""], ["Freigabedokumentation vorhanden", "X", "", ""], ] story += [ Paragraph("1.5 Checkliste Leistungsanforderung", styles["Chapter"]), _table(checklist_rows, widths=[90*mm,15*mm,15*mm,45*mm]), PageBreak(), Paragraph("1.8 Umgebungsbedingungen", styles["Chapter"]), _table([ ["Kontrolle", "Ja", "Nein", "Kommentar"], ["Änderungen seit letzter Validierung", "", "X", validation.validation_type], ["Aufstellungsort und Umgebungsbedingungen in Ordnung", "X", "", ""], ["Relative Luftfeuchtigkeit in Ordnung", "X", "", validation.room_humidity or ""], ["Gerät nach Sichtprüfung in Ordnung", "X", "", ""], ["Trennung zwischen Rein- und Unreinbereich", "X", "", ""], ["Sterilgutlagerung unter geschützten Bedingungen", "X", "", ""], ["Raumbelüftung vorhanden", "X", "", ""], ["Desinfektionsmittel-, Seifen- und Papierhandtuchspender vorhanden", "X", "", ""], ], widths=[92*mm,14*mm,14*mm,45*mm]), Spacer(1, 6*mm), Paragraph( f"Aufbereitungsraum: Raumtemperatur {validation.room_temperature or '–'}, " f"relative Luftfeuchtigkeit {validation.room_humidity or '–'}, gemessen am " f"{validation.test_date.strftime('%d.%m.%Y')}.", styles["BodyV2"] ), _placeholder("Abbildung: Übersicht Aufbereitungsraum", 55), Spacer(1, 4*mm), _placeholder("Abbildung: Reiner / unreiner Bereich", 55), PageBreak(), ] story += [ Paragraph("1.9 Chargenkontrolle", styles["Chapter"]), _table([ ["Kontrolle", "Durchführung", "Prüfintervall", "Prüfkörper"], ["Vakuumtest", "Ja", "wöchentlich", "–"], ["Bowie-Dick-Test", "nach Bedarf", "gemäß Validierungsplan", "Prüfkörper"], ["Chargenüberwachung Chemoindikatoren", "Ja", "bei jeder Charge", "Helix-Test"], ], widths=[55*mm,35*mm,40*mm,35*mm]), Paragraph("1.10 Beschreibung der verwendeten Programme", styles["Chapter"]), Paragraph( "Für alle praxisrelevanten Beladungen werden die nachfolgenden Programme verwendet: " "Vakuumtest, Leerkammerprofil / Bowie-Dick-Simulation sowie drei Testläufe im Programm " "134 °C hohl verpackt.", styles["BodyV2"] ), Paragraph("1.11 Beladungsbeschreibungen", styles["Chapter"]), Paragraph( "Als schwierigste zu sterilisierende Beladung wird eine repräsentative Worst-Case-Beladung " "aus dem tatsächlichen Instrumentarium definiert. Die konkrete Beladung und alle Fotos werden " "je Validierung neu erfasst.", styles["BodyV2"] ), _placeholder("Referenzbeladung - Testlauf 1", 60), Spacer(1, 4*mm), _placeholder("Referenzbeladung - Testlauf 2", 60), PageBreak(), _placeholder("Referenzbeladung - Testlauf 3", 60), Spacer(1, 5*mm), _placeholder("Sensorpositionierung / Prüfkonfiguration", 80), PageBreak(), ] # Equipment eq_data = [["Bezeichnung", "Typ", "Seriennummer", "Messbereich", "Kalibrierung"]] for e in equipment: eq_data.append([ e.designation, f"{e.manufacturer or ''} {e.model or ''}".strip(), e.serial_number, e.measurement_range or "–", e.calibration_date.strftime("%d.%m.%Y") if e.calibration_date else "–", ]) story += [ Paragraph("2. Eingesetzte Prüfmittel", styles["Chapter"]), Paragraph( "Für die Reproduzierbarkeit der thermoelektrischen Prüfungen werden hochauflösende " "Temperatur- und Drucksensoren eingesetzt. Die Auswahl erfolgt je Validierung aus dem " "Prüfmittelregister.", styles["BodyV2"] ), Paragraph("2.1 Beschreibung der Messgeräte", styles["Section"]), _table(eq_data, widths=[40*mm,42*mm,30*mm,33*mm,25*mm]), PageBreak(), Paragraph("2.2 Prüfmittel zur thermoelektrischen Untersuchung", styles["Chapter"]), _placeholder("Abbildung / Beschreibung Bowie-Dick-Simulationstest", 70), Spacer(1, 5*mm), _placeholder("Abbildung / Beschreibung Chargenüberwachungssystem", 70), PageBreak(), ] # Performance qualification story += [ Paragraph("3. Prüfkonfiguration", styles["Chapter"]), _placeholder("Beladungsebenen und Positionierung der Sensoren", 110), Spacer(1, 6*mm), _table([ ["Sensor", "Position / Kanal"], ["Drucksensor P111", "Ebene 1, hinten Mitte"], ["Temperatursensor T235", "Ebene 1, vorne Mitte"], ["Temperatursensor T240", "Position gemäß Fotodokumentation"], ], widths=[70*mm,95*mm]), PageBreak(), Paragraph("3. Leistungsqualifikation (LQ)", styles["Chapter"]), Paragraph("3.1 Leistungsbeurteilung - Thermoelektrische Prüfungen", styles["Section"]), Paragraph( "Der Nachweis der erforderlichen Prozessleistung gilt als erbracht, wenn die in der " "Berichtsvorlage festgelegten Temperatur-, Druck-, Zeit- und Trocknungskriterien eingehalten werden. " "Nach Vorliegen des Winlog-Exports werden diese Regeln automatisch auf die importierten Messdaten angewandt.", styles["BodyV2"] ), Paragraph("1. Vakuumtest", styles["Section"]), Paragraph( f"Der Leckagetest wurde durchgeführt. Erfasste Leckrate: " f"{validation.vacuum_leak_rate or 'noch nicht importiert'}.", styles["BodyV2"] ), _placeholder("Winlog-Diagramm Vakuumtest", 95), PageBreak(), ] # Test runs, each 3 pages for idx in range(1, 4): story += [ Paragraph(f"{idx+1}. Standardbeladung ({idx}. Durchlauf)", styles["Chapter"]), Paragraph(f"Testlauf {idx} - 134 °C hohl verpackt", styles["Section"]), _table([ ["Parameter", "Wert"], ["Programm", "134 °C hohl verpackt"], ["Beladung", f"Referenzbeladung {idx}"], ["Gesamtergebnis", validation.result], ["Sterilisationstemperatur", "Winlog-Import ausstehend"], ["Haltezeit", "Winlog-Import ausstehend"], ["Temperaturband", "Winlog-Import ausstehend"], ["Ausgleichszeit", "Winlog-Import ausstehend"], ], widths=[70*mm,95*mm]), Spacer(1, 6*mm), _placeholder(f"Beladungsfoto Testlauf {idx}", 70), PageBreak(), Paragraph(f"Gesamte Messung Testlauf {idx}", styles["Chapter"]), _placeholder(f"Winlog-Gesamtdiagramm Testlauf {idx}", 150), PageBreak(), Paragraph(f"Gesamtergebnis Testlauf {idx}", styles["Chapter"]), _table([ ["Bewertungskriterium", "Ergebnis"], ["Temperaturen nicht unter Sterilisationstemperatur", "ausstehend"], ["Maximal 3 K oberhalb der gewählten Temperatur", "ausstehend"], ["Abweichung zwischen Sensoren maximal 2 K", "ausstehend"], ["Ausgleichszeit innerhalb Vorgabe", "ausstehend"], ["Haltezeit eingehalten", "ausstehend"], ["Gesamtergebnis", validation.result], ], widths=[120*mm,45*mm]), Spacer(1, 8*mm), _placeholder(f"Detaildiagramme / Kanäle Testlauf {idx}", 95), PageBreak(), ] # Results and recommendations story += [ Paragraph("4. Ergebnisse der Validierung", styles["Chapter"]), Paragraph("4.1 Vakuumtest", styles["Section"]), _table([ ["Prüfung", "Soll", "Ist", "Bewertung"], ["Leckrate", "max. 1,3 mbar/min", validation.vacuum_leak_rate or "–", validation.result], ], widths=[45*mm,45*mm,35*mm,40*mm]), Paragraph("4.1.2 Testläufe 1 bis 3 - Programm 134 °C", styles["Section"]), _table([ ["Testlauf", "Temperaturband", "Haltezeit", "Trocknung", "Ergebnis"], ["Test 1", "Winlog", "Winlog", "Prüfung", validation.result], ["Test 2", "Winlog", "Winlog", "Prüfung", validation.result], ["Test 3", "Winlog", "Winlog", "Prüfung", validation.result], ], widths=[30*mm,38*mm,32*mm,32*mm,33*mm]), Spacer(1, 8*mm), Paragraph("Nachweis der Trocknungseigenschaften Testläufe 1 bis 3", styles["Section"]), _placeholder("Trocknungsnachweis / Wägeergebnisse", 70), PageBreak(), Paragraph("4.2 Empfehlungen und Auflagen", styles["Chapter"]), Paragraph(validation.recommendations or "Keine Auflagen erfasst.", styles["BodyV2"]), _table([ ["Nr.", "Auflage / Empfehlung", "Frist", "Status"], ["1", "Wird über die Weboberfläche erfasst.", "–", "offen"], ], widths=[15*mm,105*mm,25*mm,25*mm]), PageBreak(), ] # Annex skeleton. This deliberately yields a long report similar to source. annexes = [ "5.1 Vakuumtest - Winlog-Auswertung", "Testlauf Bowie-Dick / Leerkammerprofil", "Testlauf 1 - Allgemeine Angaben", "Testlauf 1 - Gesamte Messung", "Testlauf 1 - Gesamtergebnis", "Testlauf 2 - Allgemeine Angaben", "Testlauf 2 - Gesamte Messung", "Testlauf 2 - Gesamtergebnis", "Testlauf 3 - Allgemeine Angaben", "Testlauf 3 - Gesamte Messung", "Testlauf 3 - Gesamtergebnis", "Chargen- und Freigabedokumentation der Praxis", "Chargendokumentation Bowie-Dick / Leerkammerprofil", "Chargendokumentation Testlauf 1", "Chargendokumentation Testlauf 2", "Chargendokumentation Testlauf 3", "5.2 Freigabedokumentation / Routineprüfungen", "5.3 Nachweis umgeschlagener Indikatoren", "6. Programmabläufe / Zyklen des Sterilisators", "6.1 Zertifikate Praxis", "7. Risikoeinstufung der Praxis", "7.1 Abschlussgespräch", "8. Zertifikate", "9. Werkskalibrierzertifikate Sensoren", ] for annex in annexes: story += [ Paragraph(annex, styles["Chapter"]), Paragraph( "Dieses Kapitel ist bereits fest in der Berichtsvorlage vorgesehen. " "Die Originalbilder, Protokolle, Zertifikate oder importierten PDF-Seiten werden " "im produktiven System automatisch an dieser Stelle eingefügt.", styles["BodyV2"] ), _placeholder(annex, 170), PageBreak(), ] # Extra certificate placeholders to reach realistic report length. for i, e in enumerate(equipment, start=1): story += [ Paragraph(f"Kalibrierzertifikat {i}: {e.designation}", styles["Chapter"]), _placeholder(f"Kalibrierzertifikat {e.serial_number} - Seite 1", 205), PageBreak(), Paragraph(f"Kalibrierzertifikat {i}: {e.designation} - Fortsetzung", styles["Chapter"]), _placeholder(f"Kalibrierzertifikat {e.serial_number} - Seite 2", 205), PageBreak(), ] story += [ Paragraph("Abschlussvermerk", styles["Chapter"]), Paragraph( "Diese V2-Vorschau bildet die vollständige Berichtsstruktur ab. Das geschützte Logo, " "die Originalbilder, die konkreten Praxis- und Gerätedaten sowie die Winlog-Messwerte " "werden zukünftig je Validierung dynamisch eingesetzt. Die fachlichen Textbausteine " "werden nach der finalen Abstimmung unverändert versioniert.", styles["BodyV2"] ), Spacer(1, 15*mm), _table([ ["Ort / Datum", "Unterschrift verantwortlicher Betreiber"], ["", ""], ["Ort / Datum", "Unterschrift Prüfer / Berichtsersteller"], ["", ""], ], widths=[55*mm,110*mm], header=False), ] doc.build(story) return filename