feat(validation): add editing versioning revalidation and aligned reports

This commit is contained in:
Schubert Ferenc 2026-07-11 10:26:18 +02:00
parent f73a24df13
commit 302e542fda
28 changed files with 2691 additions and 406 deletions

View file

@ -0,0 +1,37 @@
from app.modules.orion.components.base import ReportComponent
from app.modules.orion.components.chapters import (
AttachmentComponent,
ChecklistComponent,
CoverComponent,
CustomerComponent,
DeviceComponent,
DryingComponent,
EnvironmentComponent,
EquipmentComponent,
LoadingComponent,
MeasurementComponent,
ProgramComponent,
RecommendationComponent,
StaticTextComponent,
SummaryComponent,
TocComponent,
)
__all__ = [
"AttachmentComponent",
"ChecklistComponent",
"CoverComponent",
"CustomerComponent",
"DeviceComponent",
"DryingComponent",
"EnvironmentComponent",
"EquipmentComponent",
"LoadingComponent",
"MeasurementComponent",
"ProgramComponent",
"RecommendationComponent",
"StaticTextComponent",
"ReportComponent",
"SummaryComponent",
"TocComponent",
]

View file

@ -0,0 +1,15 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from app.modules.orion.context import ReportContext
class ReportComponent(ABC):
anchor: str
title: str
@abstractmethod
def render(self, context: ReportContext) -> str:
raise NotImplementedError

View file

