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

@ -7,15 +7,17 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev libcairo2 pango1.0-tools libpango-1.0-0 libgdk-pixbuf-2.0-0 libffi-dev \
&& apt-get install -y --no-install-recommends build-essential libpq-dev libcairo2 pango1.0-tools libpango-1.0-0 libgdk-pixbuf-2.0-0 libglib2.0-0 libffi-dev shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml .
RUN pip install --no-cache-dir .
RUN pip install --no-cache-dir ".[dev]"
COPY alembic.ini .
COPY alembic ./alembic
COPY app ./app
COPY tests ./tests
COPY docs ./docs
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
@ -23,4 +25,3 @@ RUN chmod +x /docker-entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "2"]

View file

@ -0,0 +1,139 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "202607110003"
down_revision = "202607110002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"report_templates",
sa.Column("template_key", sa.String(length=120), nullable=False),
sa.Column("name", sa.String(length=180), nullable=False),
sa.Column("version", sa.String(length=40), nullable=False),
sa.Column("validation_type", sa.String(length=120), nullable=False),
sa.Column("reference_path", sa.String(length=500), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_report_templates")),
sa.UniqueConstraint("template_key", name=op.f("uq_report_templates_template_key")),
)
op.create_index(op.f("ix_report_templates_active"), "report_templates", ["active"], unique=False)
op.create_index(op.f("ix_report_templates_template_key"), "report_templates", ["template_key"], unique=False)
op.create_index(op.f("ix_report_templates_validation_type"), "report_templates", ["validation_type"], unique=False)
op.create_table(
"text_blocks",
sa.Column("template_id", sa.String(), nullable=False),
sa.Column("block_key", sa.String(length=160), nullable=False),
sa.Column("title", sa.String(length=240), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("order_index", sa.Integer(), nullable=False),
sa.Column("version", sa.String(length=40), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["template_id"], ["report_templates.id"], name=op.f("fk_text_blocks_template_id_report_templates")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_text_blocks")),
)
op.create_index(op.f("ix_text_blocks_active"), "text_blocks", ["active"], unique=False)
op.create_index(op.f("ix_text_blocks_block_key"), "text_blocks", ["block_key"], unique=False)
op.create_index(op.f("ix_text_blocks_template_id"), "text_blocks", ["template_id"], unique=False)
op.create_table(
"report_sections",
sa.Column("template_id", sa.String(), nullable=False),
sa.Column("section_key", sa.String(length=160), nullable=False),
sa.Column("number", sa.String(length=40), nullable=True),
sa.Column("title", sa.String(length=240), nullable=False),
sa.Column("order_index", sa.Integer(), nullable=False),
sa.Column("page_break_before", sa.Boolean(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["template_id"], ["report_templates.id"], name=op.f("fk_report_sections_template_id_report_templates")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_report_sections")),
)
op.create_index(op.f("ix_report_sections_active"), "report_sections", ["active"], unique=False)
op.create_index(op.f("ix_report_sections_section_key"), "report_sections", ["section_key"], unique=False)
op.create_index(op.f("ix_report_sections_template_id"), "report_sections", ["template_id"], unique=False)
op.create_table(
"checklist_templates",
sa.Column("template_id", sa.String(), nullable=False),
sa.Column("checklist_key", sa.String(length=160), nullable=False),
sa.Column("title", sa.String(length=240), nullable=False),
sa.Column("columns", sa.JSON(), nullable=False),
sa.Column("items", sa.JSON(), nullable=False),
sa.Column("order_index", sa.Integer(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["template_id"], ["report_templates.id"], name=op.f("fk_checklist_templates_template_id_report_templates")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_checklist_templates")),
)
op.create_index(op.f("ix_checklist_templates_active"), "checklist_templates", ["active"], unique=False)
op.create_index(op.f("ix_checklist_templates_checklist_key"), "checklist_templates", ["checklist_key"], unique=False)
op.create_index(op.f("ix_checklist_templates_template_id"), "checklist_templates", ["template_id"], unique=False)
op.create_table(
"measurement_imports",
sa.Column("validation_id", sa.String(), nullable=False),
sa.Column("import_type", sa.String(length=60), nullable=False),
sa.Column("original_filename", sa.String(length=255), nullable=False),
sa.Column("storage_path", sa.String(length=500), nullable=False),
sa.Column("sha256", sa.String(length=64), nullable=False),
sa.Column("parser_version", sa.String(length=40), nullable=False),
sa.Column("status", sa.String(length=60), nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["validation_id"], ["validations.id"], name=op.f("fk_measurement_imports_validation_id_validations")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_measurement_imports")),
)
op.create_index(op.f("ix_measurement_imports_import_type"), "measurement_imports", ["import_type"], unique=False)
op.create_index(op.f("ix_measurement_imports_sha256"), "measurement_imports", ["sha256"], unique=False)
op.create_index(op.f("ix_measurement_imports_status"), "measurement_imports", ["status"], unique=False)
op.create_index(op.f("ix_measurement_imports_validation_id"), "measurement_imports", ["validation_id"], unique=False)
op.create_table(
"measurement_import_values",
sa.Column("import_id", sa.String(), nullable=False),
sa.Column("test_run", sa.String(length=120), nullable=False),
sa.Column("field_name", sa.String(length=120), nullable=False),
sa.Column("raw_value", sa.Text(), nullable=True),
sa.Column("normalized_value", sa.String(length=180), nullable=True),
sa.Column("unit", sa.String(length=40), nullable=True),
sa.Column("source_page", sa.Integer(), nullable=True),
sa.Column("source_text", sa.Text(), nullable=True),
sa.Column("confidence", sa.Integer(), nullable=False),
sa.Column("confirmed", sa.Boolean(), nullable=False),
sa.Column("corrected_value", sa.String(length=180), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["import_id"], ["measurement_imports.id"], name=op.f("fk_measurement_import_values_import_id_measurement_imports")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_measurement_import_values")),
)
op.create_index(op.f("ix_measurement_import_values_confirmed"), "measurement_import_values", ["confirmed"], unique=False)
op.create_index(op.f("ix_measurement_import_values_field_name"), "measurement_import_values", ["field_name"], unique=False)
op.create_index(op.f("ix_measurement_import_values_import_id"), "measurement_import_values", ["import_id"], unique=False)
op.create_index(op.f("ix_measurement_import_values_test_run"), "measurement_import_values", ["test_run"], unique=False)
def downgrade() -> None:
op.drop_table("measurement_import_values")
op.drop_table("measurement_imports")
op.drop_table("checklist_templates")
op.drop_table("report_sections")
op.drop_table("text_blocks")
op.drop_table("report_templates")

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]

View file

@ -0,0 +1,15 @@
# Referenzdateien
Die Dateien in diesem Verzeichnis sind fachliche Referenzen und keine Laufzeitdaten. Sie duerfen nicht durch Benutzeruploads ueberschrieben werden.
| Dateiname | Berichtstyp | Version | Status | Verwendungszweck | Orion-Vorlage |
| --- | --- | --- | --- | --- | --- |
| `reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx` | Erstvalidierung Klein-Sterilisator | 1.0 | gueltig | Fachliche Referenz fuer Kapitelstruktur, Standardtexte, Tabellen, Bild- und Anlagenbereiche | `small_steam_sterilizer_initial_validation` |
| `logos/schubamed-logo.svg` | Firmenlogo | 1.0 | gueltig | Offizielles Logo fuer Atlas und Orion | alle aktiven Berichtsvorlagen |
## Struktur
- `reports/`: fachliche Berichtsvorlagen als Referenzdokumente
- `winlog/`: Winlog-Referenzdateien fuer Parser- und Importtests
- `logos/`: offizielle Markenassets
- `images/`: fachliche Bildreferenzen fuer Berichtslayouts

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

@ -14,9 +14,11 @@ dependencies = [
"python-jose[cryptography]==3.5.0",
"python-multipart==0.0.20",
"python-dateutil==2.9.0.post0",
"pydyf==0.11.0",
"pypdf==6.4.1",
"sqlalchemy==2.0.41",
"uvicorn[standard]==0.35.0",
"weasyprint==62.3"
"weasyprint==69.0"
]
[project.optional-dependencies]

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@ -14,6 +15,9 @@ from app.models.validation import Validation, ValidationStatus
from app.services.validation_workflow import ValidationWorkflowService
from app.schemas.domain import ValidationCreate
from app.modules.orion.service import OrionReportService
from app.modules.orion.assets import SCHUBAMED_LOGO_PATH, schubamed_logo_uri
from app.modules.orion.template_service import ReportTemplateService
from app.modules.helios.service import HeliosImportService
def session() -> Session:
@ -219,7 +223,217 @@ def test_orion_renders_reference_main_chapters(tmp_path):
html = OrionReportService(db, tmp_path).render_html(validation.id)
assert "1. Funktionsqualifikation" in html
assert "2.2 Pruefmittel" in html
assert "4. Ergebnisse der Validierung" in html
assert "9. Kalibrierzertifikate" in html
assert "1 Funktionsqualifikation" in html
assert "2.2 Prüfmittel" in html
assert "4 Ergebnisse der Validierung" in html
assert "9. Werkskalibrierzertifikate Sensoren" in html
def test_orion_renders_uploaded_images_as_real_images(tmp_path):
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
validation.attachments = [
{
"category": "Beladung",
"filename": "beladung.png",
"content_type": "image/png",
"description": "Beladungsmuster Testlauf 1",
"order": 1,
"url": "/uploads/validations/example/beladung.png",
}
]
db.add(validation)
db.flush()
html = OrionReportService(db, tmp_path).render_html(validation.id)
assert '<img class="report-image"' in html
assert "http://localhost:8000/uploads/validations/example/beladung.png" in html
assert "Abbildung 1: Beladungsmuster Testlauf 1" in html
def test_weasyprint_renders_minimal_pdf_bytes():
from weasyprint import HTML
pdf_bytes = HTML(string="<html><body><h1>Orion PDF Test</h1></body></html>").write_pdf()
assert pdf_bytes.startswith(b"%PDF")
assert len(pdf_bytes) > 1024
def test_orion_renders_real_pdf_with_logo(tmp_path):
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
db.add(validation)
db.flush()
assert SCHUBAMED_LOGO_PATH.exists()
html = OrionReportService(db, tmp_path).render_html(validation.id)
pdf_path = OrionReportService(db, tmp_path).render_pdf(validation.id)
assert schubamed_logo_uri() in html
assert "Neutraler Logo-Platzhalter" not in html
assert pdf_path.read_bytes().startswith(b"%PDF")
assert pdf_path.stat().st_size > 1024
def test_orion_header_footer_layout_markers_are_rendered(tmp_path):
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
validation.report_number = "SV-2026-00005"
db.add(validation)
db.flush()
html = OrionReportService(db, tmp_path).render_html(validation.id)
assert 'class="report-header"' in html
assert 'class="report-header-brand"' in html
assert "SCHUBAMED®" in html
assert "Aufbereitung mit System" in html
assert "SV-2026-00005" in html
assert "Version" in html
assert "Datum" in html
assert 'class="report-footer"' in html
assert "Validation Suite" in html
assert "Seite" in html
assert "page-count" in html
def test_orion_layout_css_reserves_header_footer_space():
css = Path("app/modules/orion/templates/report.css").read_text(encoding="utf-8")
assert "margin: 34mm 18mm 24mm 18mm" in css
assert "position: running(report-header)" in css
assert "position: running(report-footer)" in css
assert "grid-template-columns: 1fr 54mm" in css
assert "height: 24mm" in css
assert "height: 20mm" in css
assert "font-size: 22pt" in css
assert "counter(pages)" in css
def test_reference_template_textblocks_and_checklists_are_loaded():
db = session()
bundle = ReportTemplateService(db).ensure_default_template()
assert bundle.template.template_key == "small_steam_sterilizer_initial_validation"
assert ReportTemplateService(db).reference_path.exists()
assert {"summary", "bq_goal", "legal", "performance"} <= set(bundle.text_blocks)
assert len(bundle.checklists) >= 6
assert any(item.title == "Beschreibung Sterilisator" for item in bundle.checklists)
def test_orion_contains_required_reference_sections_and_three_runs(tmp_path):
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
db.add(validation)
db.flush()
html = OrionReportService(db, tmp_path).render_html(validation.id)
for title in [
"Zusammenfassendes Ergebnis der Validierung",
"1.1 Anlass und Ziel der Prüfung",
"1.2 Gesetzliche Grundlagen",
"3.2 Standardbeladung Testlauf 1",
"3.3 Standardbeladung Testlauf 2",
"3.4 Standardbeladung Testlauf 3",
]:
assert title in html
assert "Neutraler Logo-Platzhalter" not in html
assert "report-header-brand" in html
def test_orion_appends_pdf_attachments(tmp_path):
from pypdf import PdfReader, PdfWriter
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
attachment_path = tmp_path / "anlage.pdf"
writer = PdfWriter()
writer.add_blank_page(width=200, height=200)
with attachment_path.open("wb") as handle:
writer.write(handle)
validation.attachments = [
{
"category": "Kalibrierschein",
"filename": "anlage.pdf",
"content_type": "application/pdf",
"description": "Anlage",
"order": 1,
"storage_path": str(attachment_path),
}
]
db.add(validation)
db.flush()
pdf_path = OrionReportService(db, tmp_path).render_pdf(validation.id)
assert len(PdfReader(str(pdf_path)).pages) >= 2
def test_winlog_pdf_import_preview_and_confirmation(tmp_path):
from weasyprint import HTML
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
db.add(validation)
db.flush()
pdf_bytes = HTML(
string="<p>Vakuumtest</p><p>Programm: Vakuum</p><p>Leckrate: 0,1</p><p>Ergebnis: bestanden</p>"
).write_pdf()
preview = HeliosImportService(db, tmp_path).save_winlog_pdf(
validation.id, "winlog.pdf", pdf_bytes
)
assert preview["status"] == "VORSCHAU_BEREIT"
assert preview["sha256"]
assert any(value["field_name"] == "leak_rate" for value in preview["values"])
first = preview["values"][0]
confirmed = HeliosImportService(db, tmp_path).confirm_values(
preview["id"], [{"id": first["id"], "confirmed": True, "corrected_value": "korrigiert"}]
)
assert confirmed["status"] == "BESTAETIGT"
def test_unreadable_winlog_pdf_does_not_crash(tmp_path):
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
db.add(validation)
db.flush()
preview = HeliosImportService(db, tmp_path).save_winlog_pdf(
validation.id, "broken.pdf", b"not a pdf"
)
assert preview["status"] in {"FEHLER", "NUR_ANLAGE"}
assert preview["values"] == []
def test_confirmed_measurements_appear_in_orion_report(tmp_path):
from weasyprint import HTML
db = session()
customer, location, device = seed(db)
validation = valid_validation(customer, location, device)
db.add(validation)
db.flush()
pdf_bytes = HTML(string="<p>Testlauf 1</p><p>Leckrate: 0,2</p>").write_pdf()
preview = HeliosImportService(db, tmp_path).save_winlog_pdf(validation.id, "winlog.pdf", pdf_bytes)
leak_rate = next(value for value in preview["values"] if value["field_name"] == "leak_rate")
HeliosImportService(db, tmp_path).confirm_values(
preview["id"], [{"id": leak_rate["id"], "confirmed": True}]
)
html = OrionReportService(db, tmp_path).render_html(validation.id)
assert "leak_rate" in html
assert "0,2" in html

View file

@ -24,6 +24,7 @@ services:
environment:
DATABASE_URL: postgresql+psycopg://validation:validation123@postgres:5432/validation_suite
JWT_SECRET: validation-suite-local-jwt-secret
PUBLIC_BASE_URL: http://localhost:8000
ADMIN_EMAIL: admin@schubamed.de
ADMIN_PASSWORD: ValidationSuite!2026
ports:

View file

@ -0,0 +1,15 @@
# Referenzdateien
Die Dateien in diesem Verzeichnis sind fachliche Referenzen und keine Laufzeitdaten. Sie duerfen nicht durch Benutzeruploads ueberschrieben werden.
| Dateiname | Berichtstyp | Version | Status | Verwendungszweck | Orion-Vorlage |
| --- | --- | --- | --- | --- | --- |
| `reports/Erstvalidierung_Dr.Durmaz_Steri_12-25.docx` | Erstvalidierung Klein-Sterilisator | 1.0 | gueltig | Fachliche Referenz fuer Kapitelstruktur, Standardtexte, Tabellen, Bild- und Anlagenbereiche | `small_steam_sterilizer_initial_validation` |
| `logos/schubamed-logo.svg` | Firmenlogo | 1.0 | gueltig | Offizielles Logo fuer Atlas und Orion | alle aktiven Berichtsvorlagen |
## Struktur
- `reports/`: fachliche Berichtsvorlagen als Referenzdokumente
- `winlog/`: Winlog-Referenzdateien fuer Parser- und Importtests
- `logos/`: offizielle Markenassets
- `images/`: fachliche Bildreferenzen fuer Berichtslayouts

View file

@ -0,0 +1 @@

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

@ -0,0 +1 @@

View file

@ -1,20 +1,37 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, CheckCircle2, ClipboardList, Clock, FilePenLine } from "lucide-react";
import { AlertTriangle, CheckCircle2, ClipboardList, Clock, FilePenLine, Gauge, Plus, Stethoscope, TestTube2, Users } from "lucide-react";
import Link from "next/link";
import { useAuth } from "@/components/auth";
import { apiGet } from "@/lib/api";
import { BrandLogo } from "@/components/brand/brand-logo";
type DashboardData = {
customers: number;
devices: number;
equipment: number;
validations: number;
validation_drafts: number;
validation_ready: number;
validation_in_review: number;
validation_approved: number;
validation_completed: number;
validation_overdue: number;
equipment_green: number;
equipment_yellow: number;
equipment_red: number;
};
const cards = [
const primaryCards = [
{ label: "Neue Validierung", value: "Starten", href: "/validations/new", icon: Plus },
{ label: "Validierungen", key: "validations", href: "/validations", icon: ClipboardList },
{ label: "Kunden", key: "customers", href: "/customers", icon: Users },
{ label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope },
{ label: "Pruefmittel", key: "equipment", href: "/equipment", icon: TestTube2 }
] as const;
const workflowCards = [
{ label: "Entwuerfe", key: "validation_drafts", href: "/validations?status=ENTWURF", icon: FilePenLine },
{ label: "Bereit zur Pruefung", key: "validation_ready", href: "/validations?status=BEREIT_ZUR_PRUEFUNG", icon: ClipboardList },
{ label: "In Pruefung", key: "validation_in_review", href: "/validations?status=IN_PRUEFUNG", icon: Clock },
@ -32,27 +49,63 @@ export default function DashboardPage() {
return (
<div className="space-y-8">
<header>
<h1 className="text-3xl font-semibold text-text">Dashboard</h1>
<header className="rounded-lg border border-border bg-surface p-6 shadow-soft">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<BrandLogo />
<div className="text-sm text-text-light">
<p>Benutzer: angemeldet</p>
<p>Speicherstatus: synchronisiert</p>
</div>
</div>
<h1 className="mt-6 text-3xl font-semibold text-text">Dashboard</h1>
<p className="mt-2 text-text-light">Validierungsworkflow und faellige Revalidierungen.</p>
</header>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{cards.map((item) => {
{primaryCards.map((item) => {
const Icon = item.icon;
const value = "key" in item ? query.data?.[item.key] ?? 0 : item.value;
return (
<Link key={item.key} href={item.href} className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:-translate-y-0.5 hover:border-primary/40">
<Link key={item.label} href={item.href} className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg">
<div className="flex items-center justify-between">
<p className="text-sm text-text-light">{item.label}</p>
<Icon className="h-5 w-5 text-primary" />
</div>
<p className="mt-6 text-4xl font-semibold text-text">{query.data?.[item.key] ?? 0}</p>
<p className="mt-6 text-4xl font-semibold text-text">{value}</p>
</Link>
);
})}
</section>
<section className="rounded-lg border border-border bg-surface p-6 shadow-soft">
<h2 className="text-xl font-semibold">Zuletzt bearbeitete Validierungen</h2>
<p className="mt-2 text-sm leading-6 text-text-light">Die Validierungsverwaltung bietet Suche, Filter, Sortierung, Vorschau, Export und Workflow-Aktionen.</p>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{workflowCards.map((item) => {
const Icon = item.icon;
return (
<Link key={item.key} href={item.href} className="rounded-lg border border-border bg-surface p-5 shadow-soft transition hover:border-primary/40 hover:bg-background">
<div className="flex items-center justify-between">
<p className="text-sm text-text-light">{item.label}</p>
<Icon className="h-5 w-5 text-primary" />
</div>
<p className="mt-4 text-3xl font-semibold text-text">{query.data?.[item.key] ?? 0}</p>
</Link>
);
})}
</section>
<section className="grid gap-4 xl:grid-cols-4">
<Link href="/validations?sort_by=updated_at&sort_order=desc" className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:border-primary/40 hover:bg-background">
<h2 className="text-xl font-semibold">Zuletzt bearbeitet</h2>
<p className="mt-3 text-sm leading-6 text-text-light">Aktuelle Validierungen nach Bearbeitungsdatum oeffnen.</p>
</Link>
<Link href="/validations?overdue_only=true" className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:border-danger/40 hover:bg-background">
<div className="flex items-center justify-between"><h2 className="text-xl font-semibold">Ueberfaellige Revalidierungen</h2><AlertTriangle className="h-5 w-5 text-danger" /></div>
<p className="mt-4 text-3xl font-semibold text-text">{query.data?.validation_overdue ?? 0}</p>
</Link>
<Link href="/equipment" className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:border-primary/40 hover:bg-background">
<div className="flex items-center justify-between"><h2 className="text-xl font-semibold">Kalibrierstatus</h2><Gauge className="h-5 w-5 text-primary" /></div>
<p className="mt-4 text-sm text-text-light">Gruen {query.data?.equipment_green ?? 0} · Gelb {query.data?.equipment_yellow ?? 0} · Rot {query.data?.equipment_red ?? 0}</p>
</Link>
<Link href="/validations?status=ABGESCHLOSSEN" className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:border-primary/40 hover:bg-background">
<h2 className="text-xl font-semibold">Letzte Berichte</h2>
<p className="mt-4 text-3xl font-semibold text-text">{query.data?.validation_completed ?? 0}</p>
</Link>
</section>
</div>
);

View file

@ -12,6 +12,7 @@ export default function ValidationPreviewPage() {
const params = useParams<{ id: string }>();
const [html, setHtml] = useState("");
const [message, setMessage] = useState("Vorschau wird geladen.");
const [toast, setToast] = useState("");
useEffect(() => {
if (!token || !params.id) return;
@ -33,6 +34,11 @@ export default function ValidationPreviewPage() {
const response = await fetch(`${API_BASE}/validations/${params.id}/report.pdf`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
const body = await response.json().catch(() => null);
setToast(body?.detail ?? "PDF konnte nicht erzeugt werden.");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@ -54,6 +60,7 @@ export default function ValidationPreviewPage() {
<button type="button" onClick={downloadPdf} className="btn btn-primary h-12"><Download className="h-4 w-4" /> PDF herunterladen</button>
</div>
</header>
{toast && <div className="fixed right-4 top-4 z-50 max-w-md rounded-lg border border-danger/30 bg-white p-4 text-sm text-danger shadow-soft">{toast}</div>}
{message ? <div className="rounded-lg border border-border bg-surface p-6 shadow-soft">{message}</div> : <iframe title="Validierungsbericht" srcDoc={html} className="h-[78vh] w-full rounded-lg border border-border bg-white shadow-soft" />}
</div>
);

View file

@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AuthProvider, useAuth } from "@/components/auth";
import { BrandLogo } from "@/components/brand/brand-logo";
import { login } from "@/lib/api";
const schema = z.object({
@ -33,7 +34,7 @@ function LoginPanel() {
<main className="flex min-h-screen items-center justify-center bg-background px-4 py-10">
<section className="w-full max-w-md rounded-lg border border-border bg-surface p-8 shadow-soft">
<div className="mb-8">
<p className="text-sm font-medium text-primary">Validation Suite</p>
<BrandLogo />
<h1 className="mt-2 text-3xl font-semibold text-text">Anmelden</h1>
<p className="mt-3 text-sm leading-6 text-text-light">Sicherer Zugriff auf Atlas Workspace.</p>
</div>

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

@ -0,0 +1,15 @@
import { BrandLogo } from "@/components/brand/brand-logo";
export default function Loading() {
return (
<main className="flex min-h-screen items-center justify-center bg-background">
<div className="rounded-lg border border-border bg-surface p-8 shadow-soft">
<BrandLogo />
<div className="mt-6 flex items-center justify-center gap-3 text-text-light">
<span className="spinner" />
<span>Validation Suite wird geladen.</span>
</div>
</div>
</main>
);
}

View file

@ -0,0 +1,17 @@
import Link from "next/link";
import { BrandLogo } from "@/components/brand/brand-logo";
export default function NotFound() {
return (
<main className="flex min-h-screen items-center justify-center bg-background px-4">
<section className="w-full max-w-lg rounded-lg border border-border bg-surface p-8 text-center shadow-soft">
<div className="flex justify-center">
<BrandLogo />
</div>
<h1 className="mt-8 text-3xl font-semibold">Seite nicht gefunden</h1>
<p className="mt-3 text-text-light">Die angeforderte Seite ist nicht vorhanden oder wurde verschoben.</p>
<Link href="/dashboard" className="btn btn-primary mt-6">Zum Dashboard</Link>
</section>
</main>
);
}

View file

@ -14,6 +14,7 @@ import {
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/components/auth";
import { BrandLogo } from "@/components/brand/brand-logo";
const navigation = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
@ -34,8 +35,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<div className="min-h-screen bg-background text-text">
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface px-5 py-6 lg:block">
<div className="mb-8">
<div className="text-xl font-semibold text-primary-dark">Validation Suite</div>
<div className="mt-1 text-sm text-text-light">Atlas Workspace</div>
<BrandLogo compact />
</div>
<nav className="space-y-1">
{navigation.map((item, index) => {

View file

@ -0,0 +1,9 @@
import Image from "next/image";
export function BrandLogo({ compact = false }: { compact?: boolean }) {
return (
<div className="flex items-center gap-3">
<Image src="/schubamed-logo.svg" alt="SCHUBAMED Validation Suite" width={compact ? 180 : 240} height={64} priority />
</div>
);
}

View file

@ -13,30 +13,6 @@ import { API_BASE, apiGet, apiSend, Contact, Customer, Device, Equipment, Locati
const triState = ["yes", "no", "na"] as const;
const today = new Date().toISOString().slice(0, 10);
const checklistTexts = [
"Gebrauchsanweisung und Herstellerdokumentation vorhanden",
"Wartungsnachweise vollstaendig",
"Kalibrierzertifikate der Pruefmittel gueltig",
"Aufstellbedingungen dokumentiert",
"Wasserqualitaet dokumentiert",
"Chargendokumentation nachvollziehbar",
"Routinekontrollen definiert",
"Freigabeverfahren beschrieben",
"Personal eingewiesen",
"Abweichungen bewertet"
];
const performanceTexts = [
"Vakuumtest entspricht Vorgaben",
"Bowie-Dick / Leerkammerprofil entspricht Vorgaben",
"Temperaturband innerhalb Spezifikation",
"Haltezeit erreicht",
"Druckverlauf plausibel",
"Trocknungsergebnis akzeptabel",
"Beladungsmuster reproduzierbar",
"Sensorpositionen dokumentiert"
];
const attachmentCategories = [
"Aufbereitungsraum",
"reiner Bereich",
@ -95,10 +71,7 @@ const schema = z.object({
type FormValues = z.infer<typeof schema>;
type ReviewIssue = { field: string; message: string; section: string };
type ReviewResult = { status: string; errors: ReviewIssue[]; warnings: ReviewIssue[]; complete_sections: string[] };
function checklist(items: string[]) {
return items.map((text, index) => ({ number: index + 1, text, value: "na", comment: "" }));
}
type ChecklistTemplate = { checklist_key: string; title: string; items: Record<string, unknown>[] };
function defaults(reportNumber = ""): FormValues {
return {
@ -131,8 +104,8 @@ function defaults(reportNumber = ""): FormValues {
{ text: "Medienversorgung verfuegbar", value: "na", comment: "" }
]
},
documentation_checklist: checklist(checklistTexts),
performance_checklist: checklist(performanceTexts),
documentation_checklist: [],
performance_checklist: [],
programs: [
{ name: "Vakuumtest", selected: false, custom: false },
{ name: "Bowie-Dick / Leerkammerprofil", selected: false, custom: false },
@ -191,6 +164,7 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
const [saveState, setSaveState] = useState<ActionState>("normal");
const [reviewState, setReviewState] = useState<ActionState>("normal");
const [pdfState, setPdfState] = useState<ActionState>("normal");
const [uploadingCategory, setUploadingCategory] = useState<string | null>(null);
const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const nextNumber = useQuery({ queryKey: ["next-report-number", token], queryFn: () => apiGet<{ report_number: string }>("/validations/next-report-number", token ?? ""), enabled: Boolean(token) });
@ -199,6 +173,7 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
const contacts = useQuery({ queryKey: ["contacts-options", token], queryFn: () => apiGet<Paginated<Contact>>("/contacts?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
const devices = useQuery({ queryKey: ["devices-options", token], queryFn: () => apiGet<Paginated<Device>>("/devices?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
const equipment = useQuery({ queryKey: ["equipment-options", token], queryFn: () => apiGet<Paginated<Equipment>>("/equipment?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
const checklistTemplates = useQuery({ queryKey: ["report-checklists", token], queryFn: () => apiGet<ChecklistTemplate[]>("/report-templates/default/checklists", token ?? ""), enabled: Boolean(token) });
const existingValidation = useQuery({ queryKey: ["validation", validationId, token], queryFn: () => apiGet<ValidationItem>(`/validations/${validationId}`, token ?? ""), enabled: Boolean(token && validationId) });
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaults() });
@ -236,6 +211,15 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
setDraftId(data.id);
}, [existingValidation.data, form]);
useEffect(() => {
if (!checklistTemplates.data || existingValidation.data) return;
if (form.getValues("documentation_checklist").length > 0) return;
const items = checklistTemplates.data.flatMap((template) =>
template.items.map((item) => ({ ...item, template_key: template.checklist_key, template_title: template.title }))
);
form.setValue("documentation_checklist", items);
}, [checklistTemplates.data, existingValidation.data, form]);
const readonly = existingValidation.data?.status === "FREIGEGEBEN" || existingValidation.data?.status === "ABGESCHLOSSEN";
const saveMutation = useMutation({
@ -327,9 +311,40 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
}
}
function addFiles(files: FileList | null, category: string) {
if (!files) return;
Array.from(files).forEach((file, index) => attachments.append({ category, filename: file.name, description: "", order: attachments.fields.length + index + 1, preview: URL.createObjectURL(file) }));
async function addFiles(files: FileList | null, category: string) {
if (!files || files.length === 0) return;
if (!draftId || !token) {
setFormMessage("Bitte den Entwurf speichern, bevor Bilder oder Anlagen hochgeladen werden.");
return;
}
try {
setUploadingCategory(category);
for (const [index, file] of Array.from(files).entries()) {
const order = attachments.fields.length + index + 1;
const body = new FormData();
body.append("category", category);
body.append("description", "");
body.append("order", String(order));
body.append("file", file);
const response = await fetch(`${API_BASE}/validations/${draftId}/attachments`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body
});
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
throw new Error(errorBody?.detail ?? "Upload fehlgeschlagen.");
}
const uploaded = await response.json();
attachments.append(uploaded);
}
client.invalidateQueries({ queryKey: ["validation", draftId, token] });
setFormMessage("Upload abgeschlossen.");
} catch (error) {
setFormMessage(error instanceof Error ? error.message : "Upload fehlgeschlagen.");
} finally {
setUploadingCategory(null);
}
}
async function openReportPreview() {
@ -352,7 +367,10 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
const response = await fetch(`${API_BASE}/validations/${draftId}/report.pdf`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) throw new Error("PDF konnte nicht erzeugt werden.");
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.detail ?? "PDF konnte nicht erzeugt werden.");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@ -439,8 +457,8 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
</Accordion>
<Accordion title="6. Dokumentations- und Leistungschecklisten">
<h3 className="font-semibold">Dokumentation</h3>{checklistTexts.map((_, index) => <ChecklistRow key={index} form={form} path={`documentation_checklist.${index}`} />)}
<h3 className="mt-6 font-semibold">Leistung</h3>{performanceTexts.map((_, index) => <ChecklistRow key={index} form={form} path={`performance_checklist.${index}`} />)}
{form.watch("documentation_checklist").map((item, index) => <ChecklistRow key={`${String(item.template_key ?? "check")}-${index}`} form={form} path={`documentation_checklist.${index}`} />)}
{form.watch("performance_checklist").map((_, index) => <ChecklistRow key={`performance-${index}`} form={form} path={`performance_checklist.${index}`} />)}
</Accordion>
<Accordion title="7. Programme">
@ -469,8 +487,12 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
</Accordion>
<Accordion title="12. Bilder und Anlagen">
<div className="grid gap-3 md:grid-cols-2">{attachmentCategories.map((category) => <label key={category} className="rounded-lg border border-dashed border-primary/40 p-4"><UploadCloud className="mb-2 h-5 w-5 text-primary" />{category}<input type="file" multiple className="mt-3 block w-full text-sm" onChange={(event) => addFiles(event.target.files, category)} /></label>)}</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">{attachments.fields.map((field, index) => <div key={field.id} className="rounded-lg border border-border p-4"><div className="flex justify-between gap-3"><strong>{String(form.watch(`attachments.${index}.filename`) ?? "")}</strong><button type="button" onClick={() => attachments.remove(index)}><X className="h-4 w-4 text-danger" /></button></div><input className={`${inputClass} mt-3`} placeholder="Beschreibung" {...form.register(`attachments.${index}.description`)} /><input className={`${inputClass} mt-3`} placeholder="Reihenfolge" {...form.register(`attachments.${index}.order`)} /></div>)}</div>
<div className="grid gap-3 md:grid-cols-2">{attachmentCategories.map((category) => <label key={category} className="rounded-lg border border-dashed border-primary/40 p-4 transition hover:border-primary hover:bg-background"><UploadCloud className="mb-2 h-5 w-5 text-primary" />{category}{uploadingCategory === category && <span className="ml-2 text-sm text-text-light">Upload...</span>}<input type="file" multiple className="mt-3 block w-full text-sm" onChange={(event) => addFiles(event.target.files, category)} /></label>)}</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">{attachments.fields.map((field, index) => {
const fileUrl = String(form.watch(`attachments.${index}.url`) ?? "");
const imageUrl = fileUrl && /\.(png|jpe?g|webp|svg)$/i.test(fileUrl) ? `${API_BASE.replace("/api/v1", "")}${fileUrl}` : "";
return <div key={field.id} className="rounded-lg border border-border p-4">{imageUrl && <img src={imageUrl} alt={String(form.watch(`attachments.${index}.filename`) ?? "")} className="mb-3 max-h-56 w-full rounded-lg border border-border object-contain" />}<div className="flex justify-between gap-3"><strong>{String(form.watch(`attachments.${index}.filename`) ?? "")}</strong><button type="button" onClick={() => attachments.remove(index)}><X className="h-4 w-4 text-danger" /></button></div><input className={`${inputClass} mt-3`} placeholder="Beschreibung" {...form.register(`attachments.${index}.description`)} /><input className={`${inputClass} mt-3`} placeholder="Reihenfolge" {...form.register(`attachments.${index}.order`)} /></div>;
})}</div>
</Accordion>
{reviewResult && <section className="rounded-lg border border-border bg-surface p-5 shadow-soft"><h2 className="text-xl font-semibold">Pruefergebnis</h2><div className="mt-4 grid gap-4 md:grid-cols-3"><IssueList title="Fehler" items={reviewResult.errors} tone="danger" /><IssueList title="Warnungen" items={reviewResult.warnings} tone="warning" /><div><h3 className="font-semibold text-success">Vollstaendige Bereiche</h3>{reviewResult.complete_sections.map((item) => <p key={item} className="mt-2 text-sm">{item}</p>)}</div></div></section>}

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