from __future__ import annotations from datetime import date, datetime, timezone from sqlalchemy import func, or_, select from sqlalchemy.orm import Session, selectinload from app.modules.clients.models import Client from app.modules.core.iam.models import User from app.modules.services.client_services import quality_required_for_engagement, QUALITY_APPROVED from app.modules.services.models import ( ClientServiceSubscription, ClientServiceTaskInstance, ServiceTaskComment, FirmServiceTaskTemplate, ServiceCatalogue, ) TASK_STATUSES = [ ("pending", "Pending"), ("in_progress", "In Progress"), ("completed", "Completed"), ("blocked", "Blocked"), ("not_applicable", "Not Applicable"), ("cancelled", "Cancelled"), ] OPEN_TASK_STATUSES = {"pending", "in_progress", "blocked"} CLOSED_TASK_STATUSES = {"completed", "not_applicable", "cancelled"} TASK_COMMENT_TYPES = [ ("internal_note", "Internal Note"), ("client_clarification", "Client Clarification"), ("consultant_clarification", "Consultant Clarification"), ("partner_review_note", "Partner Review Note"), ] TASK_COMMENT_VISIBILITIES = [ ("internal", "Internal"), ("client", "Client"), ("consultant", "Consultant"), ] TASK_PRIORITIES = [ ("low", "Low"), ("normal", "Normal"), ("high", "High"), ("urgent", "Urgent"), ] def _normalise_status(status: str | None) -> str: allowed = {code for code, _label in TASK_STATUSES} value = (status or "pending").strip().lower() return value if value in allowed else "pending" def _normalise_priority(priority: str | None) -> str: allowed = {code for code, _label in TASK_PRIORITIES} value = (priority or "normal").strip().lower() return value if value in allowed else "normal" def _normalise_comment_type(comment_type: str | None) -> str: allowed = {code for code, _label in TASK_COMMENT_TYPES} value = (comment_type or "internal_note").strip().lower() return value if value in allowed else "internal_note" def _normalise_visibility(visibility: str | None) -> str: allowed = {code for code, _label in TASK_COMMENT_VISIBILITIES} value = (visibility or "internal").strip().lower() return value if value in allowed else "internal" def parse_date_value(value: str | None) -> date | None: text = (value or "").strip() if not text: return None return date.fromisoformat(text) def _default_assignee_for_template(subscription: ClientServiceSubscription, template: FirmServiceTaskTemplate) -> int | None: role = (template.default_role_name or "").strip().lower() if "partner" in role: return subscription.assigned_partner_user_id if "manager" in role: return subscription.assigned_manager_user_id if "staff" in role or "employee" in role: return subscription.assigned_staff_user_id return subscription.assigned_staff_user_id or subscription.assigned_manager_user_id or subscription.assigned_partner_user_id def get_subscription_for_execution(db: Session, *, tenant_id: int, subscription_id: int) -> ClientServiceSubscription | None: return db.execute( select(ClientServiceSubscription) .options( selectinload(ClientServiceSubscription.client), selectinload(ClientServiceSubscription.catalogue), selectinload(ClientServiceSubscription.assigned_partner), selectinload(ClientServiceSubscription.assigned_manager), selectinload(ClientServiceSubscription.assigned_staff), ) .where( ClientServiceSubscription.id == subscription_id, ClientServiceSubscription.tenant_id == tenant_id, ) ).scalar_one_or_none() def list_subscription_execution_payload(db: Session, *, tenant_id: int, branch_id: int | None = None, financial_year: str | None = None, q: str = ""): query = ( select(ClientServiceSubscription) .options( selectinload(ClientServiceSubscription.client), selectinload(ClientServiceSubscription.catalogue), selectinload(ClientServiceSubscription.assigned_partner), selectinload(ClientServiceSubscription.assigned_manager), selectinload(ClientServiceSubscription.assigned_staff), ) .where( ClientServiceSubscription.tenant_id == tenant_id, ClientServiceSubscription.is_active.is_(True), ClientServiceSubscription.status == "active", ClientServiceSubscription.is_locked.is_(False), ) ) if financial_year: query = query.where(ClientServiceSubscription.financial_year == financial_year.strip()) if branch_id: query = query.where(ClientServiceSubscription.branch_id == branch_id) if q.strip(): term = f"%{q.strip()}%" query = ( query.join(Client, Client.id == ClientServiceSubscription.client_id) .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id) .where( or_( Client.client_name.ilike(term), Client.client_code.ilike(term), ServiceCatalogue.service_code.ilike(term), ServiceCatalogue.service_name.ilike(term), ) ) ) rows = db.execute(query.order_by(ClientServiceSubscription.id.desc())).scalars().all() payload = [] for sub in rows: total = db.execute( select(func.count(ClientServiceTaskInstance.id)).where( ClientServiceTaskInstance.subscription_id == sub.id, ClientServiceTaskInstance.is_active.is_(True), ) ).scalar_one() completed = db.execute( select(func.count(ClientServiceTaskInstance.id)).where( ClientServiceTaskInstance.subscription_id == sub.id, ClientServiceTaskInstance.is_active.is_(True), ClientServiceTaskInstance.status == "completed", ) ).scalar_one() open_tasks = db.execute( select(func.count(ClientServiceTaskInstance.id)).where( ClientServiceTaskInstance.subscription_id == sub.id, ClientServiceTaskInstance.is_active.is_(True), ClientServiceTaskInstance.status.in_(list(OPEN_TASK_STATUSES)), ) ).scalar_one() payload.append({"subscription": sub, "total_tasks": total, "completed_tasks": completed, "open_tasks": open_tasks}) return payload def _default_internal_target_date(subscription: ClientServiceSubscription, template: FirmServiceTaskTemplate) -> date | None: # Phase 4B keeps task target dates internal. Existing task templates do not yet have # an offset field, so new generated tasks start blank and can be assigned through the tracker. return None def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceSubscription, user_id: int) -> int: if quality_required_for_engagement(subscription.engagement_type) and getattr(subscription, "quality_acceptance_status", None) != QUALITY_APPROVED: raise ValueError(getattr(subscription, "quality_block_reason", None) or "AQMM acceptance is pending for this assurance engagement. Complete AQMM before generating tasks.") templates = db.execute( select(FirmServiceTaskTemplate) .where( FirmServiceTaskTemplate.tenant_id == subscription.tenant_id, FirmServiceTaskTemplate.service_catalogue_id == subscription.service_catalogue_id, FirmServiceTaskTemplate.is_active.is_(True), ) .order_by(FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc()) ).scalars().all() created = 0 for template in templates: existing = db.execute( select(ClientServiceTaskInstance.id).where( ClientServiceTaskInstance.subscription_id == subscription.id, ClientServiceTaskInstance.firm_task_template_id == template.id, ClientServiceTaskInstance.financial_year == subscription.financial_year, ) ).first() if existing: continue db.add( ClientServiceTaskInstance( tenant_id=subscription.tenant_id, branch_id=subscription.branch_id, subscription_id=subscription.id, client_id=subscription.client_id, service_catalogue_id=subscription.service_catalogue_id, firm_task_template_id=template.id, financial_year=subscription.financial_year, assessment_year=subscription.assessment_year, task_name=template.task_name, description=template.description, sequence_no=template.sequence_no, default_role_name=template.default_role_name, assigned_to_user_id=_default_assignee_for_template(subscription, template), internal_target_date=_default_internal_target_date(subscription, template), status="pending", priority="normal", is_active=True, created_by_user_id=user_id, updated_by_user_id=user_id, ) ) created += 1 return created def _decorate_task_for_tracker(task: ClientServiceTaskInstance, *, today: date) -> ClientServiceTaskInstance: target_date = getattr(task, "internal_target_date", None) subscription = getattr(task, "subscription", None) engagement_due_date = getattr(subscription, "current_due_date", None) if subscription else None task.is_task_overdue = bool(target_date and target_date < today and task.status not in CLOSED_TASK_STATUSES) task.is_due_today = bool(target_date and target_date == today and task.status not in CLOSED_TASK_STATUSES) task.is_engagement_due_overdue = bool( engagement_due_date and engagement_due_date < today and task.status not in CLOSED_TASK_STATUSES ) task.tracker_status_label = dict(TASK_STATUSES).get(task.status, task.status) task.priority_label = dict(TASK_PRIORITIES).get(task.priority, task.priority) return task def _apply_partner_visibility_filter(query, partner_user_id: int | None): if not partner_user_id: return query return query.where( or_( ClientServiceTaskInstance.subscription.has( ClientServiceSubscription.assigned_partner_user_id == partner_user_id ), ClientServiceTaskInstance.client.has(Client.partner_id == partner_user_id), ) ) def list_tasks_payload( db: Session, *, tenant_id: int, branch_id: int | None = None, assigned_to_user_id: int | None = None, partner_user_id: int | None = None, status: str = "", q: str = "", include_inactive: bool = False, financial_year: str | None = None, ): today = date.today() special_filter = (status or "").strip().lower() query = ( select(ClientServiceTaskInstance) .options( selectinload(ClientServiceTaskInstance.client), selectinload(ClientServiceTaskInstance.catalogue), selectinload(ClientServiceTaskInstance.assigned_to), selectinload(ClientServiceTaskInstance.subscription), ) .where(ClientServiceTaskInstance.tenant_id == tenant_id) ) if financial_year: query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) if branch_id: query = query.where(ClientServiceTaskInstance.branch_id == branch_id) if assigned_to_user_id: query = query.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id) query = _apply_partner_visibility_filter(query, partner_user_id) if special_filter and special_filter not in {"overdue", "due_today", "unassigned"}: query = query.where(ClientServiceTaskInstance.status == special_filter) if special_filter == "overdue": query = query.where( ClientServiceTaskInstance.internal_target_date.is_not(None), ClientServiceTaskInstance.internal_target_date < today, ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)), ) elif special_filter == "due_today": query = query.where( ClientServiceTaskInstance.internal_target_date == today, ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)), ) elif special_filter == "unassigned": query = query.where(ClientServiceTaskInstance.assigned_to_user_id.is_(None)) if not include_inactive: query = query.where(ClientServiceTaskInstance.is_active.is_(True)) if q.strip(): term = f"%{q.strip()}%" query = ( query.join(Client, Client.id == ClientServiceTaskInstance.client_id) .join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id) .where( or_( ClientServiceTaskInstance.task_name.ilike(term), Client.client_name.ilike(term), Client.client_code.ilike(term), ServiceCatalogue.service_code.ilike(term), ServiceCatalogue.service_name.ilike(term), ) ) ) rows = db.execute( query.order_by( ClientServiceTaskInstance.internal_target_date.is_(None), ClientServiceTaskInstance.internal_target_date.asc(), ClientServiceTaskInstance.status.asc(), ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.desc(), ) ).scalars().all() return [_decorate_task_for_tracker(task, today=today) for task in rows] def get_task( db: Session, *, tenant_id: int, task_id: int, branch_id: int | None = None, assigned_to_user_id: int | None = None, partner_user_id: int | None = None, financial_year: str | None = None, ) -> ClientServiceTaskInstance | None: query = ( select(ClientServiceTaskInstance) .options( selectinload(ClientServiceTaskInstance.client), selectinload(ClientServiceTaskInstance.catalogue), selectinload(ClientServiceTaskInstance.assigned_to), selectinload(ClientServiceTaskInstance.subscription), ) .where( ClientServiceTaskInstance.id == task_id, ClientServiceTaskInstance.tenant_id == tenant_id, ) ) if branch_id: query = query.where(ClientServiceTaskInstance.branch_id == branch_id) if financial_year: query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) if assigned_to_user_id: query = query.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id) query = _apply_partner_visibility_filter(query, partner_user_id) task = db.execute(query).scalar_one_or_none() return _decorate_task_for_tracker(task, today=date.today()) if task else None def list_assignees_for_execution(db: Session, *, tenant_id: int, branch_id: int | None = None): query = select(User).where(User.tenant_id == tenant_id, User.is_active.is_(True)) if branch_id: query = query.where((User.branch_id == branch_id) | (User.branch_id.is_(None))) return db.execute(query.order_by(User.full_name.asc(), User.email.asc())).scalars().all() def apply_task_update( task: ClientServiceTaskInstance, *, status: str, priority: str, assigned_to_user_id: int | None, internal_target_date: date | None, remarks: str, is_active: bool, user_id: int, ): if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False): return previous_status = task.status task.status = _normalise_status(status) task.priority = _normalise_priority(priority) task.assigned_to_user_id = assigned_to_user_id task.internal_target_date = internal_target_date task.remarks = remarks.strip() or None task.is_active = is_active task.updated_by_user_id = user_id now = datetime.now(timezone.utc) if previous_status != "in_progress" and task.status == "in_progress" and not task.started_at_utc: task.started_at_utc = now if task.status == "completed" and not task.completed_at_utc: task.completed_at_utc = now if task.status != "completed": task.completed_at_utc = None def apply_bulk_task_update( tasks: list[ClientServiceTaskInstance], *, status: str | None, assigned_to_user_id: int | None, update_assignee: bool, internal_target_date: date | None, update_internal_target_date: bool, user_id: int, ) -> tuple[int, int]: updated = 0 skipped = 0 for task in tasks: if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False): skipped += 1 continue previous_status = task.status if status: task.status = _normalise_status(status) if update_assignee: task.assigned_to_user_id = assigned_to_user_id if update_internal_target_date: task.internal_target_date = internal_target_date task.updated_by_user_id = user_id now = datetime.now(timezone.utc) if previous_status != "in_progress" and task.status == "in_progress" and not task.started_at_utc: task.started_at_utc = now if task.status == "completed" and not task.completed_at_utc: task.completed_at_utc = now if task.status != "completed": task.completed_at_utc = None updated += 1 return updated, skipped def get_tasks_for_bulk_update( db: Session, *, tenant_id: int, task_ids: list[int], branch_id: int | None = None, assigned_to_user_id: int | None = None, partner_user_id: int | None = None, financial_year: str | None = None, ) -> list[ClientServiceTaskInstance]: if not task_ids: return [] query = ( select(ClientServiceTaskInstance) .options(selectinload(ClientServiceTaskInstance.subscription)) .where( ClientServiceTaskInstance.tenant_id == tenant_id, ClientServiceTaskInstance.id.in_(task_ids), ClientServiceTaskInstance.is_active.is_(True), ) ) if branch_id: query = query.where(ClientServiceTaskInstance.branch_id == branch_id) if financial_year: query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) if assigned_to_user_id: query = query.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id) query = _apply_partner_visibility_filter(query, partner_user_id) return db.execute(query).scalars().all() def list_task_comments(db: Session, *, tenant_id: int, task_id: int) -> list[ServiceTaskComment]: return db.execute( select(ServiceTaskComment) .options(selectinload(ServiceTaskComment.created_by)) .where( ServiceTaskComment.tenant_id == tenant_id, ServiceTaskComment.task_instance_id == task_id, ServiceTaskComment.is_deleted.is_(False), ) .order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc()) ).scalars().all() def add_task_comment( db: Session, *, task: ClientServiceTaskInstance, comment_type: str, visibility: str, message: str, user_id: int, ) -> ServiceTaskComment | None: if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False): return None clean_message = (message or "").strip() if not clean_message: return None row = ServiceTaskComment( tenant_id=task.tenant_id, branch_id=task.branch_id, subscription_id=task.subscription_id, task_instance_id=task.id, comment_type=_normalise_comment_type(comment_type), visibility=_normalise_visibility(visibility), message=clean_message, created_by_user_id=user_id, ) db.add(row) task.updated_by_user_id = user_id return row def list_client_visible_task_comments( db: Session, *, tenant_id: int, client_id: int, limit: int = 20, ) -> list[ServiceTaskComment]: """Return client-visible task communication for one client dashboard. This is intentionally read-only and scoped by tenant + client. Internal and consultant-only notes are never returned to the client portal. """ safe_limit = max(1, min(int(limit or 20), 100)) rows = db.execute( select(ServiceTaskComment) .options( selectinload(ServiceTaskComment.created_by), selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.catalogue), selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.subscription), ) .join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id) .where( ServiceTaskComment.tenant_id == tenant_id, ServiceTaskComment.visibility == "client", ServiceTaskComment.is_deleted.is_(False), ClientServiceTaskInstance.client_id == client_id, ClientServiceTaskInstance.tenant_id == tenant_id, ClientServiceTaskInstance.is_active.is_(True), ) .order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc()) .limit(safe_limit) ).scalars().all() type_labels = dict(TASK_COMMENT_TYPES) visibility_labels = dict(TASK_COMMENT_VISIBILITIES) for row in rows: row.comment_type_label = type_labels.get(row.comment_type, row.comment_type) row.visibility_label = visibility_labels.get(row.visibility, row.visibility) return rows def dashboard_stats( db: Session, *, tenant_id: int, branch_id: int | None = None, assigned_to_user_id: int | None = None, partner_user_id: int | None = None, financial_year: str | None = None, ): today = date.today() base = select(ClientServiceTaskInstance).where( ClientServiceTaskInstance.tenant_id == tenant_id, ClientServiceTaskInstance.is_active.is_(True), ) if branch_id: base = base.where(ClientServiceTaskInstance.branch_id == branch_id) if financial_year: base = base.where(ClientServiceTaskInstance.financial_year == financial_year.strip()) if assigned_to_user_id: base = base.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id) base = _apply_partner_visibility_filter(base, partner_user_id) subq = base.subquery() total = db.execute(select(func.count()).select_from(subq)).scalar_one() pending = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "pending")).scalar_one() progress = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "in_progress")).scalar_one() blocked = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "blocked")).scalar_one() completed = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "completed")).scalar_one() not_applicable = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "not_applicable")).scalar_one() unassigned = db.execute(select(func.count()).select_from(subq).where(subq.c.assigned_to_user_id.is_(None))).scalar_one() overdue = db.execute( select(func.count()).select_from(subq).where( subq.c.internal_target_date.is_not(None), subq.c.internal_target_date < today, subq.c.status.notin_(list(CLOSED_TASK_STATUSES)), ) ).scalar_one() due_today = db.execute( select(func.count()).select_from(subq).where( subq.c.internal_target_date == today, subq.c.status.notin_(list(CLOSED_TASK_STATUSES)), ) ).scalar_one() return { "total": total, "pending": pending, "in_progress": progress, "blocked": blocked, "completed": completed, "not_applicable": not_applicable, "unassigned": unassigned, "overdue": overdue, "due_today": due_today, }