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
|
PYTHONPATH=/app
|
||||||
|
|
||||||
RUN apt-get update \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir ".[dev]"
|
||||||
|
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
COPY alembic ./alembic
|
COPY alembic ./alembic
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
|
COPY tests ./tests
|
||||||
|
COPY docs ./docs
|
||||||
|
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
RUN chmod +x /docker-entrypoint.sh
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
@ -23,4 +25,3 @@ RUN chmod +x /docker-entrypoint.sh
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000", "--workers", "2"]
|
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
|
from typing import Any
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
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 fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
@ -19,6 +22,7 @@ from app.models.device import Device
|
||||||
from app.models.equipment import Equipment
|
from app.models.equipment import Equipment
|
||||||
from app.models.location import Location
|
from app.models.location import Location
|
||||||
from app.models.validation import Validation
|
from app.models.validation import Validation
|
||||||
|
from app.modules.helios.service import HeliosImportService
|
||||||
from app.modules.orion.service import OrionReportService
|
from app.modules.orion.service import OrionReportService
|
||||||
from app.schemas.common import PaginatedResponse
|
from app.schemas.common import PaginatedResponse
|
||||||
from app.schemas.domain import (
|
from app.schemas.domain import (
|
||||||
|
|
@ -37,6 +41,8 @@ from app.schemas.domain import (
|
||||||
LocationCreate,
|
LocationCreate,
|
||||||
LocationRead,
|
LocationRead,
|
||||||
LocationUpdate,
|
LocationUpdate,
|
||||||
|
MeasurementImportConfirmRequest,
|
||||||
|
MeasurementImportPreviewRead,
|
||||||
ValidationCreate,
|
ValidationCreate,
|
||||||
ValidationImportPreview,
|
ValidationImportPreview,
|
||||||
ValidationImportRequest,
|
ValidationImportRequest,
|
||||||
|
|
@ -49,6 +55,25 @@ from app.services.domain_service import CrudService, DomainServices
|
||||||
from app.services.validation_workflow import ValidationWorkflowService
|
from app.services.validation_workflow import ValidationWorkflowService
|
||||||
|
|
||||||
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
|
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")
|
@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,
|
"contacts": session.scalar(select(func.count()).select_from(Contact)) or 0,
|
||||||
"devices": session.scalar(select(func.count()).select_from(Device)) 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": 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,
|
"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_drafts": session.scalar(
|
||||||
"validation_ready": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "BEREIT_ZUR_PRUEFUNG")) or 0,
|
select(func.count()).select_from(Validation).where(Validation.status == "ENTWURF")
|
||||||
"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,
|
or 0,
|
||||||
"validation_overdue": session.scalar(select(func.count()).select_from(Validation).where(Validation.next_validation_on < today)) 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()
|
session.rollback()
|
||||||
from fastapi import HTTPException
|
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):
|
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()
|
session.rollback()
|
||||||
from fastapi import HTTPException
|
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:
|
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)
|
@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)
|
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)
|
@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)
|
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
|
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")
|
@router.get("/validations/{item_id}/export.json")
|
||||||
def export_validation_json(item_id: str, session: Session = Depends(get_session)):
|
def export_validation_json(item_id: str, session: Session = Depends(get_session)):
|
||||||
item = DomainServices(session).validations.repository.get(item_id)
|
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)
|
@router.post("/validations/import/json", response_model=ValidationImportSummary)
|
||||||
def import_validation_json(payload: ValidationImportRequest, session: Session = Depends(get_session)):
|
def import_validation_json(
|
||||||
summary = ValidationWorkflowService(session).import_rows(payload.rows, payload.duplicate_strategy)
|
payload: ValidationImportRequest, session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
summary = ValidationWorkflowService(session).import_rows(
|
||||||
|
payload.rows, payload.duplicate_strategy
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return summary
|
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")
|
@router.get("/validations/{item_id}/report.pdf")
|
||||||
def validation_report_pdf(item_id: str, session: Session = Depends(get_session)):
|
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(
|
return FileResponse(
|
||||||
report_path,
|
report_path,
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class Settings(BaseSettings):
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
access_token_minutes: int = 60 * 8
|
access_token_minutes: int = 60 * 8
|
||||||
cors_origins: list[str] = ["http://localhost:3000"]
|
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_email: str = Field(default="admin@schubamed.de", alias="ADMIN_EMAIL")
|
||||||
admin_password: str = Field(default="ValidationSuite!2026", alias="ADMIN_PASSWORD")
|
admin_password: str = Field(default="ValidationSuite!2026", alias="ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
from app.core.config import settings
|
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 = FastAPI(title=settings.app_name, version="0.1.0")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|
@ -14,10 +19,18 @@ app.add_middleware(
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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.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")
|
@app.get("/health")
|
||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ from app.models.document import Document
|
||||||
from app.models.equipment import Equipment
|
from app.models.equipment import Equipment
|
||||||
from app.models.location import Location
|
from app.models.location import Location
|
||||||
from app.models.program import Program
|
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.user import User
|
||||||
from app.models.validation import Validation
|
from app.models.validation import Validation
|
||||||
|
|
||||||
|
|
@ -16,6 +24,12 @@ __all__ = [
|
||||||
"Equipment",
|
"Equipment",
|
||||||
"Location",
|
"Location",
|
||||||
"Program",
|
"Program",
|
||||||
|
"ChecklistTemplate",
|
||||||
|
"MeasurementImport",
|
||||||
|
"MeasurementImportValue",
|
||||||
|
"ReportSection",
|
||||||
|
"ReportTemplate",
|
||||||
|
"TextBlock",
|
||||||
"User",
|
"User",
|
||||||
"Validation",
|
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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)
|
@dataclass(frozen=True)
|
||||||
class MeasurementSeries:
|
class MeasurementSeries:
|
||||||
|
|
@ -12,8 +24,181 @@ class MeasurementSeries:
|
||||||
|
|
||||||
|
|
||||||
class HeliosImportService:
|
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:
|
def import_csv(self, path: Path) -> MeasurementSeries:
|
||||||
with path.open(newline="", encoding="utf-8-sig") as handle:
|
with path.open(newline="", encoding="utf-8-sig") as handle:
|
||||||
reader = csv.DictReader(handle)
|
reader = csv.DictReader(handle)
|
||||||
return MeasurementSeries(headers=reader.fieldnames or [], rows=list(reader))
|
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 __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.components.base import ReportComponent
|
||||||
from app.modules.orion.context import ReportContext
|
from app.modules.orion.context import ReportContext
|
||||||
from app.modules.orion.html import definition_list, paragraph, section, table, text, yes_no
|
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):
|
class CoverComponent(ReportComponent):
|
||||||
anchor = "cover"
|
anchor = "cover"
|
||||||
title = "Deckblatt"
|
title = "Deckblatt"
|
||||||
|
|
@ -13,6 +35,12 @@ class CoverComponent(ReportComponent):
|
||||||
validation = context.validation
|
validation = context.validation
|
||||||
rows = definition_list(
|
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),
|
("Berichtsnummer", validation.report_number),
|
||||||
("Validierungsart", validation.validation_type),
|
("Validierungsart", validation.validation_type),
|
||||||
("Projekt", validation.project),
|
("Projekt", validation.project),
|
||||||
|
|
@ -21,18 +49,25 @@ class CoverComponent(ReportComponent):
|
||||||
("Pruefer", validation.examiner_name),
|
("Pruefer", validation.examiner_name),
|
||||||
("Gesamtergebnis", validation.result),
|
("Gesamtergebnis", validation.result),
|
||||||
("Status", validation.status),
|
("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"),
|
("Mitwirkende Personen", validation.participants or "nicht erfasst"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
logo_uri = schubamed_logo_uri()
|
||||||
return (
|
return (
|
||||||
"<section class=\"cover-page\" id=\"cover\">"
|
'<section class="cover-page" id="cover">'
|
||||||
"<div class=\"cover-kicker\">Neutraler Logo-Platzhalter · Validation Suite</div>"
|
'<div class="cover-top">'
|
||||||
"<h1>Pruefbericht zur Validierung</h1>"
|
'<div class="company-address">SCHUBAMED<br>Validation Suite<br>Medizintechnik und Validierung</div>'
|
||||||
"<p class=\"cover-subtitle\">Funktions- und Leistungsqualifikation Klein-Sterilisator</p>"
|
f'<img class="cover-logo" src="{logo_uri}" alt="SCHUBAMED Validation Suite">'
|
||||||
f"<p class=\"cover-subtitle\">{text(context.customer.name)}</p>"
|
"</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}"
|
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>"
|
"</section>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -46,11 +81,11 @@ class TocComponent(ReportComponent):
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
def render(self, context: ReportContext) -> str:
|
||||||
links = "".join(
|
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
|
for component in self.components
|
||||||
if component.anchor not in {"cover", "toc"}
|
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):
|
class SummaryComponent(ReportComponent):
|
||||||
|
|
@ -59,28 +94,44 @@ class SummaryComponent(ReportComponent):
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
def render(self, context: ReportContext) -> str:
|
||||||
validation = context.validation
|
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),
|
("Kunde", context.customer.name),
|
||||||
("Standort", context.location.name if context.location else None),
|
("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)),
|
("Pruefmittel", len(context.equipment)),
|
||||||
("Ergebnis", validation.result),
|
("Ergebnis", validation.result),
|
||||||
("Mitwirkende Personen", validation.participants),
|
("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)
|
return section(self.anchor, self.title, body)
|
||||||
|
|
||||||
|
|
||||||
class StaticTextComponent(ReportComponent):
|
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.anchor = anchor
|
||||||
self.title = title
|
self.title = title
|
||||||
self.body = body
|
self.body = body
|
||||||
|
self.block_key = block_key
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
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):
|
class CustomerComponent(ReportComponent):
|
||||||
|
|
@ -95,7 +146,10 @@ class CustomerComponent(ReportComponent):
|
||||||
[
|
[
|
||||||
("Name", customer.name),
|
("Name", customer.name),
|
||||||
("Typ", customer.customer_type.value),
|
("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),
|
("Telefon", customer.phone),
|
||||||
("Mail", customer.email),
|
("Mail", customer.email),
|
||||||
("Betreiber", context.validation.operator_name),
|
("Betreiber", context.validation.operator_name),
|
||||||
|
|
@ -107,7 +161,12 @@ class CustomerComponent(ReportComponent):
|
||||||
body += "<h3>Standort</h3>" + definition_list(
|
body += "<h3>Standort</h3>" + definition_list(
|
||||||
[
|
[
|
||||||
("Name", location.name),
|
("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),
|
("Raum", location.room),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
@ -139,7 +198,14 @@ class DeviceComponent(ReportComponent):
|
||||||
("Seriennummer", device.serial_number),
|
("Seriennummer", device.serial_number),
|
||||||
("Baujahr", device.year_built),
|
("Baujahr", device.year_built),
|
||||||
("Inbetriebnahme", device.commissioned_on),
|
("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),
|
("Dampferzeugung", device.steam_generation),
|
||||||
("Wasseraufbereitung", device.water_treatment),
|
("Wasseraufbereitung", device.water_treatment),
|
||||||
("Dokumentation", device.documentation),
|
("Dokumentation", device.documentation),
|
||||||
|
|
@ -170,7 +236,15 @@ class EquipmentComponent(ReportComponent):
|
||||||
self.anchor,
|
self.anchor,
|
||||||
self.title,
|
self.title,
|
||||||
table(
|
table(
|
||||||
["Art", "Hersteller", "Modell", "Seriennummer", "Kalibriert", "Gueltig bis", "Status"],
|
[
|
||||||
|
"Art",
|
||||||
|
"Hersteller",
|
||||||
|
"Modell",
|
||||||
|
"Seriennummer",
|
||||||
|
"Kalibriert",
|
||||||
|
"Gueltig bis",
|
||||||
|
"Status",
|
||||||
|
],
|
||||||
rows,
|
rows,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -189,7 +263,10 @@ class EnvironmentComponent(ReportComponent):
|
||||||
("Pruefzeit", data.get("test_time")),
|
("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:
|
if rows:
|
||||||
body += table(["Pruefpunkt", "Bewertung", "Kommentar"], rows)
|
body += table(["Pruefpunkt", "Bewertung", "Kommentar"], rows)
|
||||||
return section(self.anchor, self.title, body)
|
return section(self.anchor, self.title, body)
|
||||||
|
|
@ -200,10 +277,15 @@ class ChecklistComponent(ReportComponent):
|
||||||
title = "Dokumentations- und Leistungschecklisten"
|
title = "Dokumentations- und Leistungschecklisten"
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
def render(self, context: ReportContext) -> str:
|
||||||
documentation = context.validation.documentation_checklist or []
|
body = ""
|
||||||
performance = context.validation.performance_checklist or []
|
for checklist in context.checklist_templates:
|
||||||
body = "<h3>Dokumentation</h3>" + self._render_items(documentation)
|
body += f"<h3>{text(checklist.title)}</h3>"
|
||||||
body += "<h3>Leistung</h3>" + self._render_items(performance)
|
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)
|
return section(self.anchor, self.title, body)
|
||||||
|
|
||||||
def _render_items(self, items: list[dict]) -> str:
|
def _render_items(self, items: list[dict]) -> str:
|
||||||
|
|
@ -220,7 +302,10 @@ class ProgramComponent(ReportComponent):
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
def render(self, context: ReportContext) -> str:
|
||||||
programs = [item for item in (context.validation.programs or []) if item.get("selected")]
|
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))
|
return section(self.anchor, self.title, table(["Nr.", "Programm", "Typ"], rows))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -230,10 +315,19 @@ class LoadingComponent(ReportComponent):
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
def render(self, context: ReportContext) -> str:
|
||||||
rows = [
|
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 []
|
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):
|
class MeasurementComponent(ReportComponent):
|
||||||
|
|
@ -241,6 +335,23 @@ class MeasurementComponent(ReportComponent):
|
||||||
title = "Messdaten"
|
title = "Messdaten"
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
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 = [
|
rows = [
|
||||||
[
|
[
|
||||||
item.get("name"),
|
item.get("name"),
|
||||||
|
|
@ -277,9 +388,12 @@ class MeasurementComponent(ReportComponent):
|
||||||
winlog_rows = []
|
winlog_rows = []
|
||||||
for item in context.validation.measurement_data or []:
|
for item in context.validation.measurement_data or []:
|
||||||
for imported in item.get("imports", []):
|
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:
|
if winlog_rows:
|
||||||
body += "<h3>Winlog-Dateien</h3>" + table(["Bereich", "Datei", "Typ"], 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)
|
return section(self.anchor, self.title, body)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -318,8 +432,51 @@ class AttachmentComponent(ReportComponent):
|
||||||
title = "Bilder und Anlagen"
|
title = "Bilder und Anlagen"
|
||||||
|
|
||||||
def render(self, context: ReportContext) -> str:
|
def render(self, context: ReportContext) -> str:
|
||||||
|
attachments = sorted(
|
||||||
|
context.validation.attachments or [], key=lambda row: row.get("order") or 0
|
||||||
|
)
|
||||||
rows = [
|
rows = [
|
||||||
[item.get("order"), item.get("category"), item.get("filename"), item.get("description")]
|
[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.customer import Customer
|
||||||
from app.models.device import Device
|
from app.models.device import Device
|
||||||
from app.models.equipment import Equipment
|
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.location import Location
|
||||||
from app.models.validation import Validation
|
from app.models.validation import Validation
|
||||||
|
from app.modules.orion.template_service import ReportTemplateService
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -24,6 +32,10 @@ class ReportContext:
|
||||||
device: Device | None
|
device: Device | None
|
||||||
equipment: list[Equipment]
|
equipment: list[Equipment]
|
||||||
generated_dir: Path
|
generated_dir: Path
|
||||||
|
report_sections: list[ReportSection]
|
||||||
|
text_blocks: dict[str, TextBlock]
|
||||||
|
checklist_templates: list[ChecklistTemplate]
|
||||||
|
confirmed_measurements: list[MeasurementImportValue]
|
||||||
|
|
||||||
|
|
||||||
class OrionContextBuilder:
|
class OrionContextBuilder:
|
||||||
|
|
@ -50,6 +62,17 @@ class OrionContextBuilder:
|
||||||
select(Equipment).where(Equipment.id.in_(validation.equipment_ids))
|
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)
|
self.generated_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return ReportContext(
|
return ReportContext(
|
||||||
|
|
@ -60,5 +83,8 @@ class OrionContextBuilder:
|
||||||
device=device,
|
device=device,
|
||||||
equipment=equipment,
|
equipment=equipment,
|
||||||
generated_dir=self.generated_dir,
|
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 __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.modules.orion.assets import ORION_ASSET_DIR
|
||||||
from app.modules.orion.components import (
|
from app.modules.orion.components import (
|
||||||
AttachmentComponent,
|
AttachmentComponent,
|
||||||
ChecklistComponent,
|
ChecklistComponent,
|
||||||
|
|
@ -25,6 +27,8 @@ from app.modules.orion.components import (
|
||||||
from app.modules.orion.context import OrionContextBuilder, ReportContext
|
from app.modules.orion.context import OrionContextBuilder, ReportContext
|
||||||
from app.modules.orion.templates.report import render_document
|
from app.modules.orion.templates.report import render_document
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OrionReportService:
|
class OrionReportService:
|
||||||
def __init__(self, session: Session, generated_dir: Path | None = None) -> None:
|
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)
|
context = OrionContextBuilder(self.session, self.generated_dir).build(validation_id)
|
||||||
output_path = context.generated_dir / f"{context.validation.report_number}.pdf"
|
output_path = context.generated_dir / f"{context.validation.report_number}.pdf"
|
||||||
html = self.render_html(validation_id)
|
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
|
return output_path
|
||||||
|
|
||||||
def _components(self) -> list[ReportComponent]:
|
def _components(self) -> list[ReportComponent]:
|
||||||
chapters: list[ReportComponent] = [
|
chapters: list[ReportComponent] = [
|
||||||
StaticTextComponent("bq", "1. Funktionsqualifikation (BQ)", "Anlass, Ziel und gesetzliche Grundlagen werden anhand der erfassten Validierungsdaten bewertet."),
|
StaticTextComponent("bq", "1 Funktionsqualifikation (BQ)", "nicht erfasst"),
|
||||||
StaticTextComponent("bq-goal", "1.1 Anlass und Ziel der Pruefung"),
|
StaticTextComponent("bq-goal", "1.1 Anlass und Ziel der Prüfung", block_key="bq_goal"),
|
||||||
StaticTextComponent("legal", "1.2 Gesetzliche Grundlagen"),
|
StaticTextComponent("legal", "1.2 Gesetzliche Grundlagen", block_key="legal"),
|
||||||
DeviceComponent(),
|
DeviceComponent(),
|
||||||
StaticTextComponent("performance", "1.4 Leistungsueberpruefung"),
|
StaticTextComponent("performance", "1.4 Leistungsüberprüfung", block_key="performance"),
|
||||||
ChecklistComponent(),
|
ChecklistComponent(),
|
||||||
StaticTextComponent("work-instructions", "Arbeitsanweisungen"),
|
StaticTextComponent("documentation", "1.6 Dokumentation / Kontrolle"),
|
||||||
|
StaticTextComponent("work-instructions", "1.7 Arbeitsanweisungen"),
|
||||||
EnvironmentComponent(),
|
EnvironmentComponent(),
|
||||||
StaticTextComponent("batch-control", "1.9 Chargenkontrolle"),
|
StaticTextComponent("batch-control", "1.9 Chargenkontrolle"),
|
||||||
ProgramComponent(),
|
ProgramComponent(),
|
||||||
LoadingComponent(),
|
LoadingComponent(),
|
||||||
StaticTextComponent("reference-load", "1.12 Referenzbeladung"),
|
StaticTextComponent("reference-load", "1.12 Referenzbeladung Sterilisator"),
|
||||||
|
StaticTextComponent("equipment", "2 Eingesetzte Prüfmittel"),
|
||||||
EquipmentComponent(),
|
EquipmentComponent(),
|
||||||
StaticTextComponent("equipment-thermo", "2.2 Pruefmittel zur thermoelektrischen Untersuchung"),
|
StaticTextComponent("equipment-thermo", "2.2 Prüfmittel zur thermoelektrischen Untersuchung"),
|
||||||
StaticTextComponent("configuration", "3. Pruefkonfiguration"),
|
StaticTextComponent("configuration", "2.3 Prüfkonfiguration"),
|
||||||
|
StaticTextComponent("lq", "3 Leistungsqualifikation (LQ)"),
|
||||||
|
StaticTextComponent("thermo-tests", "3.1 Leistungsbeurteilung – Thermoelektrische Prüfungen"),
|
||||||
MeasurementComponent(),
|
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(),
|
DryingComponent(),
|
||||||
RecommendationComponent(),
|
RecommendationComponent(),
|
||||||
|
StaticTextComponent("appendix", "5 Anhang"),
|
||||||
AttachmentComponent(),
|
AttachmentComponent(),
|
||||||
StaticTextComponent("cycles", "6. Programmablaeufe / Zyklen"),
|
StaticTextComponent("winlog", "5.1 Winlog-Auswertungen"),
|
||||||
StaticTextComponent("risk", "7. Risikoeinstufung und Abschlussgespraech"),
|
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("certificates", "8. Zertifikate"),
|
||||||
StaticTextComponent("calibration-certificates", "9. Kalibrierzertifikate"),
|
StaticTextComponent("calibration-certificates", "9. Werkskalibrierzertifikate Sensoren"),
|
||||||
]
|
]
|
||||||
return [CoverComponent(), SummaryComponent(), TocComponent(chapters), CustomerComponent(), *chapters]
|
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 {
|
@page {
|
||||||
size: A4;
|
size: A4;
|
||||||
margin: 24mm 16mm 22mm 16mm;
|
margin: 34mm 18mm 24mm 18mm;
|
||||||
@top-left {
|
@top-left { content: element(report-header); }
|
||||||
content: "Validation Suite";
|
@bottom-left { content: element(report-footer); }
|
||||||
color: #4F6A74;
|
|
||||||
font-size: 9pt;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
@top-right {
|
|
||||||
content: string(report-number);
|
|
||||||
color: #6B7C85;
|
|
||||||
font-size: 9pt;
|
|
||||||
}
|
|
||||||
@bottom-left {
|
|
||||||
content: "Schubamed";
|
|
||||||
color: #6B7C85;
|
|
||||||
font-size: 8pt;
|
|
||||||
}
|
|
||||||
@bottom-right {
|
|
||||||
content: "Seite " counter(page) " von " counter(pages);
|
|
||||||
color: #6B7C85;
|
|
||||||
font-size: 8pt;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@page:first {
|
@page:first {
|
||||||
margin: 20mm 16mm 18mm 16mm;
|
margin: 20mm 18mm 20mm 18mm;
|
||||||
@top-left { content: ""; }
|
@top-left { content: ""; }
|
||||||
@top-right { content: ""; }
|
@bottom-left { content: ""; }
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
@ -46,17 +27,137 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-meta {
|
.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 {
|
.cover-page {
|
||||||
min-height: 245mm;
|
min-height: 245mm;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
page-break-after: always;
|
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 {
|
.cover-kicker {
|
||||||
color: #6C8A96;
|
color: #6C8A96;
|
||||||
font-size: 11pt;
|
font-size: 11pt;
|
||||||
|
|
@ -93,17 +194,29 @@ h1 {
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter {
|
.chapter {
|
||||||
break-before: page;
|
break-before: auto;
|
||||||
|
margin-top: 9mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
border-bottom: 1px solid #E6EAEA;
|
border-bottom: .25mm solid #DCE3E3;
|
||||||
color: #2E3B40;
|
color: #2E3B40;
|
||||||
font-size: 18pt;
|
font-size: 22pt;
|
||||||
margin: 0 0 8mm 0;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0 0 7mm 0;
|
||||||
padding-bottom: 4mm;
|
padding-bottom: 4mm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chapter + .chapter {
|
||||||
|
margin-top: 12mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter > p:only-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
color: #4F6A74;
|
color: #4F6A74;
|
||||||
font-size: 12pt;
|
font-size: 12pt;
|
||||||
|
|
@ -176,6 +289,32 @@ dd {
|
||||||
text-decoration: none;
|
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 {
|
@media screen {
|
||||||
body {
|
body {
|
||||||
background: #F7F8F8;
|
background: #F7F8F8;
|
||||||
|
|
@ -190,6 +329,14 @@ dd {
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-header,
|
||||||
|
.report-footer {
|
||||||
|
left: auto;
|
||||||
|
margin: 0 auto 32px auto;
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.cover-page {
|
.cover-page {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
@ -198,4 +345,12 @@ dd {
|
||||||
break-before: auto;
|
break-before: auto;
|
||||||
margin-top: 48px;
|
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 pathlib import Path
|
||||||
|
|
||||||
|
from app.modules.orion.assets import schubamed_logo_uri
|
||||||
from app.modules.orion.context import ReportContext
|
from app.modules.orion.context import ReportContext
|
||||||
from app.modules.orion.html import text
|
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:
|
def render_document(context: ReportContext, chapters: list[str]) -> str:
|
||||||
css = (Path(__file__).resolve().parent / "report.css").read_text(encoding="utf-8")
|
css = (Path(__file__).resolve().parent / "report.css").read_text(encoding="utf-8")
|
||||||
|
logo_uri = schubamed_logo_uri()
|
||||||
title = f"Validierungsbericht {context.validation.report_number}"
|
title = f"Validierungsbericht {context.validation.report_number}"
|
||||||
chapter_markup = "\n".join(chapters)
|
chapter_markup = "\n".join(chapters)
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
|
|
@ -20,9 +49,9 @@ def render_document(context: ReportContext, chapters: list[str]) -> str:
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<article class="report-document">
|
<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}
|
{chapter_markup}
|
||||||
</article>
|
</article>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -206,3 +206,38 @@ class ValidationImportSummary(ORMModel):
|
||||||
skipped: int
|
skipped: int
|
||||||
failed: int
|
failed: int
|
||||||
errors: list[str] = Field(default_factory=list)
|
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-jose[cryptography]==3.5.0",
|
||||||
"python-multipart==0.0.20",
|
"python-multipart==0.0.20",
|
||||||
"python-dateutil==2.9.0.post0",
|
"python-dateutil==2.9.0.post0",
|
||||||
|
"pydyf==0.11.0",
|
||||||
|
"pypdf==6.4.1",
|
||||||
"sqlalchemy==2.0.41",
|
"sqlalchemy==2.0.41",
|
||||||
"uvicorn[standard]==0.35.0",
|
"uvicorn[standard]==0.35.0",
|
||||||
"weasyprint==62.3"
|
"weasyprint==69.0"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session
|
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.services.validation_workflow import ValidationWorkflowService
|
||||||
from app.schemas.domain import ValidationCreate
|
from app.schemas.domain import ValidationCreate
|
||||||
from app.modules.orion.service import OrionReportService
|
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:
|
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)
|
html = OrionReportService(db, tmp_path).render_html(validation.id)
|
||||||
|
|
||||||
assert "1. Funktionsqualifikation" in html
|
assert "1 Funktionsqualifikation" in html
|
||||||
assert "2.2 Pruefmittel" in html
|
assert "2.2 Prüfmittel" in html
|
||||||
assert "4. Ergebnisse der Validierung" in html
|
assert "4 Ergebnisse der Validierung" in html
|
||||||
assert "9. Kalibrierzertifikate" 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
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://validation:validation123@postgres:5432/validation_suite
|
DATABASE_URL: postgresql+psycopg://validation:validation123@postgres:5432/validation_suite
|
||||||
JWT_SECRET: validation-suite-local-jwt-secret
|
JWT_SECRET: validation-suite-local-jwt-secret
|
||||||
|
PUBLIC_BASE_URL: http://localhost:8000
|
||||||
ADMIN_EMAIL: admin@schubamed.de
|
ADMIN_EMAIL: admin@schubamed.de
|
||||||
ADMIN_PASSWORD: ValidationSuite!2026
|
ADMIN_PASSWORD: ValidationSuite!2026
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
15
validation-suite/docs/reference/README.md
Normal file
15
validation-suite/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
|
||||||
1
validation-suite/docs/reference/images/.gitkeep
Normal file
1
validation-suite/docs/reference/images/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
9
validation-suite/docs/reference/logos/schubamed-logo.svg
Normal file
9
validation-suite/docs/reference/logos/schubamed-logo.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" ?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 640 480">
|
||||||
|
<path d="M476,95 L483,94 L486,97 L486,99 L484,101 L477,102 Z" fill="#F60B2F"/>
|
||||||
|
<path d="M473,92 L473,112 L476,112 L477,104 L480,104 L485,112 L489,112 L485,106 L489,101 L488,93 Z" fill="#F60B2F"/>
|
||||||
|
<path d="M476,83 L487,84 L496,92 L498,97 L498,107 L496,111 L485,120 L475,120 L470,118 L464,112 L461,106 L461,98 L464,91 L469,86 Z" fill="#F60B2F"/>
|
||||||
|
<path d="M329,84 L328,399 L329,401 L338,401 L354,399 L358,396 L358,119 L363,118 L385,127 L391,133 L392,386 L403,381 L417,372 L421,365 L422,158 L432,170 L444,193 L450,211 L454,230 L454,250 L448,281 L441,297 L440,303 L460,316 L466,315 L478,284 L483,255 L483,232 L480,210 L470,178 L454,150 L431,124 L407,106 L378,92 L350,85 Z" fill="#F60B2F"/>
|
||||||
|
<path d="M308,83 L283,86 L256,94 L235,104 L211,121 L193,139 L178,160 L167,182 L159,208 L159,216 L162,219 L278,285 L281,289 L280,367 L269,364 L250,355 L229,340 L211,320 L203,308 L194,289 L190,285 L186,283 L163,283 L162,288 L168,305 L184,334 L195,348 L209,362 L236,381 L268,395 L298,401 L310,400 L310,271 L303,265 L201,207 L192,200 L207,170 L217,157 L229,145 L255,127 L280,117 L281,200 L284,202 L307,202 L310,200 L310,84 Z" fill="#F60B2F"/>
|
||||||
|
<path d="M473,80 L467,83 L461,89 L458,95 L458,108 L463,117 L468,121 L476,124 L487,123 L493,120 L498,115 L501,109 L501,94 L497,87 L491,82 L486,80 Z" fill="#F60B2F"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
1
validation-suite/docs/reference/winlog/.gitkeep
Normal file
1
validation-suite/docs/reference/winlog/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -1,20 +1,37 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, CheckCircle2, ClipboardList, Clock, FilePenLine } from "lucide-react";
|
import { AlertTriangle, CheckCircle2, ClipboardList, Clock, FilePenLine, Gauge, Plus, Stethoscope, TestTube2, Users } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAuth } from "@/components/auth";
|
import { useAuth } from "@/components/auth";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
|
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||||
|
|
||||||
type DashboardData = {
|
type DashboardData = {
|
||||||
|
customers: number;
|
||||||
|
devices: number;
|
||||||
|
equipment: number;
|
||||||
|
validations: number;
|
||||||
validation_drafts: number;
|
validation_drafts: number;
|
||||||
validation_ready: number;
|
validation_ready: number;
|
||||||
validation_in_review: number;
|
validation_in_review: number;
|
||||||
validation_approved: number;
|
validation_approved: number;
|
||||||
|
validation_completed: number;
|
||||||
validation_overdue: number;
|
validation_overdue: number;
|
||||||
|
equipment_green: number;
|
||||||
|
equipment_yellow: number;
|
||||||
|
equipment_red: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cards = [
|
const primaryCards = [
|
||||||
|
{ label: "Neue Validierung", value: "Starten", href: "/validations/new", icon: Plus },
|
||||||
|
{ label: "Validierungen", key: "validations", href: "/validations", icon: ClipboardList },
|
||||||
|
{ label: "Kunden", key: "customers", href: "/customers", icon: Users },
|
||||||
|
{ label: "Geraete", key: "devices", href: "/devices", icon: Stethoscope },
|
||||||
|
{ label: "Pruefmittel", key: "equipment", href: "/equipment", icon: TestTube2 }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const workflowCards = [
|
||||||
{ label: "Entwuerfe", key: "validation_drafts", href: "/validations?status=ENTWURF", icon: FilePenLine },
|
{ label: "Entwuerfe", key: "validation_drafts", href: "/validations?status=ENTWURF", icon: FilePenLine },
|
||||||
{ label: "Bereit zur Pruefung", key: "validation_ready", href: "/validations?status=BEREIT_ZUR_PRUEFUNG", icon: ClipboardList },
|
{ label: "Bereit zur Pruefung", key: "validation_ready", href: "/validations?status=BEREIT_ZUR_PRUEFUNG", icon: ClipboardList },
|
||||||
{ label: "In Pruefung", key: "validation_in_review", href: "/validations?status=IN_PRUEFUNG", icon: Clock },
|
{ label: "In Pruefung", key: "validation_in_review", href: "/validations?status=IN_PRUEFUNG", icon: Clock },
|
||||||
|
|
@ -32,27 +49,63 @@ export default function DashboardPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header>
|
<header className="rounded-lg border border-border bg-surface p-6 shadow-soft">
|
||||||
<h1 className="text-3xl font-semibold text-text">Dashboard</h1>
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
|
<BrandLogo />
|
||||||
|
<div className="text-sm text-text-light">
|
||||||
|
<p>Benutzer: angemeldet</p>
|
||||||
|
<p>Speicherstatus: synchronisiert</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-6 text-3xl font-semibold text-text">Dashboard</h1>
|
||||||
<p className="mt-2 text-text-light">Validierungsworkflow und faellige Revalidierungen.</p>
|
<p className="mt-2 text-text-light">Validierungsworkflow und faellige Revalidierungen.</p>
|
||||||
</header>
|
</header>
|
||||||
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
{cards.map((item) => {
|
{primaryCards.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
const value = "key" in item ? query.data?.[item.key] ?? 0 : item.value;
|
||||||
return (
|
return (
|
||||||
<Link key={item.key} href={item.href} className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:-translate-y-0.5 hover:border-primary/40">
|
<Link key={item.label} href={item.href} className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-text-light">{item.label}</p>
|
<p className="text-sm text-text-light">{item.label}</p>
|
||||||
<Icon className="h-5 w-5 text-primary" />
|
<Icon className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-6 text-4xl font-semibold text-text">{query.data?.[item.key] ?? 0}</p>
|
<p className="mt-6 text-4xl font-semibold text-text">{value}</p>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-lg border border-border bg-surface p-6 shadow-soft">
|
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
<h2 className="text-xl font-semibold">Zuletzt bearbeitete Validierungen</h2>
|
{workflowCards.map((item) => {
|
||||||
<p className="mt-2 text-sm leading-6 text-text-light">Die Validierungsverwaltung bietet Suche, Filter, Sortierung, Vorschau, Export und Workflow-Aktionen.</p>
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link key={item.key} href={item.href} className="rounded-lg border border-border bg-surface p-5 shadow-soft transition hover:border-primary/40 hover:bg-background">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-text-light">{item.label}</p>
|
||||||
|
<Icon className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-3xl font-semibold text-text">{query.data?.[item.key] ?? 0}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
<section className="grid gap-4 xl:grid-cols-4">
|
||||||
|
<Link href="/validations?sort_by=updated_at&sort_order=desc" className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:border-primary/40 hover:bg-background">
|
||||||
|
<h2 className="text-xl font-semibold">Zuletzt bearbeitet</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-text-light">Aktuelle Validierungen nach Bearbeitungsdatum oeffnen.</p>
|
||||||
|
</Link>
|
||||||
|
<Link href="/validations?overdue_only=true" className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:border-danger/40 hover:bg-background">
|
||||||
|
<div className="flex items-center justify-between"><h2 className="text-xl font-semibold">Ueberfaellige Revalidierungen</h2><AlertTriangle className="h-5 w-5 text-danger" /></div>
|
||||||
|
<p className="mt-4 text-3xl font-semibold text-text">{query.data?.validation_overdue ?? 0}</p>
|
||||||
|
</Link>
|
||||||
|
<Link href="/equipment" className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:border-primary/40 hover:bg-background">
|
||||||
|
<div className="flex items-center justify-between"><h2 className="text-xl font-semibold">Kalibrierstatus</h2><Gauge className="h-5 w-5 text-primary" /></div>
|
||||||
|
<p className="mt-4 text-sm text-text-light">Gruen {query.data?.equipment_green ?? 0} · Gelb {query.data?.equipment_yellow ?? 0} · Rot {query.data?.equipment_red ?? 0}</p>
|
||||||
|
</Link>
|
||||||
|
<Link href="/validations?status=ABGESCHLOSSEN" className="rounded-lg border border-border bg-surface p-6 shadow-soft transition hover:border-primary/40 hover:bg-background">
|
||||||
|
<h2 className="text-xl font-semibold">Letzte Berichte</h2>
|
||||||
|
<p className="mt-4 text-3xl font-semibold text-text">{query.data?.validation_completed ?? 0}</p>
|
||||||
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export default function ValidationPreviewPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const [html, setHtml] = useState("");
|
const [html, setHtml] = useState("");
|
||||||
const [message, setMessage] = useState("Vorschau wird geladen.");
|
const [message, setMessage] = useState("Vorschau wird geladen.");
|
||||||
|
const [toast, setToast] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !params.id) return;
|
if (!token || !params.id) return;
|
||||||
|
|
@ -33,6 +34,11 @@ export default function ValidationPreviewPage() {
|
||||||
const response = await fetch(`${API_BASE}/validations/${params.id}/report.pdf`, {
|
const response = await fetch(`${API_BASE}/validations/${params.id}/report.pdf`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
setToast(body?.detail ?? "PDF konnte nicht erzeugt werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
|
|
@ -54,6 +60,7 @@ export default function ValidationPreviewPage() {
|
||||||
<button type="button" onClick={downloadPdf} className="btn btn-primary h-12"><Download className="h-4 w-4" /> PDF herunterladen</button>
|
<button type="button" onClick={downloadPdf} className="btn btn-primary h-12"><Download className="h-4 w-4" /> PDF herunterladen</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{toast && <div className="fixed right-4 top-4 z-50 max-w-md rounded-lg border border-danger/30 bg-white p-4 text-sm text-danger shadow-soft">{toast}</div>}
|
||||||
{message ? <div className="rounded-lg border border-border bg-surface p-6 shadow-soft">{message}</div> : <iframe title="Validierungsbericht" srcDoc={html} className="h-[78vh] w-full rounded-lg border border-border bg-white shadow-soft" />}
|
{message ? <div className="rounded-lg border border-border bg-surface p-6 shadow-soft">{message}</div> : <iframe title="Validierungsbericht" srcDoc={html} className="h-[78vh] w-full rounded-lg border border-border bg-white shadow-soft" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AuthProvider, useAuth } from "@/components/auth";
|
import { AuthProvider, useAuth } from "@/components/auth";
|
||||||
|
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||||
import { login } from "@/lib/api";
|
import { login } from "@/lib/api";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
|
|
@ -33,7 +34,7 @@ function LoginPanel() {
|
||||||
<main className="flex min-h-screen items-center justify-center bg-background px-4 py-10">
|
<main className="flex min-h-screen items-center justify-center bg-background px-4 py-10">
|
||||||
<section className="w-full max-w-md rounded-lg border border-border bg-surface p-8 shadow-soft">
|
<section className="w-full max-w-md rounded-lg border border-border bg-surface p-8 shadow-soft">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<p className="text-sm font-medium text-primary">Validation Suite</p>
|
<BrandLogo />
|
||||||
<h1 className="mt-2 text-3xl font-semibold text-text">Anmelden</h1>
|
<h1 className="mt-2 text-3xl font-semibold text-text">Anmelden</h1>
|
||||||
<p className="mt-3 text-sm leading-6 text-text-light">Sicherer Zugriff auf Atlas Workspace.</p>
|
<p className="mt-3 text-sm leading-6 text-text-light">Sicherer Zugriff auf Atlas Workspace.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
9
validation-suite/frontend/atlas/app/icon.svg
Normal file
9
validation-suite/frontend/atlas/app/icon.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" ?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 640 480">
|
||||||
|
<path d="M476,95 L483,94 L486,97 L486,99 L484,101 L477,102 Z" fill="#F60B2F"/>
|
||||||
|
<path d="M473,92 L473,112 L476,112 L477,104 L480,104 L485,112 L489,112 L485,106 L489,101 L488,93 Z" fill="#F60B2F"/>
|
||||||
|
<path d="M476,83 L487,84 L496,92 L498,97 L498,107 L496,111 L485,120 L475,120 L470,118 L464,112 L461,106 L461,98 L464,91 L469,86 Z" fill="#F60B2F"/>
|
||||||
|
<path d="M329,84 L328,399 L329,401 L338,401 L354,399 L358,396 L358,119 L363,118 L385,127 L391,133 L392,386 L403,381 L417,372 L421,365 L422,158 L432,170 L444,193 L450,211 L454,230 L454,250 L448,281 L441,297 L440,303 L460,316 L466,315 L478,284 L483,255 L483,232 L480,210 L470,178 L454,150 L431,124 L407,106 L378,92 L350,85 Z" fill="#F60B2F"/>
|
||||||
|
<path d="M308,83 L283,86 L256,94 L235,104 L211,121 L193,139 L178,160 L167,182 L159,208 L159,216 L162,219 L278,285 L281,289 L280,367 L269,364 L250,355 L229,340 L211,320 L203,308 L194,289 L190,285 L186,283 L163,283 L162,288 L168,305 L184,334 L195,348 L209,362 L236,381 L268,395 L298,401 L310,400 L310,271 L303,265 L201,207 L192,200 L207,170 L217,157 L229,145 L255,127 L280,117 L281,200 L284,202 L307,202 L310,200 L310,84 Z" fill="#F60B2F"/>
|
||||||
|
<path d="M473,80 L467,83 L461,89 L458,95 L458,108 L463,117 L468,121 L476,124 L487,123 L493,120 L498,115 L501,109 L501,94 L497,87 L491,82 L486,80 Z" fill="#F60B2F"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
15
validation-suite/frontend/atlas/app/loading.tsx
Normal file
15
validation-suite/frontend/atlas/app/loading.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
<div className="rounded-lg border border-border bg-surface p-8 shadow-soft">
|
||||||
|
<BrandLogo />
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-3 text-text-light">
|
||||||
|
<span className="spinner" />
|
||||||
|
<span>Validation Suite wird geladen.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
validation-suite/frontend/atlas/app/not-found.tsx
Normal file
17
validation-suite/frontend/atlas/app/not-found.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||||
|
<section className="w-full max-w-lg rounded-lg border border-border bg-surface p-8 text-center shadow-soft">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<BrandLogo />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-8 text-3xl font-semibold">Seite nicht gefunden</h1>
|
||||||
|
<p className="mt-3 text-text-light">Die angeforderte Seite ist nicht vorhanden oder wurde verschoben.</p>
|
||||||
|
<Link href="/dashboard" className="btn btn-primary mt-6">Zum Dashboard</Link>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useAuth } from "@/components/auth";
|
import { useAuth } from "@/components/auth";
|
||||||
|
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
|
@ -34,8 +35,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
<div className="min-h-screen bg-background text-text">
|
<div className="min-h-screen bg-background text-text">
|
||||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface px-5 py-6 lg:block">
|
<aside className="fixed inset-y-0 left-0 z-30 hidden w-72 border-r border-border bg-surface px-5 py-6 lg:block">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="text-xl font-semibold text-primary-dark">Validation Suite</div>
|
<BrandLogo compact />
|
||||||
<div className="mt-1 text-sm text-text-light">Atlas Workspace</div>
|
|
||||||
</div>
|
</div>
|
||||||
<nav className="space-y-1">
|
<nav className="space-y-1">
|
||||||
{navigation.map((item, index) => {
|
{navigation.map((item, index) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export function BrandLogo({ compact = false }: { compact?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Image src="/schubamed-logo.svg" alt="SCHUBAMED Validation Suite" width={compact ? 180 : 240} height={64} priority />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,30 +13,6 @@ import { API_BASE, apiGet, apiSend, Contact, Customer, Device, Equipment, Locati
|
||||||
const triState = ["yes", "no", "na"] as const;
|
const triState = ["yes", "no", "na"] as const;
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const checklistTexts = [
|
|
||||||
"Gebrauchsanweisung und Herstellerdokumentation vorhanden",
|
|
||||||
"Wartungsnachweise vollstaendig",
|
|
||||||
"Kalibrierzertifikate der Pruefmittel gueltig",
|
|
||||||
"Aufstellbedingungen dokumentiert",
|
|
||||||
"Wasserqualitaet dokumentiert",
|
|
||||||
"Chargendokumentation nachvollziehbar",
|
|
||||||
"Routinekontrollen definiert",
|
|
||||||
"Freigabeverfahren beschrieben",
|
|
||||||
"Personal eingewiesen",
|
|
||||||
"Abweichungen bewertet"
|
|
||||||
];
|
|
||||||
|
|
||||||
const performanceTexts = [
|
|
||||||
"Vakuumtest entspricht Vorgaben",
|
|
||||||
"Bowie-Dick / Leerkammerprofil entspricht Vorgaben",
|
|
||||||
"Temperaturband innerhalb Spezifikation",
|
|
||||||
"Haltezeit erreicht",
|
|
||||||
"Druckverlauf plausibel",
|
|
||||||
"Trocknungsergebnis akzeptabel",
|
|
||||||
"Beladungsmuster reproduzierbar",
|
|
||||||
"Sensorpositionen dokumentiert"
|
|
||||||
];
|
|
||||||
|
|
||||||
const attachmentCategories = [
|
const attachmentCategories = [
|
||||||
"Aufbereitungsraum",
|
"Aufbereitungsraum",
|
||||||
"reiner Bereich",
|
"reiner Bereich",
|
||||||
|
|
@ -95,10 +71,7 @@ const schema = z.object({
|
||||||
type FormValues = z.infer<typeof schema>;
|
type FormValues = z.infer<typeof schema>;
|
||||||
type ReviewIssue = { field: string; message: string; section: string };
|
type ReviewIssue = { field: string; message: string; section: string };
|
||||||
type ReviewResult = { status: string; errors: ReviewIssue[]; warnings: ReviewIssue[]; complete_sections: string[] };
|
type ReviewResult = { status: string; errors: ReviewIssue[]; warnings: ReviewIssue[]; complete_sections: string[] };
|
||||||
|
type ChecklistTemplate = { checklist_key: string; title: string; items: Record<string, unknown>[] };
|
||||||
function checklist(items: string[]) {
|
|
||||||
return items.map((text, index) => ({ number: index + 1, text, value: "na", comment: "" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaults(reportNumber = ""): FormValues {
|
function defaults(reportNumber = ""): FormValues {
|
||||||
return {
|
return {
|
||||||
|
|
@ -131,8 +104,8 @@ function defaults(reportNumber = ""): FormValues {
|
||||||
{ text: "Medienversorgung verfuegbar", value: "na", comment: "" }
|
{ text: "Medienversorgung verfuegbar", value: "na", comment: "" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
documentation_checklist: checklist(checklistTexts),
|
documentation_checklist: [],
|
||||||
performance_checklist: checklist(performanceTexts),
|
performance_checklist: [],
|
||||||
programs: [
|
programs: [
|
||||||
{ name: "Vakuumtest", selected: false, custom: false },
|
{ name: "Vakuumtest", selected: false, custom: false },
|
||||||
{ name: "Bowie-Dick / Leerkammerprofil", selected: false, custom: false },
|
{ name: "Bowie-Dick / Leerkammerprofil", selected: false, custom: false },
|
||||||
|
|
@ -191,6 +164,7 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
|
||||||
const [saveState, setSaveState] = useState<ActionState>("normal");
|
const [saveState, setSaveState] = useState<ActionState>("normal");
|
||||||
const [reviewState, setReviewState] = useState<ActionState>("normal");
|
const [reviewState, setReviewState] = useState<ActionState>("normal");
|
||||||
const [pdfState, setPdfState] = useState<ActionState>("normal");
|
const [pdfState, setPdfState] = useState<ActionState>("normal");
|
||||||
|
const [uploadingCategory, setUploadingCategory] = useState<string | null>(null);
|
||||||
const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const nextNumber = useQuery({ queryKey: ["next-report-number", token], queryFn: () => apiGet<{ report_number: string }>("/validations/next-report-number", token ?? ""), enabled: Boolean(token) });
|
const nextNumber = useQuery({ queryKey: ["next-report-number", token], queryFn: () => apiGet<{ report_number: string }>("/validations/next-report-number", token ?? ""), enabled: Boolean(token) });
|
||||||
|
|
@ -199,6 +173,7 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
|
||||||
const contacts = useQuery({ queryKey: ["contacts-options", token], queryFn: () => apiGet<Paginated<Contact>>("/contacts?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
const contacts = useQuery({ queryKey: ["contacts-options", token], queryFn: () => apiGet<Paginated<Contact>>("/contacts?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
||||||
const devices = useQuery({ queryKey: ["devices-options", token], queryFn: () => apiGet<Paginated<Device>>("/devices?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
const devices = useQuery({ queryKey: ["devices-options", token], queryFn: () => apiGet<Paginated<Device>>("/devices?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
||||||
const equipment = useQuery({ queryKey: ["equipment-options", token], queryFn: () => apiGet<Paginated<Equipment>>("/equipment?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
const equipment = useQuery({ queryKey: ["equipment-options", token], queryFn: () => apiGet<Paginated<Equipment>>("/equipment?page=1&page_size=100", token ?? ""), enabled: Boolean(token) });
|
||||||
|
const checklistTemplates = useQuery({ queryKey: ["report-checklists", token], queryFn: () => apiGet<ChecklistTemplate[]>("/report-templates/default/checklists", token ?? ""), enabled: Boolean(token) });
|
||||||
const existingValidation = useQuery({ queryKey: ["validation", validationId, token], queryFn: () => apiGet<ValidationItem>(`/validations/${validationId}`, token ?? ""), enabled: Boolean(token && validationId) });
|
const existingValidation = useQuery({ queryKey: ["validation", validationId, token], queryFn: () => apiGet<ValidationItem>(`/validations/${validationId}`, token ?? ""), enabled: Boolean(token && validationId) });
|
||||||
|
|
||||||
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaults() });
|
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: defaults() });
|
||||||
|
|
@ -236,6 +211,15 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
|
||||||
setDraftId(data.id);
|
setDraftId(data.id);
|
||||||
}, [existingValidation.data, form]);
|
}, [existingValidation.data, form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!checklistTemplates.data || existingValidation.data) return;
|
||||||
|
if (form.getValues("documentation_checklist").length > 0) return;
|
||||||
|
const items = checklistTemplates.data.flatMap((template) =>
|
||||||
|
template.items.map((item) => ({ ...item, template_key: template.checklist_key, template_title: template.title }))
|
||||||
|
);
|
||||||
|
form.setValue("documentation_checklist", items);
|
||||||
|
}, [checklistTemplates.data, existingValidation.data, form]);
|
||||||
|
|
||||||
const readonly = existingValidation.data?.status === "FREIGEGEBEN" || existingValidation.data?.status === "ABGESCHLOSSEN";
|
const readonly = existingValidation.data?.status === "FREIGEGEBEN" || existingValidation.data?.status === "ABGESCHLOSSEN";
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
|
|
@ -327,9 +311,40 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFiles(files: FileList | null, category: string) {
|
async function addFiles(files: FileList | null, category: string) {
|
||||||
if (!files) return;
|
if (!files || files.length === 0) return;
|
||||||
Array.from(files).forEach((file, index) => attachments.append({ category, filename: file.name, description: "", order: attachments.fields.length + index + 1, preview: URL.createObjectURL(file) }));
|
if (!draftId || !token) {
|
||||||
|
setFormMessage("Bitte den Entwurf speichern, bevor Bilder oder Anlagen hochgeladen werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setUploadingCategory(category);
|
||||||
|
for (const [index, file] of Array.from(files).entries()) {
|
||||||
|
const order = attachments.fields.length + index + 1;
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("category", category);
|
||||||
|
body.append("description", "");
|
||||||
|
body.append("order", String(order));
|
||||||
|
body.append("file", file);
|
||||||
|
const response = await fetch(`${API_BASE}/validations/${draftId}/attachments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.json().catch(() => null);
|
||||||
|
throw new Error(errorBody?.detail ?? "Upload fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
const uploaded = await response.json();
|
||||||
|
attachments.append(uploaded);
|
||||||
|
}
|
||||||
|
client.invalidateQueries({ queryKey: ["validation", draftId, token] });
|
||||||
|
setFormMessage("Upload abgeschlossen.");
|
||||||
|
} catch (error) {
|
||||||
|
setFormMessage(error instanceof Error ? error.message : "Upload fehlgeschlagen.");
|
||||||
|
} finally {
|
||||||
|
setUploadingCategory(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openReportPreview() {
|
async function openReportPreview() {
|
||||||
|
|
@ -352,7 +367,10 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
|
||||||
const response = await fetch(`${API_BASE}/validations/${draftId}/report.pdf`, {
|
const response = await fetch(`${API_BASE}/validations/${draftId}/report.pdf`, {
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error("PDF konnte nicht erzeugt werden.");
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
throw new Error(body?.detail ?? "PDF konnte nicht erzeugt werden.");
|
||||||
|
}
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
|
|
@ -439,8 +457,8 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="6. Dokumentations- und Leistungschecklisten">
|
<Accordion title="6. Dokumentations- und Leistungschecklisten">
|
||||||
<h3 className="font-semibold">Dokumentation</h3>{checklistTexts.map((_, index) => <ChecklistRow key={index} form={form} path={`documentation_checklist.${index}`} />)}
|
{form.watch("documentation_checklist").map((item, index) => <ChecklistRow key={`${String(item.template_key ?? "check")}-${index}`} form={form} path={`documentation_checklist.${index}`} />)}
|
||||||
<h3 className="mt-6 font-semibold">Leistung</h3>{performanceTexts.map((_, index) => <ChecklistRow key={index} form={form} path={`performance_checklist.${index}`} />)}
|
{form.watch("performance_checklist").map((_, index) => <ChecklistRow key={`performance-${index}`} form={form} path={`performance_checklist.${index}`} />)}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="7. Programme">
|
<Accordion title="7. Programme">
|
||||||
|
|
@ -469,8 +487,12 @@ export function ValidationEditor({ validationId }: { validationId?: string }) {
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="12. Bilder und Anlagen">
|
<Accordion title="12. Bilder und Anlagen">
|
||||||
<div className="grid gap-3 md:grid-cols-2">{attachmentCategories.map((category) => <label key={category} className="rounded-lg border border-dashed border-primary/40 p-4"><UploadCloud className="mb-2 h-5 w-5 text-primary" />{category}<input type="file" multiple className="mt-3 block w-full text-sm" onChange={(event) => addFiles(event.target.files, category)} /></label>)}</div>
|
<div className="grid gap-3 md:grid-cols-2">{attachmentCategories.map((category) => <label key={category} className="rounded-lg border border-dashed border-primary/40 p-4 transition hover:border-primary hover:bg-background"><UploadCloud className="mb-2 h-5 w-5 text-primary" />{category}{uploadingCategory === category && <span className="ml-2 text-sm text-text-light">Upload...</span>}<input type="file" multiple className="mt-3 block w-full text-sm" onChange={(event) => addFiles(event.target.files, category)} /></label>)}</div>
|
||||||
<div className="mt-5 grid gap-3 md:grid-cols-2">{attachments.fields.map((field, index) => <div key={field.id} className="rounded-lg border border-border p-4"><div className="flex justify-between gap-3"><strong>{String(form.watch(`attachments.${index}.filename`) ?? "")}</strong><button type="button" onClick={() => attachments.remove(index)}><X className="h-4 w-4 text-danger" /></button></div><input className={`${inputClass} mt-3`} placeholder="Beschreibung" {...form.register(`attachments.${index}.description`)} /><input className={`${inputClass} mt-3`} placeholder="Reihenfolge" {...form.register(`attachments.${index}.order`)} /></div>)}</div>
|
<div className="mt-5 grid gap-3 md:grid-cols-2">{attachments.fields.map((field, index) => {
|
||||||
|
const fileUrl = String(form.watch(`attachments.${index}.url`) ?? "");
|
||||||
|
const imageUrl = fileUrl && /\.(png|jpe?g|webp|svg)$/i.test(fileUrl) ? `${API_BASE.replace("/api/v1", "")}${fileUrl}` : "";
|
||||||
|
return <div key={field.id} className="rounded-lg border border-border p-4">{imageUrl && <img src={imageUrl} alt={String(form.watch(`attachments.${index}.filename`) ?? "")} className="mb-3 max-h-56 w-full rounded-lg border border-border object-contain" />}<div className="flex justify-between gap-3"><strong>{String(form.watch(`attachments.${index}.filename`) ?? "")}</strong><button type="button" onClick={() => attachments.remove(index)}><X className="h-4 w-4 text-danger" /></button></div><input className={`${inputClass} mt-3`} placeholder="Beschreibung" {...form.register(`attachments.${index}.description`)} /><input className={`${inputClass} mt-3`} placeholder="Reihenfolge" {...form.register(`attachments.${index}.order`)} /></div>;
|
||||||
|
})}</div>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
{reviewResult && <section className="rounded-lg border border-border bg-surface p-5 shadow-soft"><h2 className="text-xl font-semibold">Pruefergebnis</h2><div className="mt-4 grid gap-4 md:grid-cols-3"><IssueList title="Fehler" items={reviewResult.errors} tone="danger" /><IssueList title="Warnungen" items={reviewResult.warnings} tone="warning" /><div><h3 className="font-semibold text-success">Vollstaendige Bereiche</h3>{reviewResult.complete_sections.map((item) => <p key={item} className="mt-2 text-sm">{item}</p>)}</div></div></section>}
|
{reviewResult && <section className="rounded-lg border border-border bg-surface p-5 shadow-soft"><h2 className="text-xl font-semibold">Pruefergebnis</h2><div className="mt-4 grid gap-4 md:grid-cols-3"><IssueList title="Fehler" items={reviewResult.errors} tone="danger" /><IssueList title="Warnungen" items={reviewResult.warnings} tone="warning" /><div><h3 className="font-semibold text-success">Vollstaendige Bereiche</h3>{reviewResult.complete_sections.map((item) => <p key={item} className="mt-2 text-sm">{item}</p>)}</div></div></section>}
|
||||||
|
|
|
||||||
|
|
@ -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 |
Loading…
Add table
Add a link
Reference in a new issue