Files

543 lines
26 KiB
Python

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