from __future__ import annotations from collections import OrderedDict from datetime import date, datetime, timedelta, timezone from typing import Any from sqlalchemy import or_, select from sqlalchemy.orm import Session, selectinload from app.modules.clients.models import Client from app.modules.consultants.models import ClientConsultantLink, ConsultantProfile, ConsultantServiceRequest from app.modules.documents.models import EngagementDocument, PermanentClientDocument from app.modules.services.models import ( ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue, ServiceTaskComment, ServiceTaskDocumentRequest, ) CONSULTANT_BOARD_COLUMNS = OrderedDict( [ ("assigned", "Assigned"), ("awaiting_documents", "Awaiting Documents"), ("in_progress", "In Progress"), ("submitted", "Submitted"), ("accepted", "Accepted"), ("closed", "Closed"), ] ) def _allowed_client_ids(db: Session, *, consultant: ConsultantProfile, require_communications: bool = False) -> list[int]: query = select(ClientConsultantLink.client_id).where( ClientConsultantLink.tenant_id == consultant.tenant_id, ClientConsultantLink.consultant_id == consultant.id, ClientConsultantLink.is_active.is_(True), ) if require_communications: query = query.where(ClientConsultantLink.can_view_communications.is_(True)) return [int(x) for x in db.execute(query).scalars().all()] def _board_key_for_status(status: str | None) -> str: value = (status or "pending").strip().lower() if value in {"blocked", "awaiting_documents", "document_pending", "clarification_required"}: return "awaiting_documents" if value in {"in_progress", "under_process", "processing", "started"}: return "in_progress" if value in {"pending_review", "ready_for_review", "submitted", "completed"}: return "submitted" if value in {"approved", "accepted"}: return "accepted" if value in {"closed", "locked", "cancelled", "inactive"}: return "closed" return "assigned" def _matches_search(*values: Any, q: str = "") -> bool: term = (q or "").strip().lower() if not term: return True return any(term in str(v or "").lower() for v in values) TASK_PROGRESS_WEIGHTS = { "pending": 0, "assigned": 10, "accepted": 10, "blocked": 20, "awaiting_documents": 20, "document_pending": 20, "clarification_required": 25, "in_progress": 50, "under_process": 50, "processing": 50, "started": 50, "rework_required": 60, "pending_review": 80, "ready_for_review": 80, "submitted": 80, "completed": 100, "approved": 100, "accepted_final": 100, "closed": 100, "locked": 100, } def _task_progress(task: ClientServiceTaskInstance) -> int: status = (task.status or "pending").strip().lower() if status == "completed" and (task.manager_review_status or "").lower() in {"pending", "submitted"}: return 80 if status == "completed" and (task.partner_review_status or "").lower() in {"pending", "submitted"}: return 90 if (task.consultant_assignment_status or "").lower() == "rework_required": return 60 return TASK_PROGRESS_WEIGHTS.get(status, 0) def _friendly_pause_reason(code: str | None, notes: str | None) -> tuple[str, str]: value = (code or "").strip().lower() mappings = { "documents_required": ("Documents pending from client", "Client"), "clarification_needed": ("Clarification pending from client", "Client"), "client_asked_to_hold": ("Engagement on hold at client request", "Client"), "payment_pending": ("Payment or commercial confirmation pending", "Client"), "government_portal_issue": ("Government portal issue", "Government department"), "third_party_information": ("Third-party information awaited", "Third party"), "consultant_response_pending": ("Clarification pending from consultant", "Consultant"), "internal_review": ("Internal review in progress", "Firm"), "partner_review": ("Final review in progress", "Firm"), } text, pending_from = mappings.get(value, ("Engagement temporarily on hold", "Firm")) if notes and pending_from != "Firm": text = notes.strip() return text, pending_from def _engagement_progress_payload( db: Session, *, subscription: ClientServiceSubscription, client: Client, catalogue: ServiceCatalogue, link: ClientConsultantLink, tasks: list[ClientServiceTaskInstance], ) -> dict: applicable = [task for task in tasks if task.is_active] percentage = round(sum(_task_progress(task) for task in applicable) / len(applicable)) if applicable else 0 completed_count = sum(1 for task in applicable if _task_progress(task) >= 100) under_review = any(_task_progress(task) in {80, 90} for task in applicable) in_progress = any(0 < _task_progress(task) < 80 for task in applicable) blocker_text = "No blocker" pending_from = "No pending action" blocker_code = "none" if subscription.workflow_paused_at_utc and not subscription.workflow_resumed_at_utc: blocker_text, pending_from = _friendly_pause_reason(subscription.workflow_pause_reason, subscription.workflow_pause_notes) blocker_code = (subscription.workflow_pause_reason or "on_hold").lower() else: open_requests = db.execute( select(ServiceTaskDocumentRequest) .where( ServiceTaskDocumentRequest.tenant_id == subscription.tenant_id, ServiceTaskDocumentRequest.subscription_id == subscription.id, ServiceTaskDocumentRequest.is_active.is_(True), ServiceTaskDocumentRequest.status.in_(["pending", "clarification_required", "rejected"]), ) .order_by(ServiceTaskDocumentRequest.due_date.asc(), ServiceTaskDocumentRequest.created_at_utc.asc()) ).scalars().all() if open_requests: request = open_requests[0] recipient = (request.requested_from or "client_and_consultant").lower() pending_from = "Consultant" if recipient == "consultant" else "Client" if recipient == "client_and_consultant": pending_from = "Client / Consultant" blocker_code = "document_or_clarification_pending" blocker_text = request.title or "Documents or clarification awaited" elif subscription.quality_block_reason: blocker_code = "quality_clearance_pending" blocker_text = "Firm quality clearance in progress" pending_from = "Firm" elif any((task.consultant_assignment_status or "").lower() in {"offered", "rework_required"} for task in applicable): blocker_code = "consultant_action_pending" blocker_text = "Consultant action or rework pending" pending_from = "Consultant" elif any((task.rework_status or "").lower() == "requested" for task in applicable): blocker_code = "internal_rework" blocker_text = "Internal review and correction in progress" pending_from = "Firm" if percentage >= 100 or (subscription.status or "").lower() in {"completed", "closed", "locked"}: stage = "Completed" blocker_text = "No blocker" pending_from = "No pending action" blocker_code = "none" elif subscription.workflow_paused_at_utc and not subscription.workflow_resumed_at_utc: stage = "On Hold" elif blocker_code == "document_or_clarification_pending": stage = "Documents / Clarification Awaited" elif under_review: stage = "Under Review" elif percentage >= 90: stage = "Finalisation in Progress" elif in_progress or percentage > 0: stage = "Preparation in Progress" else: stage = "Not Started" due_date = subscription.current_due_date or subscription.original_due_date or subscription.expiry_date assigned_tasks = [ task for task in applicable if task.execution_mode == "consultant" and task.assigned_consultant_id == link.consultant_id ] last_update = max( [subscription.updated_at_utc] + [task.updated_at_utc for task in applicable if task.updated_at_utc], default=subscription.updated_at_utc, ) return { "subscription": subscription, "client": client, "catalogue": catalogue, "link": link, "progress_percentage": max(0, min(100, percentage)), "external_stage": stage, "blocker_code": blocker_code, "blocker_text": blocker_text, "pending_from": pending_from, "due_date": due_date, "is_overdue": bool(due_date and due_date < date.today() and percentage < 100), "total_tasks": len(applicable), "completed_tasks": completed_count, "assigned_tasks": assigned_tasks, "last_update_at": last_update, } def _effective_link(link: ClientConsultantLink, today: date) -> bool: return not (link.effective_from and link.effective_from > today) and not (link.effective_to and link.effective_to < today) def get_consultant_work_board(db: Session, *, consultant: ConsultantProfile, q: str = "", status: str = "") -> dict: today = date.today() links = db.execute( select(ClientConsultantLink).where( ClientConsultantLink.tenant_id == consultant.tenant_id, ClientConsultantLink.consultant_id == consultant.id, ClientConsultantLink.is_active.is_(True), ClientConsultantLink.can_view_engagements.is_(True), # Backward-compatible permission: this now controls engagement-level # progress visibility, not exposure of every internal task status. ClientConsultantLink.can_view_task_status.is_(True), ) ).scalars().all() links = [link for link in links if _effective_link(link, today)] links_by_client: dict[int, list[ClientConsultantLink]] = {} for link in links: links_by_client.setdefault(int(link.client_id), []).append(link) engagements: list[dict] = [] assigned_tasks: list[dict] = [] if links_by_client: subscriptions = db.execute( select(ClientServiceSubscription, Client, ServiceCatalogue) .join(Client, Client.id == ClientServiceSubscription.client_id) .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id) .where( ClientServiceSubscription.tenant_id == consultant.tenant_id, ClientServiceSubscription.client_id.in_(list(links_by_client)), ClientServiceSubscription.is_active.is_(True), ) .order_by(ClientServiceSubscription.current_due_date.asc(), ClientServiceSubscription.id.desc()) .limit(500) ).all() subscription_ids = [int(row[0].id) for row in subscriptions] tasks_by_subscription: dict[int, list[ClientServiceTaskInstance]] = {} if subscription_ids: all_tasks = db.execute( select(ClientServiceTaskInstance) .where( ClientServiceTaskInstance.tenant_id == consultant.tenant_id, ClientServiceTaskInstance.subscription_id.in_(subscription_ids), ClientServiceTaskInstance.is_active.is_(True), ) .order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc()) ).scalars().all() for task in all_tasks: tasks_by_subscription.setdefault(int(task.subscription_id), []).append(task) for subscription, client, catalogue in subscriptions: allowed = next( ( link for link in links_by_client[int(client.id)] if link.service_catalogue_id in (None, subscription.service_catalogue_id) ), None, ) if not allowed: continue item = _engagement_progress_payload( db, subscription=subscription, client=client, catalogue=catalogue, link=allowed, tasks=tasks_by_subscription.get(int(subscription.id), []), ) if not _matches_search( client.client_name, getattr(client, "client_code", ""), catalogue.service_name, subscription.financial_year, subscription.assessment_year, item["external_stage"], item["blocker_text"], q=q, ): continue if status and (item["external_stage"] or "").lower().replace(" / ", "_").replace(" ", "_") != status: continue engagements.append(item) for task in item["assigned_tasks"]: assigned_tasks.append({"task": task, "client": client, "catalogue": catalogue, "subscription": subscription, "is_overdue": bool(task.consultant_due_date and task.consultant_due_date < today)}) service_requests = db.execute( select(ConsultantServiceRequest) .options(selectinload(ConsultantServiceRequest.managed_client), selectinload(ConsultantServiceRequest.firm_client), selectinload(ConsultantServiceRequest.service_catalogue)) .where( ConsultantServiceRequest.tenant_id == consultant.tenant_id, ConsultantServiceRequest.consultant_id == consultant.id, ConsultantServiceRequest.is_active.is_(True), ) .order_by(ConsultantServiceRequest.created_at_utc.desc()) .limit(50) ).scalars().all() stage_options = sorted({(item["external_stage"].lower().replace(" / ", "_").replace(" ", "_"), item["external_stage"]) for item in engagements}) return { "engagements": engagements, "assigned_tasks": assigned_tasks, "stage_options": stage_options, "total_engagements": len(engagements), "average_progress": round(sum(item["progress_percentage"] for item in engagements) / len(engagements)) if engagements else 0, "blocked_engagements": sum(1 for item in engagements if item["blocker_code"] != "none"), "overdue_engagements": sum(1 for item in engagements if item["is_overdue"]), "service_requests": service_requests, } def get_consultant_engagement_progress(db: Session, *, consultant: ConsultantProfile, subscription_id: int) -> dict | None: row = db.execute( select(ClientServiceSubscription, Client, ServiceCatalogue) .join(Client, Client.id == ClientServiceSubscription.client_id) .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id) .where( ClientServiceSubscription.id == subscription_id, ClientServiceSubscription.tenant_id == consultant.tenant_id, ClientServiceSubscription.is_active.is_(True), ) ).first() if not row: return None subscription, client, catalogue = row link = db.execute( select(ClientConsultantLink).where( ClientConsultantLink.tenant_id == consultant.tenant_id, ClientConsultantLink.client_id == client.id, ClientConsultantLink.consultant_id == consultant.id, ClientConsultantLink.is_active.is_(True), ClientConsultantLink.can_view_engagements.is_(True), ClientConsultantLink.can_view_task_status.is_(True), or_(ClientConsultantLink.service_catalogue_id.is_(None), ClientConsultantLink.service_catalogue_id == subscription.service_catalogue_id), ) ).scalars().first() if not link or not _effective_link(link, date.today()): return None tasks = db.execute( select(ClientServiceTaskInstance) .where( ClientServiceTaskInstance.tenant_id == consultant.tenant_id, ClientServiceTaskInstance.subscription_id == subscription.id, ClientServiceTaskInstance.is_active.is_(True), ) .order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc()) ).scalars().all() item = _engagement_progress_payload(db, subscription=subscription, client=client, catalogue=catalogue, link=link, tasks=tasks) communications = db.execute( select(ServiceTaskComment) .join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id) .where( ServiceTaskComment.tenant_id == consultant.tenant_id, ClientServiceTaskInstance.subscription_id == subscription.id, ServiceTaskComment.visibility == "consultant", ServiceTaskComment.is_deleted.is_(False), ) .order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc()) .limit(25) ).scalars().all() pending_requirements = [] if link.can_view_document_requests: pending_requirements = db.execute( select(ServiceTaskDocumentRequest) .where( ServiceTaskDocumentRequest.tenant_id == consultant.tenant_id, ServiceTaskDocumentRequest.subscription_id == subscription.id, ServiceTaskDocumentRequest.is_active.is_(True), ServiceTaskDocumentRequest.status.in_(["pending", "clarification_required", "rejected"]), ) .order_by(ServiceTaskDocumentRequest.due_date.asc(), ServiceTaskDocumentRequest.created_at_utc.asc()) ).scalars().all() item.update({"communications": communications, "pending_requirements": pending_requirements}) return item def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfile, task_id: int) -> dict | None: row = db.execute( select(ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue) .join(ClientServiceSubscription, ClientServiceSubscription.id == ClientServiceTaskInstance.subscription_id) .join(Client, Client.id == ClientServiceTaskInstance.client_id) .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id) .where( ClientServiceTaskInstance.id == task_id, ClientServiceTaskInstance.tenant_id == consultant.tenant_id, ClientServiceTaskInstance.is_active.is_(True), ClientServiceTaskInstance.execution_mode == "consultant", ClientServiceTaskInstance.assigned_consultant_id == consultant.id, ) ).first() if not row: return None task, subscription, client, catalogue = row link = db.execute( select(ClientConsultantLink).where( ClientConsultantLink.tenant_id == consultant.tenant_id, ClientConsultantLink.client_id == client.id, ClientConsultantLink.consultant_id == consultant.id, ClientConsultantLink.is_active.is_(True), ClientConsultantLink.can_view_engagements.is_(True), or_(ClientConsultantLink.service_catalogue_id.is_(None), ClientConsultantLink.service_catalogue_id == task.service_catalogue_id), ) ).scalars().first() if not link or not _effective_link(link, date.today()): return None timeline = db.execute( select(ServiceTaskComment) .options(selectinload(ServiceTaskComment.created_by)) .where( ServiceTaskComment.tenant_id == consultant.tenant_id, ServiceTaskComment.task_instance_id == task.id, ServiceTaskComment.visibility == "consultant", ServiceTaskComment.is_deleted.is_(False), ) .order_by(ServiceTaskComment.created_at_utc.asc(), ServiceTaskComment.id.asc()) ).scalars().all() engagement_documents = db.execute( select(EngagementDocument) .options(selectinload(EngagementDocument.versions)) .where( EngagementDocument.tenant_id == consultant.tenant_id, EngagementDocument.client_id == client.id, EngagementDocument.engagement_id == subscription.id, EngagementDocument.is_deleted.is_(False), ) .order_by(EngagementDocument.document_type.asc(), EngagementDocument.title.asc()) .limit(50) ).scalars().all() if not (link.can_view_final_documents or link.can_upload_documents): engagement_documents = [] permanent_documents = db.execute( select(PermanentClientDocument) .options(selectinload(PermanentClientDocument.versions)) .where( PermanentClientDocument.tenant_id == consultant.tenant_id, PermanentClientDocument.client_id == client.id, PermanentClientDocument.is_deleted.is_(False), ) .order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc()) .limit(50) ).scalars().all() if not link.can_view_permanent_documents: permanent_documents = [] return { "link": link, "task": task, "subscription": subscription, "client": client, "catalogue": catalogue, "timeline": timeline, "engagement_documents": engagement_documents, "permanent_documents": permanent_documents, } def get_consultant_document_centre(db: Session, *, consultant: ConsultantProfile, q: str = "") -> dict: links=db.execute(select(ClientConsultantLink).where(ClientConsultantLink.tenant_id==consultant.tenant_id,ClientConsultantLink.consultant_id==consultant.id,ClientConsultantLink.is_active.is_(True))).scalars().all() engagement_ids={int(x.client_id) for x in links if x.can_view_final_documents or x.can_upload_documents} permanent_ids={int(x.client_id) for x in links if x.can_view_permanent_documents} engagement_documents=[]; permanent_documents=[] if engagement_ids: stmt=select(EngagementDocument).options(selectinload(EngagementDocument.client),selectinload(EngagementDocument.engagement),selectinload(EngagementDocument.versions)).where(EngagementDocument.tenant_id==consultant.tenant_id,EngagementDocument.client_id.in_(engagement_ids),EngagementDocument.is_deleted.is_(False)) if q.strip(): term=f"%{q.strip()}%"; stmt=stmt.where(or_(EngagementDocument.title.ilike(term),EngagementDocument.document_type.ilike(term),EngagementDocument.document_code.ilike(term))) engagement_documents=db.execute(stmt.order_by(EngagementDocument.created_at_utc.desc()).limit(200)).scalars().all() if permanent_ids: stmt=select(PermanentClientDocument).options(selectinload(PermanentClientDocument.client),selectinload(PermanentClientDocument.versions)).where(PermanentClientDocument.tenant_id==consultant.tenant_id,PermanentClientDocument.client_id.in_(permanent_ids),PermanentClientDocument.is_deleted.is_(False)) if q.strip(): term=f"%{q.strip()}%"; stmt=stmt.where(or_(PermanentClientDocument.title.ilike(term),PermanentClientDocument.category.ilike(term),PermanentClientDocument.document_code.ilike(term))) permanent_documents=db.execute(stmt.order_by(PermanentClientDocument.created_at_utc.desc()).limit(200)).scalars().all() return {"engagement_documents":engagement_documents,"permanent_documents":permanent_documents,"total":len(engagement_documents)+len(permanent_documents)} def consultant_can_execute_task(*, consultant: ConsultantProfile, task: ClientServiceTaskInstance) -> bool: return bool(task.execution_mode == "consultant" and task.assigned_consultant_id == consultant.id and task.is_active and not task.is_locked) def update_consultant_assignment(db: Session, *, consultant: ConsultantProfile, task: ClientServiceTaskInstance, action: str, note: str, user_id: int) -> tuple[bool, str | None]: if not consultant_can_execute_task(consultant=consultant, task=task): return False, "This task is not assigned to your consultant profile." action = (action or "").strip().lower() note = (note or "").strip() current = (task.consultant_assignment_status or "not_applicable").lower() now = datetime.now(timezone.utc) message = None if action == "accept" and current == "offered": task.consultant_assignment_status = "accepted" task.consultant_accepted_at_utc = now task.consultant_declined_at_utc = None task.consultant_decline_reason = None task.status = "pending" message = "Consultant accepted the assignment." elif action == "decline" and current == "offered": if not note: return False, "Decline reason is required." task.consultant_assignment_status = "declined" task.consultant_declined_at_utc = now task.consultant_decline_reason = note task.status = "pending" message = f"Consultant declined the assignment: {note}" elif action == "start" and current in {"accepted", "rework_required"}: task.consultant_assignment_status = "in_progress" task.consultant_started_at_utc = task.consultant_started_at_utc or now task.status = "in_progress" if current == "rework_required": task.rework_status = "in_progress" message = "Consultant started work on the assignment." elif action == "submit" and current in {"accepted", "in_progress", "rework_required"}: if not note: return False, "Submission note is required." task.consultant_assignment_status = "submitted" task.consultant_submitted_at_utc = now task.consultant_submission_note = note task.status = "pending_review" if task.rework_status in {"requested", "in_progress"}: task.rework_status = "resolved" task.rework_resolved_at_utc = now message = f"Consultant submitted the assignment for firm review: {note}" else: return False, "This action is not allowed for the current assignment status." task.updated_by_user_id = user_id db.add(ServiceTaskComment(tenant_id=task.tenant_id, branch_id=task.branch_id, subscription_id=task.subscription_id, task_instance_id=task.id, comment_type="consultant_execution", visibility="consultant", message=message, created_by_user_id=user_id)) return True, None