feat(validation): add editing versioning revalidation and aligned reports
This commit is contained in:
parent
f73a24df13
commit
302e542fda
28 changed files with 2691 additions and 406 deletions
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -41,16 +42,33 @@ class CrudService:
|
|||
}
|
||||
|
||||
def create(self, data: dict) -> ModelT:
|
||||
data = self._normalize(data)
|
||||
return self.repository.add(self.repository.model(**data))
|
||||
|
||||
def update(self, item_id: str, data: dict) -> ModelT:
|
||||
data = self._normalize(data)
|
||||
item = self.repository.get(item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found")
|
||||
if isinstance(item, Validation) and item.status in {"FREIGEGEBEN", "ABGESCHLOSSEN"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Freigegebene oder abgeschlossene Validierungen sind schreibgeschuetzt")
|
||||
for key, value in data.items():
|
||||
setattr(item, key, value)
|
||||
return item
|
||||
|
||||
def _normalize(self, data: dict) -> dict:
|
||||
normalized = {}
|
||||
for key, value in data.items():
|
||||
if key.endswith("_id") and value == "":
|
||||
normalized[key] = None
|
||||
elif isinstance(value, UUID):
|
||||
normalized[key] = str(value)
|
||||
elif isinstance(value, list):
|
||||
normalized[key] = [str(item) if isinstance(item, UUID) else item for item in value]
|
||||
else:
|
||||
normalized[key] = value
|
||||
return normalized
|
||||
|
||||
def delete(self, item_id: str) -> None:
|
||||
item = self.repository.get(item_id)
|
||||
if item is None:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,344 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.contact import Contact
|
||||
from app.models.customer import Customer
|
||||
from app.models.device import Device
|
||||
from app.models.equipment import Equipment
|
||||
from app.models.location import Location
|
||||
from app.models.validation import Validation, ValidationStatus
|
||||
|
||||
REQUIRED_FIELDS = {
|
||||
"report_number": ("Allgemeine Angaben", "Berichtsnummer"),
|
||||
"validation_type": ("Allgemeine Angaben", "Validierungsart"),
|
||||
"performed_on": ("Allgemeine Angaben", "Pruefdatum"),
|
||||
"customer_id": ("Kunde und Standort", "Kunde"),
|
||||
"location_id": ("Kunde und Standort", "Standort"),
|
||||
"device_id": ("Geraet", "Geraet"),
|
||||
"examiner_name": ("Allgemeine Angaben", "Pruefer"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewIssue:
|
||||
field: str
|
||||
message: str
|
||||
section: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"field": self.field, "message": self.message, "section": self.section}
|
||||
|
||||
|
||||
class ValidationWorkflowService:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def review(self, validation: Validation) -> dict:
|
||||
errors = self._required_errors(validation) + self._reference_errors(validation)
|
||||
warnings = self._warnings(validation)
|
||||
complete_sections = self._complete_sections(validation, errors, warnings)
|
||||
return {
|
||||
"status": validation.status,
|
||||
"errors": [issue.as_dict() for issue in errors],
|
||||
"warnings": [issue.as_dict() for issue in warnings],
|
||||
"complete_sections": complete_sections,
|
||||
}
|
||||
|
||||
def mark_ready_for_review(self, validation: Validation) -> dict:
|
||||
review = self.review(validation)
|
||||
validation.status = (
|
||||
ValidationStatus.ready_for_review.value
|
||||
if not review["errors"]
|
||||
else ValidationStatus.draft.value
|
||||
)
|
||||
self.session.flush()
|
||||
review["status"] = validation.status
|
||||
return review
|
||||
|
||||
def apply_revalidation_date(self, validation: Validation) -> None:
|
||||
if validation.performed_on and not validation.next_validation_manually_overridden:
|
||||
validation.next_validation_on = validation.performed_on + relativedelta(
|
||||
months=validation.revalidation_interval_months or 24
|
||||
)
|
||||
|
||||
def duplicate(self, validation: Validation) -> Validation:
|
||||
clone = Validation(
|
||||
report_number=f"{validation.report_number}-KOPIE-{str(uuid4())[:8]}",
|
||||
customer_id=validation.customer_id,
|
||||
location_id=validation.location_id,
|
||||
contact_id=validation.contact_id,
|
||||
device_id=validation.device_id,
|
||||
validation_type=validation.validation_type,
|
||||
project=validation.project,
|
||||
test_location=validation.test_location,
|
||||
examiner_name=validation.examiner_name,
|
||||
participants=validation.participants,
|
||||
operator_name=validation.operator_name,
|
||||
scheduled_on=validation.scheduled_on,
|
||||
performed_on=validation.performed_on,
|
||||
next_validation_on=validation.next_validation_on,
|
||||
revalidation_interval_months=validation.revalidation_interval_months,
|
||||
next_validation_manually_overridden=validation.next_validation_manually_overridden,
|
||||
version=validation.version + 1,
|
||||
previous_validation_id=validation.id,
|
||||
examiner_id=validation.examiner_id,
|
||||
status=ValidationStatus.draft.value,
|
||||
result=validation.result,
|
||||
notes=validation.notes,
|
||||
equipment_ids=validation.equipment_ids,
|
||||
environment_conditions=validation.environment_conditions,
|
||||
documentation_checklist=validation.documentation_checklist,
|
||||
performance_checklist=validation.performance_checklist,
|
||||
programs=validation.programs,
|
||||
loading_patterns=validation.loading_patterns,
|
||||
measurement_data=validation.measurement_data,
|
||||
drying=validation.drying,
|
||||
recommendations=validation.recommendations,
|
||||
attachments=validation.attachments,
|
||||
)
|
||||
self.session.add(clone)
|
||||
self.session.flush()
|
||||
return clone
|
||||
|
||||
def create_new_version(self, validation: Validation) -> Validation:
|
||||
clone = self.duplicate(validation)
|
||||
clone.report_number = f"{validation.report_number}-V{clone.version}"
|
||||
return clone
|
||||
|
||||
def export_json(self, validation: Validation) -> dict:
|
||||
return {
|
||||
column.name: getattr(validation, column.name)
|
||||
for column in Validation.__table__.columns
|
||||
if column.name not in {"created_at", "updated_at"}
|
||||
}
|
||||
|
||||
def preview_csv(self, content: bytes) -> dict:
|
||||
rows = []
|
||||
reader = csv.DictReader(io.StringIO(content.decode("utf-8-sig")))
|
||||
for index, row in enumerate(reader, start=2):
|
||||
rows.append(self._preview_import_row(index, row))
|
||||
return {
|
||||
"rows": rows,
|
||||
"valid_rows": sum(1 for row in rows if not row["errors"]),
|
||||
"invalid_rows": sum(1 for row in rows if row["errors"]),
|
||||
"duplicates": sum(1 for row in rows if row["duplicate"]),
|
||||
}
|
||||
|
||||
def import_rows(self, rows: list[dict], duplicate_strategy: str) -> dict:
|
||||
summary = {"successful": 0, "skipped": 0, "failed": 0, "errors": []}
|
||||
for index, row in enumerate(rows, start=1):
|
||||
preview = self._preview_import_row(index, row)
|
||||
if preview["errors"]:
|
||||
summary["failed"] += 1
|
||||
summary["errors"].append(f"Zeile {index}: {', '.join(preview['errors'])}")
|
||||
continue
|
||||
existing = self.session.scalar(
|
||||
select(Validation).where(Validation.report_number == row.get("report_number"))
|
||||
)
|
||||
if existing and duplicate_strategy == "skip":
|
||||
summary["skipped"] += 1
|
||||
continue
|
||||
target = existing if existing and duplicate_strategy == "update" else Validation()
|
||||
target.report_number = (
|
||||
f"{row.get('report_number')}-IMPORT-{str(uuid4())[:8]}"
|
||||
if existing and duplicate_strategy == "copy"
|
||||
else row.get("report_number")
|
||||
)
|
||||
target.validation_type = row.get("validation_type")
|
||||
target.performed_on = self._parse_date(row.get("test_date") or row.get("performed_on"))
|
||||
target.customer_id = preview["resolved_customer_id"]
|
||||
target.location_id = preview["resolved_location_id"]
|
||||
target.contact_id = row.get("contact_id")
|
||||
target.device_id = preview["resolved_device_id"]
|
||||
target.examiner_name = row.get("examiner") or row.get("examiner_name")
|
||||
target.project = row.get("project")
|
||||
target.test_location = row.get("test_location")
|
||||
target.participants = row.get("participants")
|
||||
target.operator_name = row.get("operator_name")
|
||||
target.next_validation_on = self._parse_date(row.get("next_validation_on"))
|
||||
target.equipment_ids = row.get("equipment_ids") or []
|
||||
target.environment_conditions = row.get("environment_conditions") or {}
|
||||
target.documentation_checklist = row.get("documentation_checklist") or []
|
||||
target.performance_checklist = row.get("performance_checklist") or []
|
||||
target.programs = row.get("programs") or []
|
||||
target.loading_patterns = row.get("loading_patterns") or []
|
||||
target.measurement_data = row.get("measurement_data") or []
|
||||
target.drying = row.get("drying") or {}
|
||||
target.recommendations = row.get("recommendations") or []
|
||||
target.attachments = row.get("attachments") or []
|
||||
target.status = row.get("status") or ValidationStatus.draft.value
|
||||
target.result = row.get("result")
|
||||
target.notes = row.get("notes")
|
||||
if target.id is None:
|
||||
self.session.add(target)
|
||||
summary["successful"] += 1
|
||||
self.session.flush()
|
||||
return summary
|
||||
|
||||
def query_validations(
|
||||
self,
|
||||
search: str | None,
|
||||
page: int,
|
||||
page_size: int,
|
||||
sort_by: str,
|
||||
sort_order: str,
|
||||
filters: dict,
|
||||
) -> dict:
|
||||
statement = select(Validation)
|
||||
count_statement = select(Validation)
|
||||
conditions = []
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
Validation.report_number.ilike(term),
|
||||
Validation.validation_type.ilike(term),
|
||||
Validation.examiner_name.ilike(term),
|
||||
Validation.result.ilike(term),
|
||||
)
|
||||
)
|
||||
for key in ["status", "customer_id", "device_id", "validation_type", "result"]:
|
||||
if filters.get(key):
|
||||
conditions.append(getattr(Validation, key) == filters[key])
|
||||
if filters.get("date_from"):
|
||||
conditions.append(Validation.performed_on >= filters["date_from"])
|
||||
if filters.get("date_to"):
|
||||
conditions.append(Validation.performed_on <= filters["date_to"])
|
||||
if filters.get("overdue_only"):
|
||||
conditions.append(Validation.next_validation_on < date.today())
|
||||
conditions.append(Validation.status != ValidationStatus.cancelled.value)
|
||||
if conditions:
|
||||
statement = statement.where(and_(*conditions))
|
||||
count_statement = count_statement.where(and_(*conditions))
|
||||
sort_column = getattr(Validation, sort_by, Validation.updated_at)
|
||||
if sort_order == "asc":
|
||||
statement = statement.order_by(sort_column.asc())
|
||||
else:
|
||||
statement = statement.order_by(sort_column.desc())
|
||||
total = len(list(self.session.scalars(count_statement)))
|
||||
items = list(self.session.scalars(statement.offset((page - 1) * page_size).limit(page_size)))
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
def revalidation_status(self, validation: Validation) -> str:
|
||||
if not validation.next_validation_on:
|
||||
return "nicht_erfasst"
|
||||
delta = (validation.next_validation_on - date.today()).days
|
||||
if delta < 0:
|
||||
return "ueberfaellig"
|
||||
if delta <= 30:
|
||||
return "faellig_30"
|
||||
if delta <= 90:
|
||||
return "faellig_90"
|
||||
return "faellig_spaeter"
|
||||
|
||||
def _required_errors(self, validation: Validation) -> list[ReviewIssue]:
|
||||
issues = []
|
||||
for field, (section, label) in REQUIRED_FIELDS.items():
|
||||
if not getattr(validation, field):
|
||||
issues.append(ReviewIssue(field, f"{label} fehlt.", section))
|
||||
return issues
|
||||
|
||||
def _reference_errors(self, validation: Validation) -> list[ReviewIssue]:
|
||||
issues = []
|
||||
customer = self.session.get(Customer, validation.customer_id) if validation.customer_id else None
|
||||
location = self.session.get(Location, validation.location_id) if validation.location_id else None
|
||||
device = self.session.get(Device, validation.device_id) if validation.device_id else None
|
||||
if validation.customer_id and customer is None:
|
||||
issues.append(ReviewIssue("customer_id", "Kunde existiert nicht.", "Kunde und Standort"))
|
||||
if validation.location_id and location is None:
|
||||
issues.append(ReviewIssue("location_id", "Standort existiert nicht.", "Kunde und Standort"))
|
||||
if validation.device_id and device is None:
|
||||
issues.append(ReviewIssue("device_id", "Geraet existiert nicht.", "Geraet"))
|
||||
if location and device and device.location_id != location.id:
|
||||
issues.append(ReviewIssue("device_id", "Geraet gehoert nicht zum gewaehlten Standort.", "Geraet"))
|
||||
return issues
|
||||
|
||||
def _warnings(self, validation: Validation) -> list[ReviewIssue]:
|
||||
warnings = []
|
||||
equipment = []
|
||||
if validation.equipment_ids:
|
||||
equipment = list(self.session.scalars(select(Equipment).where(Equipment.id.in_(validation.equipment_ids))))
|
||||
if not validation.equipment_ids:
|
||||
warnings.append(ReviewIssue("equipment_ids", "Keine Pruefmittel ausgewaehlt.", "Pruefmittel"))
|
||||
for item in equipment:
|
||||
if item.calibration_due_on and item.calibration_due_on < date.today():
|
||||
warnings.append(ReviewIssue("equipment_ids", f"Pruefmittel {item.serial_number} ist abgelaufen.", "Pruefmittel"))
|
||||
if not any(item.get("selected") for item in validation.programs or []):
|
||||
warnings.append(ReviewIssue("programs", "Keine Programme ausgewaehlt.", "Programme"))
|
||||
if not validation.measurement_data:
|
||||
warnings.append(ReviewIssue("measurement_data", "Keine Messdaten vorhanden.", "Messdaten"))
|
||||
if not validation.attachments:
|
||||
warnings.append(ReviewIssue("attachments", "Keine Bilder oder Anlagen vorhanden.", "Bilder und Anlagen"))
|
||||
if not validation.recommendations:
|
||||
warnings.append(ReviewIssue("recommendations", "Keine Empfehlungen oder Auflagen erfasst.", "Empfehlungen"))
|
||||
winlog_imported = any(item.get("imports") for item in validation.measurement_data or [])
|
||||
if not winlog_imported:
|
||||
warnings.append(ReviewIssue("measurement_data", "Winlog-Datei noch nicht importiert.", "Messdaten"))
|
||||
return warnings
|
||||
|
||||
def _complete_sections(
|
||||
self, validation: Validation, errors: list[ReviewIssue], warnings: list[ReviewIssue]
|
||||
) -> list[str]:
|
||||
blocked = {issue.section for issue in [*errors, *warnings]}
|
||||
sections = [
|
||||
"Allgemeine Angaben",
|
||||
"Kunde und Standort",
|
||||
"Geraet",
|
||||
"Pruefmittel",
|
||||
"Programme",
|
||||
"Messdaten",
|
||||
"Bilder und Anlagen",
|
||||
"Empfehlungen",
|
||||
]
|
||||
return [section for section in sections if section not in blocked]
|
||||
|
||||
def _preview_import_row(self, row_number: int, row: dict) -> dict:
|
||||
errors = []
|
||||
customer = self.session.get(Customer, row.get("customer_id")) if row.get("customer_id") else self.session.scalar(select(Customer).where(Customer.name == row.get("customer_reference")))
|
||||
location = self.session.get(Location, row.get("location_id")) if row.get("location_id") else self.session.scalar(select(Location).where(Location.name == row.get("location_reference")))
|
||||
device = self.session.get(Device, row.get("device_id")) if row.get("device_id") else self.session.scalar(select(Device).where(Device.serial_number == row.get("device_serial_number")))
|
||||
duplicate = bool(
|
||||
row.get("report_number")
|
||||
and self.session.scalar(select(Validation).where(Validation.report_number == row.get("report_number")))
|
||||
)
|
||||
required = [
|
||||
("report_number", row.get("report_number")),
|
||||
("validation_type", row.get("validation_type")),
|
||||
("test_date", row.get("test_date") or row.get("performed_on")),
|
||||
("customer_reference", row.get("customer_reference") or row.get("customer_id")),
|
||||
("location_reference", row.get("location_reference") or row.get("location_id")),
|
||||
("device_serial_number", row.get("device_serial_number") or row.get("device_id")),
|
||||
("examiner", row.get("examiner") or row.get("examiner_name")),
|
||||
]
|
||||
for field, value in required:
|
||||
if not value:
|
||||
errors.append(f"{field} fehlt")
|
||||
if row.get("customer_reference") and not customer:
|
||||
errors.append("Kunde nicht gefunden")
|
||||
if row.get("location_reference") and not location:
|
||||
errors.append("Standort nicht gefunden")
|
||||
if row.get("device_serial_number") and not device:
|
||||
errors.append("Geraet nicht gefunden")
|
||||
return {
|
||||
"row_number": row_number,
|
||||
"data": row,
|
||||
"errors": errors,
|
||||
"duplicate": duplicate,
|
||||
"resolved_customer_id": customer.id if customer else None,
|
||||
"resolved_location_id": location.id if location else None,
|
||||
"resolved_device_id": device.id if device else None,
|
||||
}
|
||||
|
||||
def _parse_date(self, value: str | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||
Loading…
Add table
Add a link
Reference in a new issue