from __future__ import annotations from collections import Counter from datetime import date from typing import Any from sqlalchemy import select, func from sqlalchemy.orm import Session, selectinload from app.modules.documents.models import EngagementDocument, PermanentClientDocument from app.modules.services.models import ( ClientServiceSubscription, ClientServiceTaskInstance, ServiceTaskComment, ) OPEN_TASK_STATUSES = {"pending", "in_progress", "blocked", "ready_for_review", "rework"} CLOSED_TASK_STATUSES = {"completed", "approved", "closed", "not_applicable"} def _client_id(client_row: dict[str, Any]) -> int: return int(client_row.get("id") or 0) def _tenant_id(client_row: dict[str, Any]) -> int: return int(client_row.get("tenant_id") or 0) def list_client_engagements(db: Session, client_row: dict[str, Any], *, limit: int = 200, financial_year: str | None = None) -> list[ClientServiceSubscription]: """Return engagements/subscriptions visible to the logged-in client.""" query = ( select(ClientServiceSubscription) .options( selectinload(ClientServiceSubscription.catalogue), selectinload(ClientServiceSubscription.assigned_partner), selectinload(ClientServiceSubscription.assigned_manager), selectinload(ClientServiceSubscription.assigned_staff), ) .where( ClientServiceSubscription.tenant_id == _tenant_id(client_row), ClientServiceSubscription.client_id == _client_id(client_row), ClientServiceSubscription.is_active.is_(True), ) ) if financial_year: query = query.where(ClientServiceSubscription.financial_year == financial_year.strip()) rows = db.execute( query.order_by( ClientServiceSubscription.current_due_date.asc().nulls_last(), ClientServiceSubscription.updated_at_utc.desc(), ) .limit(max(1, min(int(limit or 200), 500))) ).scalars().all() return rows def list_client_tasks_for_engagement(db: Session, client_row: dict[str, Any], engagement_id: int) -> list[ClientServiceTaskInstance]: return db.execute( select(ClientServiceTaskInstance) .options( selectinload(ClientServiceTaskInstance.catalogue), selectinload(ClientServiceTaskInstance.assigned_to), selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), ) .where( ClientServiceTaskInstance.tenant_id == _tenant_id(client_row), ClientServiceTaskInstance.client_id == _client_id(client_row), ClientServiceTaskInstance.subscription_id == int(engagement_id), ClientServiceTaskInstance.is_active.is_(True), ) .order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc()) ).scalars().all() def get_client_engagement(db: Session, client_row: dict[str, Any], engagement_id: int, *, financial_year: str | None = None) -> ClientServiceSubscription | None: query = ( select(ClientServiceSubscription) .options( selectinload(ClientServiceSubscription.catalogue), selectinload(ClientServiceSubscription.assigned_partner), selectinload(ClientServiceSubscription.assigned_manager), selectinload(ClientServiceSubscription.assigned_staff), ) .where( ClientServiceSubscription.id == int(engagement_id), ClientServiceSubscription.tenant_id == _tenant_id(client_row), ClientServiceSubscription.client_id == _client_id(client_row), ClientServiceSubscription.is_active.is_(True), ) ) if financial_year: query = query.where(ClientServiceSubscription.financial_year == financial_year.strip()) return db.execute(query).scalar_one_or_none() def get_client_task(db: Session, client_row: dict[str, Any], task_id: int, *, financial_year: str | None = None) -> ClientServiceTaskInstance | None: query = ( select(ClientServiceTaskInstance) .where( ClientServiceTaskInstance.id == int(task_id), ClientServiceTaskInstance.tenant_id == _tenant_id(client_row), ClientServiceTaskInstance.client_id == _client_id(client_row), ClientServiceTaskInstance.is_active.is_(True), ) ) if financial_year: query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) return db.execute(query).scalar_one_or_none() def list_client_visible_comments(db: Session, client_row: dict[str, Any], *, limit: int = 100, financial_year: str | None = None) -> list[ServiceTaskComment]: query = ( select(ServiceTaskComment) .options( selectinload(ServiceTaskComment.created_by), selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.catalogue), selectinload(ServiceTaskComment.subscription).selectinload(ClientServiceSubscription.catalogue), ) .join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id) .where( ServiceTaskComment.tenant_id == _tenant_id(client_row), ServiceTaskComment.visibility == "client", ServiceTaskComment.is_deleted.is_(False), ClientServiceTaskInstance.client_id == _client_id(client_row), ClientServiceTaskInstance.tenant_id == _tenant_id(client_row), ClientServiceTaskInstance.is_active.is_(True), ) ) if financial_year: query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) return db.execute( query.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc()) .limit(max(1, min(int(limit or 100), 300))) ).scalars().all() def create_client_reply(db: Session, *, client_row: dict[str, Any], task: ClientServiceTaskInstance, message: str, user) -> ServiceTaskComment: clean_message = (message or "").strip() if not clean_message: raise ValueError("Reply message is required.") if len(clean_message) > 4000: raise ValueError("Reply message is too long. Please keep it within 4000 characters.") comment = ServiceTaskComment( tenant_id=task.tenant_id, branch_id=task.branch_id, subscription_id=task.subscription_id, task_instance_id=task.id, comment_type="client_clarification", visibility="client", message=clean_message, created_by_user_id=getattr(user, "id", None), ) db.add(comment) db.flush() return comment def list_client_engagement_documents(db: Session, client_row: dict[str, Any], *, engagement_id: int | None = None, financial_year: str | None = None) -> list[EngagementDocument]: stmt = ( select(EngagementDocument) .options(selectinload(EngagementDocument.versions), selectinload(EngagementDocument.engagement).selectinload(ClientServiceSubscription.catalogue)) .where( EngagementDocument.tenant_id == _tenant_id(client_row), EngagementDocument.client_id == _client_id(client_row), EngagementDocument.is_deleted.is_(False), ) ) if engagement_id is not None: stmt = stmt.where(EngagementDocument.engagement_id == int(engagement_id)) if financial_year: stmt = stmt.where(EngagementDocument.financial_year == financial_year.strip()) return db.execute(stmt.order_by(EngagementDocument.updated_at_utc.desc())).unique().scalars().all() def list_client_permanent_documents(db: Session, client_row: dict[str, Any]) -> list[PermanentClientDocument]: return db.execute( select(PermanentClientDocument) .options(selectinload(PermanentClientDocument.versions)) .where( PermanentClientDocument.tenant_id == _tenant_id(client_row), PermanentClientDocument.client_id == _client_id(client_row), PermanentClientDocument.is_deleted.is_(False), ) .order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.updated_at_utc.desc()) ).unique().scalars().all() def build_client_portal_summary(db: Session, client_row: dict[str, Any], *, financial_year: str | None = None) -> dict[str, Any]: engagements = list_client_engagements(db, client_row, limit=500, financial_year=financial_year) engagement_ids = [row.id for row in engagements] today = date.today() task_rows: list[ClientServiceTaskInstance] = [] if engagement_ids: task_rows = db.execute( select(ClientServiceTaskInstance).where( ClientServiceTaskInstance.tenant_id == _tenant_id(client_row), ClientServiceTaskInstance.client_id == _client_id(client_row), ClientServiceTaskInstance.subscription_id.in_(engagement_ids), ClientServiceTaskInstance.is_active.is_(True), ) ).scalars().all() status_counter = Counter((task.status or "pending") for task in task_rows) open_tasks = [task for task in task_rows if (task.status or "pending") in OPEN_TASK_STATUSES] overdue_tasks = [ task for task in open_tasks if task.internal_target_date is not None and task.internal_target_date < today ] due_soon_engagements = [ row for row in engagements if row.current_due_date is not None and row.current_due_date >= today ][:10] pending_from_client = 0 with_firm = 0 completed = 0 clarification_required = 0 for row in engagements: tasks_for_eng = [t for t in task_rows if t.subscription_id == row.id] statuses = {(t.status or "pending") for t in tasks_for_eng} if statuses & {"blocked", "client_pending", "clarification_required"}: clarification_required += 1 elif tasks_for_eng and all((t.status or "pending") in CLOSED_TASK_STATUSES for t in tasks_for_eng): completed += 1 elif statuses & {"pending"}: pending_from_client += 1 else: with_firm += 1 return { "engagements": engagements, "task_rows": task_rows, "status_counter": status_counter, "total_engagements": len(engagements), "open_tasks": len(open_tasks), "overdue_tasks": len(overdue_tasks), "completed_tasks": status_counter.get("completed", 0) + status_counter.get("approved", 0) + status_counter.get("closed", 0), "due_soon_engagements": due_soon_engagements, "pending_from_client": pending_from_client, "with_firm": with_firm, "clarification_required": clarification_required, "completed_engagements": completed, }