@ -0,0 +1,325 @@
from __future__ import annotations
from app.modules.orion.components.base import ReportComponent
from app.modules.orion.context import ReportContext
from app.modules.orion.html import definition_list, paragraph, section, table, text, yes_no
class CoverComponent(ReportComponent):
anchor = "cover"
title = "Deckblatt"
def render(self, context: ReportContext) -> str:
validation = context.validation
rows = definition_list(
[
("Berichtsnummer", validation.report_number),
("Validierungsart", validation.validation_type),
("Projekt", validation.project),
("Pruefdatum", validation.performed_on),
("Pruefungsort", validation.test_location),
("Pruefer", validation.examiner_name),
("Gesamtergebnis", validation.result),
("Status", validation.status),
("Ansprechpartner", context.contact.full_name if context.contact else "nicht erfasst"),
("Mitwirkende Personen", validation.participants or "nicht erfasst"),
]
)
return (
"<section class=\"cover-page\" id=\"cover\">"
"<div class=\"cover-kicker\">Neutraler Logo-Platzhalter · Validation Suite</div>"
"<h1>Pruefbericht 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}"
"<div class=\"signature-grid\"><div>Unterschrift technische Validierung</div><div>Unterschrift Auftraggeber</div></div>"
"</section>"
)
class TocComponent(ReportComponent):
anchor = "toc"
title = "Inhaltsverzeichnis"
def __init__(self, components: list[ReportComponent]) -> None:
self.components = components
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"}
)
return section(self.anchor, self.title, f"<ol class=\"toc-list\">{links}</ol>")
class SummaryComponent(ReportComponent):
anchor = "summary"
title = "Zusammenfassendes Ergebnis der Validierung"
def render(self, context: ReportContext) -> str:
validation = context.validation
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),
("Pruefmittel", len(context.equipment)),
("Ergebnis", validation.result),
("Mitwirkende Personen", validation.participants),
("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:
self.anchor = anchor
self.title = title
self.body = body
def render(self, context: ReportContext) -> str:
return section(self.anchor, self.title, f"<p>{text(self.body)}</p>")
class CustomerComponent(ReportComponent):
anchor = "customer"
title = "Kunde, Standort und Ansprechpartner"
def render(self, context: ReportContext) -> str:
customer = context.customer
location = context.location
contact = context.contact
body = "<h3>Kunde</h3>" + definition_list(
[
("Name", customer.name),
("Typ", customer.customer_type.value),
("Adresse", " ".join(filter(None, [customer.street, customer.postal_code, customer.city]))),
("Telefon", customer.phone),
("Mail", customer.email),
("Betreiber", context.validation.operator_name),
("QM", customer.quality_manager),
("Hygienebeauftragter", customer.hygiene_officer),
]
)
if location:
body += "<h3>Standort</h3>" + definition_list(
[
("Name", location.name),
("Adresse", " ".join(filter(None, [location.street, location.postal_code, location.city]))),
("Raum", location.room),
]
)
if contact:
body += "<h3>Ansprechpartner</h3>" + definition_list(
[
("Name", contact.full_name),
("Funktion", contact.function),
("Mail", contact.email),
("Telefon", contact.phone),
]
)
return section(self.anchor, self.title, body)
class DeviceComponent(ReportComponent):
anchor = "device"
title = "Geraet"
def render(self, context: ReportContext) -> str:
device = context.device
if device is None:
return section(self.anchor, self.title, "")
body = definition_list(
[
("Hersteller", device.manufacturer),
("Modell", device.model),
("Typ", device.device_type),
("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),
("Dampferzeugung", device.steam_generation),
("Wasseraufbereitung", device.water_treatment),
("Dokumentation", device.documentation),
("Lieferant", device.supplier),
]
)
return section(self.anchor, self.title, body)
class EquipmentComponent(ReportComponent):
anchor = "equipment"
title = "Pruefmittel"
def render(self, context: ReportContext) -> str:
rows = [
[
item.kind.value,
item.manufacturer,
item.model,
item.serial_number,
item.calibrated_on,
item.calibration_due_on,
item.status.value,
]
for item in context.equipment
]
return section(
self.anchor,
self.title,
table(
["Art", "Hersteller", "Modell", "Seriennummer", "Kalibriert", "Gueltig bis", "Status"],
rows,
),
)
class EnvironmentComponent(ReportComponent):
anchor = "environment"
title = "Umgebungsbedingungen"
def render(self, context: ReportContext) -> str:
data = context.validation.environment_conditions or {}
body = definition_list(
[
("Raumtemperatur", data.get("room_temperature")),
("relative Luftfeuchtigkeit", data.get("humidity")),
("Pruefzeit", data.get("test_time")),
]
)
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)
class ChecklistComponent(ReportComponent):
anchor = "checklists"
title = "Dokumentations- und Leistungschecklisten"
def render(self, context: ReportContext) -> str:
documentation = context.validation.documentation_checklist or []
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)
def _render_items(self, items: list[dict]) -> str:
rows = [
[item.get("number"), item.get("text"), yes_no(item.get("value")), item.get("comment")]
for item in items
]
return table(["Nr.", "Pruefpunkt", "Bewertung", "Kommentar"], rows)
class ProgramComponent(ReportComponent):
anchor = "programs"
title = "Programme"
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)]
return section(self.anchor, self.title, table(["Nr.", "Programm", "Typ"], rows))
class LoadingComponent(ReportComponent):
anchor = "loading"
title = "Beladungsmuster"
def render(self, context: ReportContext) -> str:
rows = [
[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))
class MeasurementComponent(ReportComponent):
anchor = "measurements"
title = "Messdaten"
def render(self, context: ReportContext) -> str:
rows = [
[
item.get("name"),
item.get("start_time"),
item.get("end_time"),
item.get("duration"),
item.get("leak_rate"),
item.get("min_temperature"),
item.get("max_temperature"),
item.get("temperature_band"),
item.get("holding_time"),
item.get("pressure"),
item.get("result"),
]
for item in context.validation.measurement_data or []
]
body = table(
[
"Bereich",
"Start",
"Ende",
"Dauer",
"Leckrate",
"Min. Temp.",
"Max. Temp.",
"Band",
"Haltezeit",
"Druck",
"Ergebnis",
],
rows,
"compact",
)
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")])
if winlog_rows:
body += "<h3>Winlog-Dateien</h3>" + table(["Bereich", "Datei", "Typ"], winlog_rows)
return section(self.anchor, self.title, body)
class DryingComponent(ReportComponent):
anchor = "drying"
title = "Trocknung"
def render(self, context: ReportContext) -> str:
data = context.validation.drying or {}
body = definition_list(
[
("Startgewicht", data.get("start_weight")),
("Endgewicht", data.get("end_weight")),
("Differenz", data.get("difference")),
("Bewertung", data.get("rating")),
("Bemerkung", data.get("comment")),
]
)
return section(self.anchor, self.title, body)
class RecommendationComponent(ReportComponent):
anchor = "recommendations"
title = "Empfehlungen und Auflagen"
def render(self, context: ReportContext) -> str:
rows = [
[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))
class AttachmentComponent(ReportComponent):
anchor = "attachments"
title = "Bilder und Anlagen"
def render(self, context: ReportContext) -> str:
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)
]
return section(self.anchor, self.title, table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows))

View file

