# mypy: disable-error-code="arg-type,assignment,union-attr,no-any-return,attr-defined,index,call-overload,type-arg,var-annotated,misc,call-arg,return-value" """ ISMS Findings & CAPA service -- Audit Findings and Corrective Actions. Phase 1 Step 4: extracted from ``compliance.api.isms_routes``. """ from datetime import datetime, date, timezone from typing import Optional from sqlalchemy.orm import Session from compliance.db.models import ( AuditFindingDB, CorrectiveActionDB, InternalAuditDB, FindingTypeEnum, FindingStatusEnum, CAPATypeEnum, ) from compliance.domain import NotFoundError, ConflictError, ValidationError from compliance.services.isms_governance_service import generate_id, log_audit_trail # ============================================================================ # Audit Findings # ============================================================================ class AuditFindingService: """Business logic for Audit Findings.""" @staticmethod def list_findings( db: Session, finding_type: Optional[str] = None, status: Optional[str] = None, internal_audit_id: Optional[str] = None, ) -> dict: query = db.query(AuditFindingDB) if finding_type: query = query.filter(AuditFindingDB.finding_type == finding_type) if status: query = query.filter(AuditFindingDB.status == status) if internal_audit_id: query = query.filter(AuditFindingDB.internal_audit_id == internal_audit_id) findings = query.order_by(AuditFindingDB.identified_date.desc()).all() major_count = sum(1 for f in findings if f.finding_type == FindingTypeEnum.MAJOR) minor_count = sum(1 for f in findings if f.finding_type == FindingTypeEnum.MINOR) ofi_count = sum(1 for f in findings if f.finding_type == FindingTypeEnum.OFI) open_count = sum(1 for f in findings if f.status != FindingStatusEnum.CLOSED) return { "findings": findings, "total": len(findings), "major_count": major_count, "minor_count": minor_count, "ofi_count": ofi_count, "open_count": open_count, } @staticmethod def create(db: Session, data: dict) -> AuditFindingDB: year = date.today().year existing_count = ( db.query(AuditFindingDB) .filter(AuditFindingDB.finding_id.like(f"FIND-{year}-%")) .count() ) finding_id = f"FIND-{year}-{existing_count + 1:03d}" internal_audit_id = data.pop("internal_audit_id", None) audit_session_id = data.pop("audit_session_id", None) finding_type_str = data.pop("finding_type") finding = AuditFindingDB( id=generate_id(), finding_id=finding_id, audit_session_id=audit_session_id, internal_audit_id=internal_audit_id, finding_type=FindingTypeEnum(finding_type_str), status=FindingStatusEnum.OPEN, **data, ) db.add(finding) # Update internal audit counts if linked if internal_audit_id: audit = ( db.query(InternalAuditDB) .filter(InternalAuditDB.id == internal_audit_id) .first() ) if audit: audit.total_findings = (audit.total_findings or 0) + 1 if finding_type_str == "major": audit.major_findings = (audit.major_findings or 0) + 1 elif finding_type_str == "minor": audit.minor_findings = (audit.minor_findings or 0) + 1 elif finding_type_str == "ofi": audit.ofi_count = (audit.ofi_count or 0) + 1 elif finding_type_str == "positive": audit.positive_observations = (audit.positive_observations or 0) + 1 log_audit_trail(db, "audit_finding", finding.id, finding_id, "create", data.get("auditor", "unknown")) db.commit() db.refresh(finding) return finding @staticmethod def update(db: Session, finding_id: str, data: dict, updated_by: str) -> AuditFindingDB: finding = ( db.query(AuditFindingDB) .filter((AuditFindingDB.id == finding_id) | (AuditFindingDB.finding_id == finding_id)) .first() ) if not finding: raise NotFoundError("Finding not found") for field, value in data.items(): if field == "status" and value: setattr(finding, field, FindingStatusEnum(value)) else: setattr(finding, field, value) log_audit_trail(db, "audit_finding", finding.id, finding.finding_id, "update", updated_by) db.commit() db.refresh(finding) return finding @staticmethod def close( db: Session, finding_id: str, closure_notes: str, closed_by: str, verification_method: str, verification_evidence: str, ) -> AuditFindingDB: finding = ( db.query(AuditFindingDB) .filter((AuditFindingDB.id == finding_id) | (AuditFindingDB.finding_id == finding_id)) .first() ) if not finding: raise NotFoundError("Finding not found") open_capas = ( db.query(CorrectiveActionDB) .filter( CorrectiveActionDB.finding_id == finding.id, CorrectiveActionDB.status != "verified", ) .count() ) if open_capas > 0: raise ValidationError(f"Cannot close finding: {open_capas} CAPA(s) not yet verified") finding.status = FindingStatusEnum.CLOSED finding.closed_date = date.today() finding.closure_notes = closure_notes finding.closed_by = closed_by finding.verification_method = verification_method finding.verification_evidence = verification_evidence finding.verified_by = closed_by finding.verified_at = datetime.now(timezone.utc) log_audit_trail(db, "audit_finding", finding.id, finding.finding_id, "close", closed_by) db.commit() db.refresh(finding) return finding # ============================================================================ # Corrective Actions (CAPA) # ============================================================================ class CAPAService: """Business logic for Corrective / Preventive Actions.""" @staticmethod def list_capas( db: Session, finding_id: Optional[str] = None, status: Optional[str] = None, assigned_to: Optional[str] = None, ) -> tuple: query = db.query(CorrectiveActionDB) if finding_id: query = query.filter(CorrectiveActionDB.finding_id == finding_id) if status: query = query.filter(CorrectiveActionDB.status == status) if assigned_to: query = query.filter(CorrectiveActionDB.assigned_to == assigned_to) actions = query.order_by(CorrectiveActionDB.planned_completion).all() return actions, len(actions) @staticmethod def create(db: Session, data: dict, created_by: str) -> CorrectiveActionDB: finding = db.query(AuditFindingDB).filter(AuditFindingDB.id == data["finding_id"]).first() if not finding: raise NotFoundError("Finding not found") year = date.today().year existing_count = ( db.query(CorrectiveActionDB) .filter(CorrectiveActionDB.capa_id.like(f"CAPA-{year}-%")) .count() ) capa_id = f"CAPA-{year}-{existing_count + 1:03d}" capa_type_str = data.pop("capa_type") capa = CorrectiveActionDB( id=generate_id(), capa_id=capa_id, capa_type=CAPATypeEnum(capa_type_str), status="planned", **data, ) db.add(capa) finding.status = FindingStatusEnum.CORRECTIVE_ACTION_PENDING log_audit_trail(db, "capa", capa.id, capa_id, "create", created_by) db.commit() db.refresh(capa) return capa @staticmethod def update(db: Session, capa_id: str, data: dict, updated_by: str) -> CorrectiveActionDB: capa = ( db.query(CorrectiveActionDB) .filter((CorrectiveActionDB.id == capa_id) | (CorrectiveActionDB.capa_id == capa_id)) .first() ) if not capa: raise NotFoundError("CAPA not found") for field, value in data.items(): setattr(capa, field, value) if capa.status == "completed" and not capa.actual_completion: capa.actual_completion = date.today() log_audit_trail(db, "capa", capa.id, capa.capa_id, "update", updated_by) db.commit() db.refresh(capa) return capa @staticmethod def verify( db: Session, capa_id: str, verified_by: str, is_effective: bool, effectiveness_notes: str, ) -> CorrectiveActionDB: capa = ( db.query(CorrectiveActionDB) .filter((CorrectiveActionDB.id == capa_id) | (CorrectiveActionDB.capa_id == capa_id)) .first() ) if not capa: raise NotFoundError("CAPA not found") if capa.status != "completed": raise ValidationError("CAPA must be completed before verification") capa.effectiveness_verified = is_effective capa.effectiveness_verification_date = date.today() capa.effectiveness_notes = effectiveness_notes capa.status = "verified" if is_effective else "completed" if is_effective: finding = db.query(AuditFindingDB).filter(AuditFindingDB.id == capa.finding_id).first() if finding: unverified = ( db.query(CorrectiveActionDB) .filter( CorrectiveActionDB.finding_id == finding.id, CorrectiveActionDB.id != capa.id, CorrectiveActionDB.status != "verified", ) .count() ) if unverified == 0: finding.status = FindingStatusEnum.VERIFICATION_PENDING log_audit_trail(db, "capa", capa.id, capa.capa_id, "verify", verified_by) db.commit() db.refresh(capa) return capa