from __future__ import annotations import json import re from dataclasses import dataclass from fastapi import Request from sqlalchemy import select from sqlalchemy.orm import Session from app.modules.core.audit.models import AuditLog from app.modules.core.tenancy.year_control import is_financial_year_locked, is_row_financial_year_locked from app.modules.documents.models import EngagementDocument from app.modules.services.client_services import assessment_year_from_financial_year, period_choices_for_service from app.modules.services.due_dates import apply_due_date_rule_to_subscription from app.modules.services.models import ( ClientServiceSubscription, ClientServiceTaskInstance, EngagementClosureChecklist, ) _FINANCIAL_YEAR_RE = re.compile(r"^(\d{4})-(\d{2})$") _CLOSED_ENGAGEMENT_STATUSES = {"completed", "cancelled", "inactive"} @dataclass(frozen=True) class EngagementFYCorrectionResult: subscription_id: int old_financial_year: str new_financial_year: str old_period_label: str new_period_label: str task_count: int document_count: int def validate_financial_year(value: str | None) -> str: raw = (value or "").strip() match = _FINANCIAL_YEAR_RE.fullmatch(raw) if not match: raise ValueError("Financial year must be in YYYY-YY format, for example 2025-26.") start_year = int(match.group(1)) expected_suffix = str(start_year + 1)[-2:] if match.group(2) != expected_suffix: raise ValueError("Financial year end year does not match the start year.") return f"{start_year:04d}-{expected_suffix}" def _remap_period_label(row: ClientServiceSubscription, target_financial_year: str) -> str: recurrence = (getattr(getattr(row, "catalogue", None), "recurrence_type", None) or "one_time").strip().lower() current = (getattr(row, "period_label", None) or "").strip() choices = period_choices_for_service(target_financial_year, recurrence) valid_codes = [code for code, _label in choices] if recurrence == "monthly": # Period codes are YYYY-MM. Keep the same month while moving it to the # corresponding month inside the corrected financial year. try: month = int(current.rsplit("-", 1)[1]) except (ValueError, IndexError): raise ValueError("The existing monthly period is invalid and cannot be remapped automatically.") target_start = int(target_financial_year.split("-", 1)[0]) target_year = target_start if month >= 4 else target_start + 1 candidate = f"{target_year:04d}-{month:02d}" if candidate not in valid_codes: raise ValueError("The monthly period cannot be mapped to the corrected financial year.") return candidate if recurrence == "quarterly": if current not in valid_codes: raise ValueError("The existing quarter is invalid for the corrected financial year.") return current return "" def _engagement_has_finalised_documents(db: Session, subscription_id: int) -> bool: rows = db.execute( select(EngagementDocument).where( EngagementDocument.engagement_id == subscription_id, EngagementDocument.is_deleted.is_(False), ) ).scalars().all() for document in rows: if (getattr(document, "final_release_status", None) or "draft").strip().lower() == "released": return True if (getattr(document, "udin_number", None) or "").strip(): return True return False def validate_engagement_can_change_financial_year( db: Session, *, row: ClientServiceSubscription, target_financial_year: str, ) -> None: if getattr(row, "is_locked", False): raise ValueError("Locked engagements cannot be moved to another financial year.") if is_row_financial_year_locked(db, row): raise ValueError(f"Source FY {row.financial_year} is locked and cannot be changed.") if is_financial_year_locked(db, tenant_id=row.tenant_id, year_code=target_financial_year): raise ValueError(f"Target FY {target_financial_year} is locked and cannot receive an engagement.") if not getattr(row, "is_active", True): raise ValueError("Inactive engagements cannot be moved to another financial year.") status = (getattr(row, "status", None) or "active").strip().lower() if status in _CLOSED_ENGAGEMENT_STATUSES: raise ValueError("Only open engagements can have their financial year corrected.") closure = db.execute( select(EngagementClosureChecklist).where( EngagementClosureChecklist.subscription_id == row.id ) ).scalar_one_or_none() if closure and (closure.closure_status or "").strip().lower() == "approved": raise ValueError("The engagement closure is already approved. Reopen it before correcting the financial year.") if _engagement_has_finalised_documents(db, row.id): raise ValueError("A released final document or UDIN exists. Reopen/correct the finalisation workflow before changing FY.") def _duplicate_engagement( db: Session, *, row: ClientServiceSubscription, target_financial_year: str, target_period_label: str, ) -> ClientServiceSubscription | None: return db.execute( select(ClientServiceSubscription).where( ClientServiceSubscription.tenant_id == row.tenant_id, ClientServiceSubscription.service_catalogue_id == row.service_catalogue_id, ClientServiceSubscription.scope_key == row.scope_key, ClientServiceSubscription.financial_year == target_financial_year, ClientServiceSubscription.period_label == target_period_label, ClientServiceSubscription.id != row.id, ) ).scalar_one_or_none() def _add_audit_log( db: Session, *, request: Request | None, row: ClientServiceSubscription, actor, old_financial_year: str, new_financial_year: str, old_period_label: str, new_period_label: str, reason: str, task_count: int, document_count: int, ) -> None: ip_address = request.client.host if request and request.client else None user_agent = request.headers.get("user-agent") if request else None client_name = getattr(getattr(row, "client", None), "client_name", None) or f"Client {row.client_id}" service_name = getattr(getattr(row, "catalogue", None), "service_name", None) or f"Service {row.service_catalogue_id}" db.add( AuditLog( actor_user_id=getattr(actor, "id", None), actor_email=getattr(actor, "email", None), actor_tenant_id=getattr(actor, "tenant_id", None), actor_branch_id=getattr(actor, "branch_id", None), action="engagement.financial_year_corrected", entity_type="ClientServiceSubscription", entity_id=str(row.id), entity_name=f"{client_name} - {service_name}", status="success", target_tenant_id=row.tenant_id, target_branch_id=row.branch_id, ip_address=ip_address, user_agent=user_agent, details_json=json.dumps( { "old_financial_year": old_financial_year, "new_financial_year": new_financial_year, "old_assessment_year": assessment_year_from_financial_year(old_financial_year), "new_assessment_year": assessment_year_from_financial_year(new_financial_year), "old_period_label": old_period_label, "new_period_label": new_period_label, "reason": reason, "task_instances_updated": task_count, "engagement_documents_updated": document_count, "physical_document_paths_moved": False, }, ensure_ascii=False, sort_keys=True, ), ) ) def correct_engagement_financial_year( db: Session, *, row: ClientServiceSubscription, target_financial_year: str, actor, reason: str, request: Request | None = None, ) -> EngagementFYCorrectionResult: target_fy = validate_financial_year(target_financial_year) reason_text = (reason or "").strip() if not reason_text: raise ValueError("A correction reason is required for the audit trail.") old_fy = validate_financial_year(row.financial_year) if target_fy == old_fy: raise ValueError("The corrected financial year is the same as the existing financial year.") validate_engagement_can_change_financial_year(db, row=row, target_financial_year=target_fy) old_period = (row.period_label or "").strip() new_period = _remap_period_label(row, target_fy) duplicate = _duplicate_engagement( db, row=row, target_financial_year=target_fy, target_period_label=new_period, ) if duplicate: raise ValueError( f"A matching engagement already exists in FY {target_fy}" + (f" for period {new_period}" if new_period else "") + "." ) # Pre-flight the task unique key before mutating anything. A partially # inconsistent legacy subscription must be repaired manually rather than # losing execution/review history. target_task = db.execute( select(ClientServiceTaskInstance.id).where( ClientServiceTaskInstance.subscription_id == row.id, ClientServiceTaskInstance.financial_year == target_fy, ).limit(1) ).scalar_one_or_none() if target_task is not None: raise ValueError( f"This engagement already has a task instance tagged to FY {target_fy}. " "The mixed-year task data must be reviewed before FY correction." ) new_ay = assessment_year_from_financial_year(target_fy) tasks = db.execute( select(ClientServiceTaskInstance).where( ClientServiceTaskInstance.subscription_id == row.id ) ).scalars().all() documents = db.execute( select(EngagementDocument).where( EngagementDocument.engagement_id == row.id, EngagementDocument.is_deleted.is_(False), ) ).scalars().all() row.financial_year = target_fy row.assessment_year = new_ay row.period_label = new_period row.updated_by_user_id = getattr(actor, "id", None) for task in tasks: task.financial_year = target_fy task.assessment_year = new_ay task.period_label = new_period task.updated_by_user_id = getattr(actor, "id", None) # Document metadata follows the corrected engagement. Existing physical # revision paths are deliberately preserved so no already-uploaded evidence # is orphaned; future uploads use the corrected engagement FY. for document in documents: document.financial_year = target_fy document.assessment_year = new_ay document.updated_by_user_id = getattr(actor, "id", None) apply_due_date_rule_to_subscription(db, row, force=True) _add_audit_log( db, request=request, row=row, actor=actor, old_financial_year=old_fy, new_financial_year=target_fy, old_period_label=old_period, new_period_label=new_period, reason=reason_text, task_count=len(tasks), document_count=len(documents), ) db.flush() return EngagementFYCorrectionResult( subscription_id=row.id, old_financial_year=old_fy, new_financial_year=target_fy, old_period_label=old_period, new_period_label=new_period, task_count=len(tasks), document_count=len(documents), )