@ -0,0 +1,64 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
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.location import Location
from app.models.validation import Validation
@dataclass(frozen=True)
class ReportContext:
validation: Validation
customer: Customer
location: Location | None
contact: Contact | None
device: Device | None
equipment: list[Equipment]
generated_dir: Path
class OrionContextBuilder:
def __init__(self, session: Session, generated_dir: Path) -> None:
self.session = session
self.generated_dir = generated_dir
def build(self, validation_id: str) -> ReportContext:
validation = self.session.get(Validation, validation_id)
if validation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Validation not found")
customer = self.session.get(Customer, validation.customer_id)
if customer is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Validation has no customer")
location = self.session.get(Location, validation.location_id) if validation.location_id else None
contact = self.session.get(Contact, validation.contact_id) if validation.contact_id else None
device = self.session.get(Device, validation.device_id) if validation.device_id else None
equipment = []
if validation.equipment_ids:
equipment = list(
self.session.scalars(
select(Equipment).where(Equipment.id.in_(validation.equipment_ids))
)
)
self.generated_dir.mkdir(parents=True, exist_ok=True)
return ReportContext(
validation=validation,
customer=customer,
location=location,
contact=contact,
device=device,
equipment=equipment,
generated_dir=self.generated_dir,
)

View file

@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import date, datetime
from html import escape
from typing import Any
def text(value: Any) -> str:
if value is None or value == "":
return "nicht erfasst"
if isinstance(value, (date, datetime)):
return value.strftime("%d.%m.%Y")
return escape(str(value))
def paragraph(value: Any) -> str:
content = text(value)
return content.replace("\n", "<br>")
def yes_no(value: Any) -> str:
labels = {"yes": "Ja", "no": "Nein", "na": "Nicht zutreffend", True: "Ja", False: "Nein"}
return text(labels.get(value, value))
def definition_list(rows: list[tuple[str, Any]]) -> str:
items = "".join(
f"<div class=\"definition-row\"><dt>{text(label)}</dt><dd>{paragraph(value)}</dd></div>"
for label, value in rows
if value not in (None, "", [])
)
return f"<dl class=\"definition-list\">{items}</dl>"
def table(headers: list[str], rows: list[list[Any]], css_class: str = "") -> str:
head = "".join(f"<th>{text(header)}</th>" for header in headers)
body = "".join(
"<tr>" + "".join(f"<td>{paragraph(cell)}</td>" for cell in row) + "</tr>"
for row in rows
)
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>"

View file

@ -2,29 +2,75 @@ from __future__ import annotations
from pathlib import Path
from weasyprint import HTML
from sqlalchemy.orm import Session
from app.modules.orion.components import (
AttachmentComponent,
ChecklistComponent,
CoverComponent,
CustomerComponent,
DeviceComponent,
DryingComponent,
EnvironmentComponent,
EquipmentComponent,
LoadingComponent,
MeasurementComponent,
ProgramComponent,
RecommendationComponent,
ReportComponent,
StaticTextComponent,
SummaryComponent,
TocComponent,
)
from app.modules.orion.context import OrionContextBuilder, ReportContext
from app.modules.orion.templates.report import render_document
class OrionReportService:
chapters = [
"Deckblatt",
"Inhaltsverzeichnis",
"Zusammenfassung",
"Gerät",
"Kunde",
"Normen",
"Prüfmittel",
"Programme",
"Beladung",
"Messungen",
"Diagramme",
"Empfehlungen",
"Anlagen",
]
def __init__(self, session: Session, generated_dir: Path | None = None) -> None:
self.session = session
self.generated_dir = generated_dir or Path("/app/reports")
def render_pdf(self, title: str, output_path: Path) -> Path:
chapter_markup = "".join(f"<section><h2>{chapter}</h2></section>" for chapter in self.chapters)
html = f"<html><body><h1>{title}</h1>{chapter_markup}</body></html>"
HTML(string=html).write_pdf(output_path)
def render_html(self, validation_id: str) -> str:
context = OrionContextBuilder(self.session, self.generated_dir).build(validation_id)
components = self._components()
chapters = [component.render(context) for component in components]
return render_document(context, chapters)
def render_pdf(self, validation_id: str) -> Path:
from weasyprint import HTML
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)
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"),
DeviceComponent(),
StaticTextComponent("performance", "1.4 Leistungsueberpruefung"),
ChecklistComponent(),
StaticTextComponent("work-instructions", "Arbeitsanweisungen"),
EnvironmentComponent(),
StaticTextComponent("batch-control", "1.9 Chargenkontrolle"),
ProgramComponent(),
LoadingComponent(),
StaticTextComponent("reference-load", "1.12 Referenzbeladung"),
EquipmentComponent(),
StaticTextComponent("equipment-thermo", "2.2 Pruefmittel zur thermoelektrischen Untersuchung"),
StaticTextComponent("configuration", "3. Pruefkonfiguration"),
MeasurementComponent(),
StaticTextComponent("results", "4. Ergebnisse der Validierung"),
DryingComponent(),
RecommendationComponent(),
AttachmentComponent(),
StaticTextComponent("cycles", "6. Programmablaeufe / Zyklen"),
StaticTextComponent("risk", "7. Risikoeinstufung und Abschlussgespraech"),
StaticTextComponent("certificates", "8. Zertifikate"),
StaticTextComponent("calibration-certificates", "9. Kalibrierzertifikate"),
]
return [CoverComponent(), SummaryComponent(), TocComponent(chapters), CustomerComponent(), *chapters]

