style(orion): align report header footer and page layout
This commit is contained in:
parent
302e542fda
commit
503b343070
40 changed files with 2010 additions and 148 deletions
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
12
validation-suite/backend/mercury/app/modules/orion/assets.py
Normal file
12
validation-suite/backend/mercury/app/modules/orion/assets.py
Normal 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()
|
||||
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
15
validation-suite/backend/mercury/docs/reference/README.md
Normal file
15
validation-suite/backend/mercury/docs/reference/README.md
Normal 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
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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 |
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue