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()