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

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

View file

@ -2,8 +2,13 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Query, Response
import json
from pathlib import Path
from fastapi import APIRouter, Depends, Query, Response, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.dependencies import current_user
@ -14,6 +19,7 @@ from app.models.device import Device
from app.models.equipment import Equipment
from app.models.location import Location
from app.models.validation import Validation
from app.modules.orion.service import OrionReportService
from app.schemas.common import PaginatedResponse
from app.schemas.domain import (
ContactCreate,
@ -32,16 +38,22 @@ from app.schemas.domain import (
LocationRead,
LocationUpdate,
ValidationCreate,
ValidationImportPreview,
ValidationImportRequest,
ValidationImportSummary,
ValidationRead,
ValidationReview,
ValidationUpdate,
)
from app.services.domain_service import CrudService, DomainServices
from app.services.validation_workflow import ValidationWorkflowService
router = APIRouter(tags=["domain"], dependencies=[Depends(current_user)])
@router.get("/dashboard")
def dashboard(session: Session = Depends(get_session)) -> dict[str, int]:
today = func.current_date()
return {
"customers": session.scalar(select(func.count()).select_from(Customer)) or 0,
"locations": session.scalar(select(func.count()).select_from(Location)) or 0,
@ -49,6 +61,11 @@ def dashboard(session: Session = Depends(get_session)) -> dict[str, int]:
"devices": session.scalar(select(func.count()).select_from(Device)) or 0,
"equipment": session.scalar(select(func.count()).select_from(Equipment)) or 0,
"validations": session.scalar(select(func.count()).select_from(Validation)) or 0,
"validation_drafts": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "ENTWURF")) or 0,
"validation_ready": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "BEREIT_ZUR_PRUEFUNG")) or 0,
"validation_in_review": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "IN_PRUEFUNG")) or 0,
"validation_approved": session.scalar(select(func.count()).select_from(Validation).where(Validation.status == "FREIGEGEBEN")) or 0,
"validation_overdue": session.scalar(select(func.count()).select_from(Validation).where(Validation.next_validation_on < today)) or 0,
}
@ -61,17 +78,33 @@ def paging(
def commit_create(session: Session, service: CrudService, payload):
item = service.create(payload.model_dump())
session.commit()
session.refresh(item)
return item
try:
item = service.create(payload.model_dump())
if isinstance(item, Validation):
ValidationWorkflowService(session).apply_revalidation_date(item)
session.commit()
session.refresh(item)
return item
except IntegrityError as exc:
session.rollback()
from fastapi import HTTPException
raise HTTPException(status_code=409, detail="Datensatz verletzt Datenbankbeziehungen.") from exc
def commit_update(session: Session, service: CrudService, item_id: str, payload):
item = service.update(item_id, payload.model_dump())
session.commit()
session.refresh(item)
return item
try:
item = service.update(item_id, payload.model_dump())
if isinstance(item, Validation):
ValidationWorkflowService(session).apply_revalidation_date(item)
session.commit()
session.refresh(item)
return item
except IntegrityError as exc:
session.rollback()
from fastapi import HTTPException
raise HTTPException(status_code=409, detail="Datensatz verletzt Datenbankbeziehungen.") from exc
def commit_delete(session: Session, service: CrudService, item_id: str) -> Response:
@ -181,8 +214,39 @@ def delete_equipment(item_id: str, session: Session = Depends(get_session)):
@router.get("/validations", response_model=PaginatedResponse[ValidationRead])
def list_validations(params: dict = Depends(paging), session: Session = Depends(get_session)):
return DomainServices(session).validations.list(**params)
def list_validations(
search: str | None = Query(default=None, max_length=120),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
sort_by: str = Query(default="updated_at"),
sort_order: str = Query(default="desc", pattern="^(asc|desc)$"),
status: str | None = None,
customer_id: str | None = None,
device_id: str | None = None,
validation_type: str | None = None,
result: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
overdue_only: bool = False,
session: Session = Depends(get_session),
):
return ValidationWorkflowService(session).query_validations(
search=search,
page=page,
page_size=page_size,
sort_by=sort_by,
sort_order=sort_order,
filters={
"status": status,
"customer_id": customer_id,
"device_id": device_id,
"validation_type": validation_type,
"result": result,
"date_from": date_from,
"date_to": date_to,
"overdue_only": overdue_only,
},
)
@router.get("/validations/next-report-number")
@ -213,4 +277,101 @@ def update_validation(item_id: str, payload: ValidationUpdate, session: Session
@router.delete("/validations/{item_id}", status_code=204)
def delete_validation(item_id: str, session: Session = Depends(get_session)):
item = DomainServices(session).validations.repository.get(item_id)
if item and item.status != "ENTWURF":
from fastapi import HTTPException
raise HTTPException(status_code=409, detail="Only draft validations can be deleted")
return commit_delete(session, DomainServices(session).validations, item_id)
@router.post("/validations/{item_id}/review", response_model=ValidationReview)
def review_validation(item_id: str, 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")
review = ValidationWorkflowService(session).mark_ready_for_review(item)
session.commit()
return review
@router.post("/validations/{item_id}/duplicate", response_model=ValidationRead, status_code=201)
def duplicate_validation(item_id: str, 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")
clone = ValidationWorkflowService(session).duplicate(item)
session.commit()
session.refresh(clone)
return clone
@router.post("/validations/{item_id}/new-version", response_model=ValidationRead, status_code=201)
def new_validation_version(item_id: str, 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")
clone = ValidationWorkflowService(session).create_new_version(item)
session.commit()
session.refresh(clone)
return clone
@router.post("/validations/{item_id}/cancel", response_model=ValidationRead)
def cancel_validation(item_id: str, 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")
item.status = "STORNIERT"
session.commit()
session.refresh(item)
return item
@router.get("/validations/{item_id}/export.json")
def export_validation_json(item_id: str, session: Session = Depends(get_session)):
item = DomainServices(session).validations.repository.get(item_id)
if item is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Resource not found")
data = ValidationWorkflowService(session).export_json(item)
return JSONResponse(
content=json.loads(json.dumps(data, default=str)),
headers={"Content-Disposition": f'attachment; filename="{item.report_number}.json"'},
)
@router.post("/validations/import/csv-preview", response_model=ValidationImportPreview)
async def preview_validation_csv(file: UploadFile, session: Session = Depends(get_session)):
return ValidationWorkflowService(session).preview_csv(await file.read())
@router.post("/validations/import/json", response_model=ValidationImportSummary)
def import_validation_json(payload: ValidationImportRequest, session: Session = Depends(get_session)):
summary = ValidationWorkflowService(session).import_rows(payload.rows, payload.duplicate_strategy)
session.commit()
return summary
@router.get("/validations/{item_id}/report.html", response_class=HTMLResponse)
def validation_report_preview(item_id: str, session: Session = Depends(get_session)):
return OrionReportService(session, Path("/app/reports")).render_html(item_id)
@router.get("/validations/{item_id}/report.pdf")
def validation_report_pdf(item_id: str, session: Session = Depends(get_session)):
report_path = OrionReportService(session, Path("/app/reports")).render_pdf(item_id)
return FileResponse(
report_path,
media_type="application/pdf",
filename=report_path.name,
)