Show engagement progress instead of internal task status to consultants
This commit is contained in:
@@ -15,6 +15,7 @@ from app.modules.services.models import (
|
||||
ClientServiceTaskInstance,
|
||||
ServiceCatalogue,
|
||||
ServiceTaskComment,
|
||||
ServiceTaskDocumentRequest,
|
||||
)
|
||||
|
||||
CONSULTANT_BOARD_COLUMNS = OrderedDict(
|
||||
@@ -62,31 +63,337 @@ def _matches_search(*values: Any, q: str = "") -> bool:
|
||||
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:
|
||||
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), ClientConsultantLink.can_view_task_status.is_(True))).scalars().all()
|
||||
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)
|
||||
columns = {key: {"label": label, "items": []} for key, label in CONSULTANT_BOARD_COLUMNS.items()}
|
||||
items=[]
|
||||
|
||||
engagements: list[dict] = []
|
||||
assigned_tasks: list[dict] = []
|
||||
if links_by_client:
|
||||
rows=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.tenant_id==consultant.tenant_id, ClientServiceTaskInstance.client_id.in_(list(links_by_client)), ClientServiceTaskInstance.is_active.is_(True)).order_by(ClientServiceTaskInstance.internal_target_date.asc(), ClientServiceTaskInstance.id.desc()).limit(500)).all()
|
||||
for task, subscription, client, catalogue in rows:
|
||||
allowed=next((ln for ln in links_by_client[int(client.id)] if ln.service_catalogue_id in (None, task.service_catalogue_id)),None)
|
||||
if not allowed or not _matches_search(client.client_name, getattr(client,"client_code",""), catalogue.service_name, task.task_name, q=q): continue
|
||||
key=_board_key_for_status(task.status)
|
||||
if status and key!=status: continue
|
||||
latest=db.execute(select(ServiceTaskComment).where(ServiceTaskComment.task_instance_id==task.id, ServiceTaskComment.visibility=="consultant", ServiceTaskComment.is_deleted.is_(False)).order_by(ServiceTaskComment.created_at_utc.desc()).limit(1)).scalars().first()
|
||||
item={"comment":latest,"task":task,"subscription":subscription,"client":client,"catalogue":catalogue,"board_key":key,"link":allowed,"is_overdue":bool(task.internal_target_date and task.internal_target_date<date.today())}
|
||||
columns[key]["items"].append(item); items.append(item)
|
||||
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()
|
||||
return {"columns":columns,"column_options":list(CONSULTANT_BOARD_COLUMNS.items()),"total_tasks":len(items),"service_requests":service_requests}
|
||||
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:
|
||||
client_ids = _allowed_client_ids(db, consultant=consultant, require_communications=False)
|
||||
if not client_ids:
|
||||
return None
|
||||
row = db.execute(
|
||||
select(ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
|
||||
.join(ClientServiceSubscription, ClientServiceSubscription.id == ClientServiceTaskInstance.subscription_id)
|
||||
@@ -95,15 +402,25 @@ def get_consultant_assignment_detail(db: Session, *, consultant: ConsultantProfi
|
||||
.where(
|
||||
ClientServiceTaskInstance.id == task_id,
|
||||
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
|
||||
ClientServiceTaskInstance.client_id.in_(client_ids),
|
||||
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:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user