Add advanced employee engagement workflow controls phase 4
This commit is contained in:
@@ -74,6 +74,15 @@ WORKFLOW_HOLD_REASONS = {
|
||||
|
||||
TASK_COMMUNICATION_VISIBILITY_CODES = {code for code, _ in TASK_COMMUNICATION_VISIBILITIES}
|
||||
|
||||
# Phase 4 derived controls deliberately reuse existing task, engagement, review and
|
||||
# alert records. No duplicate workflow or capacity tables are introduced.
|
||||
WORKFLOW_ESCALATION_LEVELS = {
|
||||
"manager": "Manager",
|
||||
"partner": "Engagement Partner",
|
||||
"review_partner": "Review Partner",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmployeeScope:
|
||||
@@ -2955,8 +2964,12 @@ def list_employee_work_kanban(
|
||||
for card in engagement_lookup.values():
|
||||
total = card["task_count"]
|
||||
completed = card["completed_count"]
|
||||
card["progress_percent"] = int(round((completed * 100) / total)) if total else 0
|
||||
weighted = _weighted_progress(card.get("tasks", []))
|
||||
card.update(weighted)
|
||||
card["simple_progress_percent"] = int(round((completed * 100) / total)) if total else 0
|
||||
card["open_count"] = max(total - completed, 0)
|
||||
card["engagement_team"] = _engagement_team(card.get("subscription"))
|
||||
card["sla"] = _engagement_sla(card.get("subscription"), card.get("tasks", []))
|
||||
|
||||
manual_blocker = _workflow_manual_blocker(card.get("subscription"))
|
||||
automatic_blocker = _workflow_automatic_blocker(card.get("subscription"), card.get("tasks", []))
|
||||
@@ -3118,6 +3131,148 @@ def _employee_workflow_next_task(tasks: list[ClientServiceTaskInstance], current
|
||||
)
|
||||
|
||||
|
||||
def _workflow_task_weight(task: ClientServiceTaskInstance) -> int:
|
||||
"""Stable derived progress weight without changing the task schema.
|
||||
|
||||
Ordinary execution tasks count as 1. Review and AQMM/final-release tasks carry
|
||||
higher weight so completion cannot appear artificially high while significant
|
||||
review gates remain open.
|
||||
"""
|
||||
weight = 1
|
||||
if bool(getattr(task, "is_aqmm_task", False)):
|
||||
weight += 2
|
||||
if bool(getattr(task, "aqmm_blocks_final_release", False)):
|
||||
weight += 2
|
||||
if any(bool(getattr(task, name, False)) for name in (
|
||||
"aqmm_manager_review_required",
|
||||
"aqmm_partner_review_required",
|
||||
"aqmm_review_partner_required",
|
||||
)):
|
||||
weight += 1
|
||||
if (getattr(task, "default_role_name", "") or "").strip().lower() in {"manager", "partner", "review partner"}:
|
||||
weight += 1
|
||||
return min(weight, 7)
|
||||
|
||||
|
||||
def _weighted_progress(tasks: list[ClientServiceTaskInstance]) -> dict[str, int]:
|
||||
total_weight = sum(_workflow_task_weight(task) for task in tasks)
|
||||
completed_weight = sum(
|
||||
_workflow_task_weight(task)
|
||||
for task in tasks
|
||||
if (getattr(task, "status", None) or "pending").strip().lower() in CLOSED_TASK_STATUSES
|
||||
)
|
||||
percent = int(round((completed_weight * 100) / total_weight)) if total_weight else 0
|
||||
return {
|
||||
"total_weight": total_weight,
|
||||
"completed_weight": completed_weight,
|
||||
"progress_percent": percent,
|
||||
}
|
||||
|
||||
|
||||
def _engagement_team(subscription: ClientServiceSubscription | None) -> list[dict[str, Any]]:
|
||||
if subscription is None:
|
||||
return []
|
||||
team = []
|
||||
for role, rel_name, id_name in (
|
||||
("Engagement Partner", "assigned_partner", "assigned_partner_user_id"),
|
||||
("Manager", "assigned_manager", "assigned_manager_user_id"),
|
||||
("Primary Staff", "assigned_staff", "assigned_staff_user_id"),
|
||||
("Review Partner", "review_partner", "review_partner_user_id"),
|
||||
):
|
||||
user = getattr(subscription, rel_name, None)
|
||||
user_id = getattr(subscription, id_name, None)
|
||||
if user_id:
|
||||
team.append({
|
||||
"role": role,
|
||||
"user_id": user_id,
|
||||
"name": getattr(user, "full_name", None) or getattr(user, "email", None) or f"User #{user_id}",
|
||||
"email": getattr(user, "email", None) or "",
|
||||
})
|
||||
return team
|
||||
|
||||
|
||||
def _engagement_sla(subscription: ClientServiceSubscription | None, tasks: list[ClientServiceTaskInstance]) -> dict[str, Any]:
|
||||
today = date.today()
|
||||
due_date = None
|
||||
if subscription is not None:
|
||||
due_date = getattr(subscription, "current_due_date", None) or getattr(subscription, "original_due_date", None) or getattr(subscription, "end_date", None)
|
||||
if due_date is None:
|
||||
dates = [getattr(task, "internal_target_date", None) for task in tasks if getattr(task, "internal_target_date", None)]
|
||||
due_date = max(dates) if dates else None
|
||||
started_dates = [getattr(task, "started_at_utc", None) for task in tasks if getattr(task, "started_at_utc", None)]
|
||||
created_at = getattr(subscription, "created_at_utc", None) if subscription is not None else None
|
||||
anchor = min(started_dates) if started_dates else created_at
|
||||
age_days = max((datetime.now(timezone.utc) - anchor).days, 0) if anchor else 0
|
||||
days_remaining = (due_date - today).days if due_date else None
|
||||
if due_date and days_remaining < 0:
|
||||
status = "breached"
|
||||
label = f"Overdue by {abs(days_remaining)} day(s)"
|
||||
elif due_date and days_remaining <= 3:
|
||||
status = "critical"
|
||||
label = f"Due in {days_remaining} day(s)"
|
||||
elif due_date and days_remaining <= 7:
|
||||
status = "warning"
|
||||
label = f"Due in {days_remaining} day(s)"
|
||||
elif due_date:
|
||||
status = "on_track"
|
||||
label = f"{days_remaining} day(s) remaining"
|
||||
else:
|
||||
status = "not_set"
|
||||
label = "No SLA due date"
|
||||
return {
|
||||
"due_date": due_date,
|
||||
"days_remaining": days_remaining,
|
||||
"age_days": age_days,
|
||||
"status": status,
|
||||
"label": label,
|
||||
}
|
||||
|
||||
|
||||
def escalate_employee_engagement_workflow(
|
||||
db: Session,
|
||||
scope: EmployeeScope,
|
||||
engagement_id: int,
|
||||
*,
|
||||
level: str,
|
||||
message: str,
|
||||
actor_user_id: int,
|
||||
financial_year: str | None = None,
|
||||
) -> int:
|
||||
tasks = _employee_engagement_tasks(db, scope, engagement_id, financial_year=financial_year)
|
||||
if not tasks:
|
||||
raise ValueError("Engagement was not found or is not assigned to you.")
|
||||
subscription = getattr(tasks[0], "subscription", None)
|
||||
if subscription is None:
|
||||
raise ValueError("Engagement subscription was not found.")
|
||||
code = (level or "manager").strip().lower()
|
||||
target_id = {
|
||||
"manager": getattr(subscription, "assigned_manager_user_id", None),
|
||||
"partner": getattr(subscription, "assigned_partner_user_id", None),
|
||||
"review_partner": getattr(subscription, "review_partner_user_id", None),
|
||||
}.get(code)
|
||||
if not target_id:
|
||||
raise ValueError(f"{WORKFLOW_ESCALATION_LEVELS.get(code, 'Selected reviewer')} is not assigned to this engagement.")
|
||||
clean_message = (message or "").strip()[:1000]
|
||||
if not clean_message:
|
||||
raise ValueError("Escalation details are required.")
|
||||
create_alert(
|
||||
db,
|
||||
user_id=int(target_id),
|
||||
title=f"Workflow escalation: {_subscription_label(subscription, tasks[0])}",
|
||||
message=clean_message,
|
||||
tenant_id=subscription.tenant_id,
|
||||
branch_id=subscription.branch_id,
|
||||
role_context=code,
|
||||
alert_type="escalation",
|
||||
priority="high",
|
||||
target_url=f"/employee/work/engagements/{engagement_id}",
|
||||
created_by_user_id=actor_user_id,
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return int(target_id)
|
||||
|
||||
|
||||
def get_employee_engagement_work_board(
|
||||
db: Session,
|
||||
scope: EmployeeScope,
|
||||
@@ -3199,7 +3354,9 @@ def get_employee_engagement_work_board(
|
||||
category["in_progress"] += 1
|
||||
|
||||
for category in categories:
|
||||
category["progress_percent"] = round(
|
||||
weighted_category = _weighted_progress(category["tasks"])
|
||||
category.update(weighted_category)
|
||||
category["simple_progress_percent"] = round(
|
||||
(category["completed"] / category["total"]) * 100
|
||||
) if category["total"] else 0
|
||||
if category["completed"] == category["total"] and category["total"]:
|
||||
@@ -3225,7 +3382,8 @@ def get_employee_engagement_work_board(
|
||||
|
||||
selected_category = _employee_task_category(selected_task)
|
||||
next_task = _employee_workflow_next_task(tasks, selected_task.id)
|
||||
overall_progress = round((summary["completed"] / summary["total"]) * 100) if summary["total"] else 0
|
||||
weighted_overall = _weighted_progress(tasks)
|
||||
overall_progress = weighted_overall["progress_percent"]
|
||||
subscription = getattr(tasks[0], "subscription", None)
|
||||
manual_blocker = _workflow_manual_blocker(subscription)
|
||||
automatic_blocker = _workflow_automatic_blocker(subscription, tasks)
|
||||
@@ -3241,6 +3399,10 @@ def get_employee_engagement_work_board(
|
||||
"label": _subscription_label(getattr(tasks[0], "subscription", None), tasks[0]),
|
||||
"summary": summary,
|
||||
"progress_percent": overall_progress,
|
||||
"weighted_progress": weighted_overall,
|
||||
"engagement_team": _engagement_team(subscription),
|
||||
"sla": _engagement_sla(subscription, tasks),
|
||||
"escalation_levels": WORKFLOW_ESCALATION_LEVELS,
|
||||
"categories": categories,
|
||||
"active_task": selected_task,
|
||||
"active_category": selected_category,
|
||||
|
||||
Reference in New Issue
Block a user