344 lines
16 KiB
Python
344 lines
16 KiB
Python
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()
|