View file

@ -0,0 +1,201 @@
@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;
}
}
@page:first {
margin: 20mm 16mm 18mm 16mm;
@top-left { content: ""; }
@top-right { content: ""; }
}
* {
box-sizing: border-box;
}
html {
color: #2E3B40;
font-family: Inter, Arial, sans-serif;
font-size: 10.5pt;
line-height: 1.45;
}
body {
margin: 0;
}
.report-meta {
string-set: report-number attr(data-report-number);
}
.cover-page {
min-height: 245mm;
display: flex;
flex-direction: column;
justify-content: center;
page-break-after: always;
}
.cover-kicker {
color: #6C8A96;
font-size: 11pt;
font-weight: 700;
letter-spacing: .08em;
margin-bottom: 14mm;
text-transform: uppercase;
}
h1 {
color: #2E3B40;
font-size: 34pt;
line-height: 1.05;
margin: 0 0 8mm 0;
}
.cover-subtitle {
color: #4F6A74;
font-size: 16pt;
margin: 0 0 18mm 0;
}
.signature-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16mm;
margin-top: 18mm;
}
.signature-grid div {
border-top: 1px solid #6B7C85;
color: #6B7C85;
padding-top: 3mm;
}
.chapter {
break-before: page;
}
h2 {
border-bottom: 1px solid #E6EAEA;
color: #2E3B40;
font-size: 18pt;
margin: 0 0 8mm 0;
padding-bottom: 4mm;
}
h3 {
color: #4F6A74;
font-size: 12pt;
margin: 8mm 0 3mm 0;
}
.definition-list {
display: block;
margin: 0;
}
.definition-row {
border-bottom: 1px solid #E6EAEA;
display: grid;
grid-template-columns: 42mm 1fr;
gap: 6mm;
padding: 2.5mm 0;
}
dt {
color: #6B7C85;
font-weight: 700;
}
dd {
margin: 0;
}
.data-table {
border-collapse: collapse;
margin-top: 4mm;
table-layout: fixed;
width: 100%;
}
.data-table th {
background: #F7F8F8;
color: #4F6A74;
font-size: 8.5pt;
font-weight: 700;
text-align: left;
}
.data-table th,
.data-table td {
border: 1px solid #E6EAEA;
padding: 2.4mm;
vertical-align: top;
word-wrap: break-word;
}
.data-table.compact {
font-size: 8.5pt;
}
.toc-list {
counter-reset: toc;
list-style: none;
margin: 0;
padding: 0;
}
.toc-list li {
border-bottom: 1px solid #E6EAEA;
padding: 3mm 0;
}
.toc-list a {
color: #2E3B40;
text-decoration: none;
}
@media screen {
body {
background: #F7F8F8;
padding: 24px;
}
.report-document {
background: #FFFFFF;
box-shadow: 0 14px 40px rgba(46, 59, 64, 0.08);
margin: 0 auto;
max-width: 960px;
padding: 48px;
}
.cover-page {
min-height: auto;
}
.chapter {
break-before: auto;
margin-top: 48px;
}
}

View file

@ -0,0 +1,28 @@
from __future__ import annotations
from pathlib import Path
from app.modules.orion.context import ReportContext
from app.modules.orion.html import text
def render_document(context: ReportContext, chapters: list[str]) -> str:
css = (Path(__file__).resolve().parent / "report.css").read_text(encoding="utf-8")
title = f"Validierungsbericht {context.validation.report_number}"
chapter_markup = "\n".join(chapters)
return f"""<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{text(title)}</title>
<style>{css}</style>
</head>
<body>
<article class="report-document">
<div class="report-meta" data-report-number="{text(context.validation.report_number)}"></div>
{chapter_markup}
</article>
</body>
</html>"""