style(orion): align report header footer and page layout

This commit is contained in:
Schubert Ferenc 2026-07-11 12:19:10 +02:00
parent 302e542fda
commit 503b343070
40 changed files with 2010 additions and 148 deletions

View file

@ -3,9 +3,12 @@ from __future__ import annotations
from typing import Any
import json
import logging
import shutil
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, Query, Response, UploadFile
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
@ -19,6 +22,7 @@ from app.models.device import Device
from app.models.equipment import Equipment
from app.models.location import Location
from app.models.validation import Validation
from app.modules.helios.service import HeliosImportService
from app.modules.orion.service import OrionReportService
from app.schemas.common import PaginatedResponse
from app.schemas.domain import (
@ -37,6 +41,8 @@ from app.schemas.domain import (
LocationCreate,
LocationRead,
LocationUpdate,
MeasurementImportConfirmRequest,
MeasurementImportPreviewRead,
ValidationCreate,
ValidationImportPreview,
ValidationImportRequest,
@ -49,6 +55,25 @@ from app.services.domain_service import CrudService, DomainServices
from app.services.validation_workflow import ValidationWorkflowService
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
logger = logging.getLogger(__name__)
@router.get("/report-templates/default/checklists")
def default_report_checklists(session: Session = Depends(get_session)) -> list[dict]:
from app.modules.orion.template_service import ReportTemplateService
bundle = ReportTemplateService(session).ensure_default_template()
session.commit()
return [
{
"checklist_key": item.checklist_key,
"title": item.title,
"columns": item.columns,
"items": item.items,
"order_index": item.order_index,
}
for item in bundle.checklists
]
@router.get("/dashboard")
@ -60,12 +85,47 @@ def dashboard(session: Session = Depends(get_session)) -> dict[str, int]:
"contacts": session.scalar(select(func.count()).select_from(Contact)) or 0,
"devices": session.scalar(select(func.count()).select_from(Device)) or 0,
"equipment": session.scalar(select(func.count()).select_from(Equipment)) or 0,
"equipment_green": session.scalar(
select(func.count()).select_from(Equipment).where(Equipment.status == "green")
)
or 0,
"equipment_yellow": session.scalar(
select(func.count()).select_from(Equipment).where(Equipment.status == "yellow")
)
or 0,
"equipment_red": session.scalar(
select(func.count()).select_from(Equipment).where(Equipment.status == "red")
)
or 0,
"validations": session.scalar(select(func.count()).select_from(Validation)) or 0,
"validation_drafts": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "ENTWURF")) or 0,
"validation_ready": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "BEREIT_ZUR_PRUEFUNG")) or 0,
"validation_in_review": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "IN_PRUEFUNG")) or 0,
"validation_approved": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "FREIGEGEBEN")) or 0,
"validation_overdue": session.scalar(select(func.count()).select_from(Validation).where(Validation.next_validation_on < today)) or 0,
"validation_drafts": session.scalar(
select(func.count()).select_from(Validation).where(Validation.status == "ENTWURF")
)
or 0,
"validation_ready": session.scalar(
select(func.count())
.select_from(Validation)
.where(Validation.status == "BEREIT_ZUR_PRUEFUNG")
)
or 0,
"validation_in_review": session.scalar(
select(func.count()).select_from(Validation).where(Validation.status == "IN_PRUEFUNG")
)
or 0,
"validation_approved": session.scalar(
select(func.count()).select_from(Validation).where(Validation.status == "FREIGEGEBEN")
)
or 0,
"validation_completed": session.scalar(
select(func.count()).select_from(Validation).where(Validation.status == "ABGESCHLOSSEN")
)
or 0,
"validation_overdue": session.scalar(
select(func.count())
.select_from(Validation)
.where(Validation.next_validation_on < today)
)
or 0,
}
@ -89,7 +149,9 @@ def commit_create(session: Session, service: CrudService, payload):
session.rollback()
from fastapi import HTTPException
raise HTTPException(status_code=409, detail="Datensatz verletzt Datenbankbeziehungen.") from exc
raise HTTPException(
status_code=409, detail="Datensatz verletzt Datenbankbeziehungen."
) from exc
def commit_update(session: Session, service: CrudService, item_id: str, payload):
@ -104,7 +166,9 @@ def commit_update(session: Session, service: CrudService, item_id: str, payload)
session.rollback()
from fastapi import HTTPException
raise HTTPException(status_code=409, detail="Datensatz verletzt Datenbankbeziehungen.") from exc
raise HTTPException(
status_code=409, detail="Datensatz verletzt Datenbankbeziehungen."
) from exc
def commit_delete(session: Session, service: CrudService, item_id: str) -> Response:
@ -204,7 +268,9 @@ def create_equipment(payload: EquipmentCreate, session: Session = Depends(get_se
@router.put("/equipment/{item_id}", response_model=EquipmentRead)
def update_equipment(item_id: str, payload: EquipmentUpdate, session: Session = Depends(get_session)):
def update_equipment(
item_id: str, payload: EquipmentUpdate, session: Session = Depends(get_session)
):
return commit_update(session, DomainServices(session).equipment, item_id, payload)
@ -271,7 +337,9 @@ def create_validation(payload: ValidationCreate, session: Session = Depends(get_
@router.put("/validations/{item_id}", response_model=ValidationRead)
def update_validation(item_id: str, payload: ValidationUpdate, session: Session = Depends(get_session)):
def update_validation(
item_id: str, payload: ValidationUpdate, session: Session = Depends(get_session)
):
return commit_update(session, DomainServices(session).validations, item_id, payload)
@ -336,6 +404,96 @@ def cancel_validation(item_id: str, session: Session = Depends(get_session)):
return item
@router.post("/validations/{item_id}/attachments")
def upload_validation_attachment(
item_id: str,
category: str = Form(...),
description: str = Form(default=""),
order: int = Form(default=0),
file: UploadFile = File(...),
session: Session = Depends(get_session),
):
item = DomainServices(session).validations.repository.get(item_id)
if item is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Resource not found")
if item.status in {"FREIGEGEBEN", "ABGESCHLOSSEN"}:
from fastapi import HTTPException
raise HTTPException(status_code=409, detail="Freigegebene Berichte sind schreibgeschuetzt.")
original_name = Path(file.filename or "anlage").name
suffix = Path(original_name).suffix
stored_name = f"{uuid.uuid4().hex}{suffix}"
upload_dir = Path("/app/uploads/validations") / item_id
upload_dir.mkdir(parents=True, exist_ok=True)
storage_path = upload_dir / stored_name
with storage_path.open("wb") as target:
shutil.copyfileobj(file.file, target)
attachment = {
"category": category,
"filename": original_name,
"content_type": file.content_type,
"description": description,
"order": order,
"storage_path": str(storage_path),
"url": f"/uploads/validations/{item_id}/{stored_name}",
}
current = list(item.attachments or [])
current.append(attachment)
item.attachments = current
session.commit()
return attachment
@router.post(
"/validations/{item_id}/measurement-imports/winlog-pdf",
response_model=MeasurementImportPreviewRead,
)
async def upload_winlog_pdf_import(
item_id: str,
file: UploadFile = File(...),
attachment_only: bool = Form(default=False),
session: Session = Depends(get_session),
):
item = DomainServices(session).validations.repository.get(item_id)
if item is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Resource not found")
preview = HeliosImportService(session).save_winlog_pdf(
item_id,
file.filename or "winlog.pdf",
await file.read(),
attachment_only=attachment_only,
)
session.commit()
return preview
@router.post(
"/measurement-imports/{import_id}/confirm",
response_model=MeasurementImportPreviewRead,
)
def confirm_measurement_import(
import_id: str,
payload: MeasurementImportConfirmRequest,
session: Session = Depends(get_session),
):
try:
preview = HeliosImportService(session).confirm_values(
import_id, [item.model_dump() for item in payload.values]
)
except ValueError as exc:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=str(exc)) from exc
session.commit()
return preview
@router.get("/validations/{item_id}/export.json")
def export_validation_json(item_id: str, session: Session = Depends(get_session)):
item = DomainServices(session).validations.repository.get(item_id)
@ -356,8 +514,12 @@ async def preview_validation_csv(file: UploadFile, session: Session = Depends(ge
@router.post("/validations/import/json", response_model=ValidationImportSummary)
def import_validation_json(payload: ValidationImportRequest, session: Session = Depends(get_session)):
summary = ValidationWorkflowService(session).import_rows(payload.rows, payload.duplicate_strategy)
def import_validation_json(
payload: ValidationImportRequest, session: Session = Depends(get_session)
):
summary = ValidationWorkflowService(session).import_rows(
payload.rows, payload.duplicate_strategy
)
session.commit()
return summary
@ -369,7 +531,16 @@ def validation_report_preview(item_id: str, session: Session = Depends(get_sessi
@router.get("/validations/{item_id}/report.pdf")
def validation_report_pdf(item_id: str, session: Session = Depends(get_session)):
report_path = OrionReportService(session, Path("/app/reports")).render_pdf(item_id)
try:
report_path = OrionReportService(session, Path("/app/reports")).render_pdf(item_id)
except Exception as exc:
logger.exception("PDF generation failed for validation %s", item_id)
from fastapi import HTTPException
raise HTTPException(
status_code=502,
detail="Der PDF-Bericht konnte nicht erzeugt werden. Die HTML-Vorschau ist weiterhin verfuegbar.",
) from exc
return FileResponse(
report_path,
media_type="application/pdf",

View file

@ -17,6 +17,7 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256"
access_token_minutes: int = 60 * 8
cors_origins: list[str] = ["http://localhost:3000"]
public_base_url: str = Field(default="http://localhost:8000", alias="PUBLIC_BASE_URL")
admin_email: str = Field(default="admin@schubamed.de", alias="ADMIN_EMAIL")
admin_password: str = Field(default="ValidationSuite!2026", alias="ADMIN_PASSWORD")

View file

@ -1,10 +1,15 @@
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.v1.router import api_router
from app.core.config import settings
from app.db.session import SessionLocal
from app.modules.orion.template_service import ReportTemplateService
app = FastAPI(title=settings.app_name, version="0.1.0")
app.add_middleware(
@ -14,10 +19,18 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
Path("/app/uploads").mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory="/app/uploads"), name="uploads")
app.include_router(api_router)
@app.on_event("startup")
def load_reference_templates() -> None:
with SessionLocal() as session:
ReportTemplateService(session).ensure_default_template()
session.commit()
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}

View file

@ -5,6 +5,14 @@ from app.models.document import Document
from app.models.equipment import Equipment
from app.models.location import Location
from app.models.program import Program
from app.models.report_template import (
ChecklistTemplate,
MeasurementImport,
MeasurementImportValue,
ReportSection,
ReportTemplate,
TextBlock,
)
from app.models.user import User
from app.models.validation import Validation
@ -16,6 +24,12 @@ __all__ = [
"Equipment",
"Location",
"Program",
"ChecklistTemplate",
"MeasurementImport",
"MeasurementImportValue",
"ReportSection",
"ReportTemplate",
"TextBlock",
"User",
"Validation",
]

View file

@ -0,0 +1,98 @@
from __future__ import annotations
import enum
from sqlalchemy import Boolean, ForeignKey, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDMixin
class ReportTemplate(Base, UUIDMixin, TimestampMixin):
__tablename__ = "report_templates"
template_key: Mapped[str] = mapped_column(String(120), unique=True, index=True)
name: Mapped[str] = mapped_column(String(180))
version: Mapped[str] = mapped_column(String(40))
validation_type: Mapped[str] = mapped_column(String(120), index=True)
reference_path: Mapped[str] = mapped_column(String(500))
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
class TextBlock(Base, UUIDMixin, TimestampMixin):
__tablename__ = "text_blocks"
template_id: Mapped[str] = mapped_column(ForeignKey("report_templates.id"), index=True)
block_key: Mapped[str] = mapped_column(String(160), index=True)
title: Mapped[str] = mapped_column(String(240))
content: Mapped[str] = mapped_column(Text)
order_index: Mapped[int] = mapped_column(Integer, default=0)
version: Mapped[str] = mapped_column(String(40), default="1.0")
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
class ReportSection(Base, UUIDMixin, TimestampMixin):
__tablename__ = "report_sections"
template_id: Mapped[str] = mapped_column(ForeignKey("report_templates.id"), index=True)
section_key: Mapped[str] = mapped_column(String(160), index=True)
number: Mapped[str | None] = mapped_column(String(40))
title: Mapped[str] = mapped_column(String(240))
order_index: Mapped[int] = mapped_column(Integer, default=0)
page_break_before: Mapped[bool] = mapped_column(Boolean, default=False)
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
class ChecklistTemplate(Base, UUIDMixin, TimestampMixin):
__tablename__ = "checklist_templates"
template_id: Mapped[str] = mapped_column(ForeignKey("report_templates.id"), index=True)
checklist_key: Mapped[str] = mapped_column(String(160), index=True)
title: Mapped[str] = mapped_column(String(240))
columns: Mapped[list[str]] = mapped_column(JSON, default=list)
items: Mapped[list[dict]] = mapped_column(JSON, default=list)
order_index: Mapped[int] = mapped_column(Integer, default=0)
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
class MeasurementImportStatus(str, enum.Enum):
uploaded = "HOCHGELADEN"
analyzing = "ANALYSE_LAEUFT"
preview_ready = "VORSCHAU_BEREIT"
confirmed = "BESTAETIGT"
error = "FEHLER"
attachment_only = "NUR_ANLAGE"
class MeasurementImportType(str, enum.Enum):
winlog_csv = "WINLOG_CSV"
winlog_pdf = "WINLOG_PDF"
winlog_attachment_only = "WINLOG_ATTACHMENT_ONLY"
class MeasurementImport(Base, UUIDMixin, TimestampMixin):
__tablename__ = "measurement_imports"
validation_id: Mapped[str] = mapped_column(ForeignKey("validations.id"), index=True)
import_type: Mapped[str] = mapped_column(String(60), index=True)
original_filename: Mapped[str] = mapped_column(String(255))
storage_path: Mapped[str] = mapped_column(String(500))
sha256: Mapped[str] = mapped_column(String(64), index=True)
parser_version: Mapped[str] = mapped_column(String(40))
status: Mapped[str] = mapped_column(String(60), index=True)
class MeasurementImportValue(Base, UUIDMixin, TimestampMixin):
__tablename__ = "measurement_import_values"
import_id: Mapped[str] = mapped_column(ForeignKey("measurement_imports.id"), index=True)
test_run: Mapped[str] = mapped_column(String(120), index=True)
field_name: Mapped[str] = mapped_column(String(120), index=True)
raw_value: Mapped[str | None] = mapped_column(Text)
normalized_value: Mapped[str | None] = mapped_column(String(180))
unit: Mapped[str | None] = mapped_column(String(40))
source_page: Mapped[int | None] = mapped_column(Integer)
source_text: Mapped[str | None] = mapped_column(Text)
confidence: Mapped[int] = mapped_column(Integer, default=0)
confirmed: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
corrected_value: Mapped[str | None] = mapped_column(String(180))

View file

@ -1,9 +1,21 @@
from __future__ import annotations
import csv
import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.report_template import (
MeasurementImport,
MeasurementImportStatus,
MeasurementImportType,
MeasurementImportValue,
)
@dataclass(frozen=True)
class MeasurementSeries:
@ -12,8 +24,181 @@ class MeasurementSeries:
class HeliosImportService:
parser_version = "helios-winlog-pdf-1.0"
def __init__(self, session: Session | None = None, upload_root: Path | None = None) -> None:
self.session = session
self.upload_root = upload_root or Path("/app/uploads")
def import_csv(self, path: Path) -> MeasurementSeries:
with path.open(newline="", encoding="utf-8-sig") as handle:
reader = csv.DictReader(handle)
return MeasurementSeries(headers=reader.fieldnames or [], rows=list(reader))
def save_winlog_pdf(
self, validation_id: str, filename: str, content: bytes, attachment_only: bool = False
) -> dict:
if self.session is None:
raise RuntimeError("A database session is required for Winlog imports")
safe_name = Path(filename or "winlog.pdf").name
target_dir = self.upload_root / "validations" / validation_id / "winlog"
target_dir.mkdir(parents=True, exist_ok=True)
storage_path = target_dir / safe_name
storage_path.write_bytes(content)
digest = hashlib.sha256(content).hexdigest()
import_row = MeasurementImport(
validation_id=validation_id,
import_type=(
MeasurementImportType.winlog_attachment_only.value
if attachment_only
else MeasurementImportType.winlog_pdf.value
),
original_filename=safe_name,
storage_path=str(storage_path),
sha256=digest,
parser_version=self.parser_version,
status=(
MeasurementImportStatus.attachment_only.value
if attachment_only
else MeasurementImportStatus.uploaded.value
),
)
self.session.add(import_row)
self.session.flush()
values: list[MeasurementImportValue] = []
if not attachment_only:
values = self._extract_pdf_values(import_row, storage_path)
import_row.status = (
MeasurementImportStatus.preview_ready.value
if values
else MeasurementImportStatus.attachment_only.value
)
import_row.import_type = (
MeasurementImportType.winlog_pdf.value
if values
else MeasurementImportType.winlog_attachment_only.value
)
self.session.flush()
return self.preview(import_row.id)
def preview(self, import_id: str) -> dict:
if self.session is None:
raise RuntimeError("A database session is required for Winlog imports")
import_row = self.session.get(MeasurementImport, import_id)
if import_row is None:
raise ValueError("Measurement import not found")
values = list(
self.session.scalars(
select(MeasurementImportValue).where(MeasurementImportValue.import_id == import_id)
)
)
return {
"id": import_row.id,
"validation_id": import_row.validation_id,
"import_type": import_row.import_type,
"original_filename": import_row.original_filename,
"sha256": import_row.sha256,
"parser_version": import_row.parser_version,
"status": import_row.status,
"values": [
{
"id": value.id,
"test_run": value.test_run,
"field_name": value.field_name,
"raw_value": value.raw_value,
"normalized_value": value.normalized_value,
"unit": value.unit,
"source_page": value.source_page,
"source_text": value.source_text,
"confidence": value.confidence,
"confirmed": value.confirmed,
"corrected_value": value.corrected_value,
}
for value in values
],
}
def confirm_values(self, import_id: str, values: list[dict]) -> dict:
if self.session is None:
raise RuntimeError("A database session is required for Winlog imports")
import_row = self.session.get(MeasurementImport, import_id)
if import_row is None:
raise ValueError("Measurement import not found")
by_id = {
value.id: value
for value in self.session.scalars(
select(MeasurementImportValue).where(MeasurementImportValue.import_id == import_id)
)
}
for payload in values:
item = by_id.get(payload.get("id"))
if item is None:
continue
item.confirmed = bool(payload.get("confirmed"))
item.corrected_value = payload.get("corrected_value") or item.corrected_value
import_row.status = MeasurementImportStatus.confirmed.value
self.session.flush()
return self.preview(import_id)
def _extract_pdf_values(self, import_row: MeasurementImport, path: Path) -> list[MeasurementImportValue]:
from pypdf import PdfReader
values: list[MeasurementImportValue] = []
try:
reader = PdfReader(str(path))
pages = [page.extract_text() or "" for page in reader.pages]
except Exception:
import_row.status = MeasurementImportStatus.error.value
return []
for page_index, page_text in enumerate(pages, start=1):
if not page_text.strip():
continue
test_run = self._detect_test_run(page_text)
for field_name, pattern, unit in self._patterns():
match = re.search(pattern, page_text, flags=re.IGNORECASE)
if not match:
continue
raw_value = match.group(1).strip()
value = MeasurementImportValue(
import_id=import_row.id,
test_run=test_run,
field_name=field_name,
raw_value=raw_value,
normalized_value=raw_value,
unit=unit,
source_page=page_index,
source_text=match.group(0)[:500],
confidence=80,
confirmed=False,
corrected_value=None,
)
self.session.add(value)
values.append(value)
return values
def _detect_test_run(self, text: str) -> str:
lower = text.lower()
if "vakuum" in lower:
return "Vakuumtest"
if "bowie" in lower or "leerkammer" in lower:
return "Bowie-Dick / Leerkammerprofil"
for index in (1, 2, 3):
if f"testlauf {index}" in lower or f"test {index}" in lower:
return f"Testlauf {index}"
return "nicht zugeordnet"
def _patterns(self) -> list[tuple[str, str, str | None]]:
return [
("program_name", r"Programm(?:name)?[:\s]+([^\n]+)", None),
("batch_number", r"Charge(?:nnummer)?[:\s]+([^\n]+)", None),
("start_time", r"Start(?:zeit)?[:\s]+([0-9:.\-\s]+)", None),
("end_time", r"(?:Ende|Endzeit)[:\s]+([0-9:.\-\s]+)", None),
("duration", r"Dauer[:\s]+([0-9:.\-\s]+)", None),
("min_temperature", r"Min(?:dest)?temperatur[:\s]+([0-9,.]+)", "°C"),
("max_temperature", r"Max(?:imal|\.)?temperatur[:\s]+([0-9,.]+)", "°C"),
("temperature_band", r"Temperaturband[:\s]+([0-9,.]+)", "K"),
("holding_time", r"Haltezeit[:\s]+([0-9:.\-\s]+)", None),
("pressure", r"Druck[:\s]+([0-9,.-]+)", "bar"),
("leak_rate", r"Leckrate[:\s]+([0-9,.-]+)", "mbar/min"),
("result", r"Ergebnis[:\s]+([^\n]+)", None),
]

View file

@ -0,0 +1,12 @@
from __future__ import annotations
from pathlib import Path
ORION_ASSET_DIR = Path(__file__).resolve().parent / "assets"
SCHUBAMED_LOGO_PATH = ORION_ASSET_DIR / "schubamed-logo.svg"
def schubamed_logo_uri() -> str:
if not SCHUBAMED_LOGO_PATH.exists():
raise FileNotFoundError(f"Required Orion logo asset is missing: {SCHUBAMED_LOGO_PATH}")
return SCHUBAMED_LOGO_PATH.resolve().as_uri()

View file

@ -0,0 +1,9 @@
<?xml version="1.0" ?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 640 480">
<path d="M476,95 L483,94 L486,97 L486,99 L484,101 L477,102 Z" fill="#F60B2F"/>
<path d="M473,92 L473,112 L476,112 L477,104 L480,104 L485,112 L489,112 L485,106 L489,101 L488,93 Z" fill="#F60B2F"/>
<path d="M476,83 L487,84 L496,92 L498,97 L498,107 L496,111 L485,120 L475,120 L470,118 L464,112 L461,106 L461,98 L464,91 L469,86 Z" fill="#F60B2F"/>
<path d="M329,84 L328,399 L329,401 L338,401 L354,399 L358,396 L358,119 L363,118 L385,127 L391,133 L392,386 L403,381 L417,372 L421,365 L422,158 L432,170 L444,193 L450,211 L454,230 L454,250 L448,281 L441,297 L440,303 L460,316 L466,315 L478,284 L483,255 L483,232 L480,210 L470,178 L454,150 L431,124 L407,106 L378,92 L350,85 Z" fill="#F60B2F"/>
<path d="M308,83 L283,86 L256,94 L235,104 L211,121 L193,139 L178,160 L167,182 L159,208 L159,216 L162,219 L278,285 L281,289 L280,367 L269,364 L250,355 L229,340 L211,320 L203,308 L194,289 L190,285 L186,283 L163,283 L162,288 L168,305 L184,334 L195,348 L209,362 L236,381 L268,395 L298,401 L310,400 L310,271 L303,265 L201,207 L192,200 L207,170 L217,157 L229,145 L255,127 L280,117 L281,200 L284,202 L307,202 L310,200 L310,84 Z" fill="#F60B2F"/>
<path d="M473,80 L467,83 L461,89 L458,95 L458,108 L463,117 L468,121 L476,124 L487,123 L493,120 L498,115 L501,109 L501,94 L497,87 L491,82 L486,80 Z" fill="#F60B2F"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,10 +1,32 @@
from __future__ import annotations
from pathlib import Path
from urllib.parse import quote
from app.core.config import settings
from app.modules.orion.assets import schubamed_logo_uri
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
def render_template_text(content: str, context: ReportContext) -> 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
class CoverComponent(ReportComponent):
anchor = "cover"
title = "Deckblatt"
@ -13,6 +35,12 @@ class CoverComponent(ReportComponent):
validation = context.validation
rows = definition_list(
[
("Hersteller", context.device.manufacturer if context.device else "nicht erfasst"),
("Geraet", context.device.model if context.device else "nicht erfasst"),
(
"Seriennummer",
context.device.serial_number if context.device else "nicht erfasst",
),
("Berichtsnummer", validation.report_number),
("Validierungsart", validation.validation_type),
("Projekt", validation.project),
@ -21,18 +49,25 @@ class CoverComponent(ReportComponent):
("Pruefer", validation.examiner_name),
("Gesamtergebnis", validation.result),
("Status", validation.status),
("Ansprechpartner", context.contact.full_name if context.contact else "nicht erfasst"),
(
"Ansprechpartner",
context.contact.full_name if context.contact else "nicht erfasst",
),
("Mitwirkende Personen", validation.participants or "nicht erfasst"),
]
)
logo_uri = schubamed_logo_uri()
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>"
'<section class="cover-page" id="cover">'
'<div class="cover-top">'
'<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>"
'<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>"
'<div class="signature-grid"><div>Unterschrift technische Validierung</div><div>Unterschrift Auftraggeber</div></div>'
"</section>"
)
@ -46,11 +81,11 @@ class TocComponent(ReportComponent):
def render(self, context: ReportContext) -> str:
links = "".join(
f"<li><a href=\"#{component.anchor}\">{text(component.title)}</a></li>"
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>")
return section(self.anchor, self.title, f'<ol class="toc-list">{links}</ol>')
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"<p>{paragraph(render_template_text(block.content, context) if block else 'nicht erfasst')}</p>"
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"<p>{text(self.body)}</p>")
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>")
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 += "<h3>Standort</h3>" + 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 = "<h3>Dokumentation</h3>" + self._render_items(documentation)
body += "<h3>Leistung</h3>" + self._render_items(performance)
body = ""
for checklist in context.checklist_templates:
body += f"<h3>{text(checklist.title)}</h3>"
body += self._render_items(checklist.items)
if not body:
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:
@ -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 += "<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)
@ -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(
'<figure class="report-figure">'
f'<img class="report-image" src="{text(src)}" alt="{text(caption)}">'
f"<figcaption>Abbildung {index}: {text(caption)}</figcaption>"
"</figure>"
)
body = table(["Reihenfolge", "Kategorie", "Datei", "Beschreibung"], rows)
if figures:
body += '<div class="figure-grid">' + "".join(figures) + "</div>"
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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"""
<header class="report-header" aria-label="Berichtskopf">
<div class="report-header-brand">
<img src="{logo_uri}" alt="SCHUBAMED">
<div>
<strong>SCHUBAMED®</strong>
<span>Aufbereitung mit System</span>
</div>
</div>
<dl class="report-header-meta">
<div><dt>Berichtsnummer</dt><dd>{report_number}</dd></div>
<div><dt>Version</dt><dd>{version}</dd></div>
<div><dt>Datum</dt><dd>{report_date}</dd></div>
</dl>
</header>
<footer class="report-footer" aria-label="Berichtsfuß">
<span>Validation Suite</span>
<span>Seite <span class="page-number"></span> von <span class="page-count"></span></span>
<span>Version {version}</span>
</footer>
"""
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"""<!doctype html>
@ -20,9 +49,9 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
</head>
<body>
<article class="report-document">
<div class="report-meta" data-report-number="{text(context.validation.report_number)}"></div>
<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}
</article>
</body>
</html>"""

View file

@ -206,3 +206,38 @@ class ValidationImportSummary(ORMModel):
skipped: int
failed: int
errors: list[str] = Field(default_factory=list)
class MeasurementImportValueRead(ORMModel):
id: str
test_run: str
field_name: str
raw_value: str | None = None
normalized_value: str | None = None
unit: str | None = None
source_page: int | None = None
source_text: str | None = None
confidence: int
confirmed: bool
corrected_value: str | None = None
class MeasurementImportPreviewRead(ORMModel):
id: str
validation_id: str
import_type: str
original_filename: str
sha256: str
parser_version: str
status: str
values: list[MeasurementImportValueRead]
class MeasurementImportValueConfirm(ORMModel):
id: str
confirmed: bool = False
corrected_value: str | None = None
class MeasurementImportConfirmRequest(ORMModel):
values: list[MeasurementImportValueConfirm]