diff --git a/app/modules/employees/service.py b/app/modules/employees/service.py index 9133374..34b4a39 100644 --- a/app/modules/employees/service.py +++ b/app/modules/employees/service.py @@ -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, diff --git a/app/modules/employees/templates/employees/self_work.html b/app/modules/employees/templates/employees/self_work.html index 3a2418f..c71b835 100644 --- a/app/modules/employees/templates/employees/self_work.html +++ b/app/modules/employees/templates/employees/self_work.html @@ -78,10 +78,10 @@
Unassigned, overdue and client-pending tasks sorted by urgency.
Engagement-level statutory/internal due-date breaches.
| {{ row.client_name }} — {{ row.service_name }} {{ row.financial_year }} · {{ row.progress_percent }}% complete | {{ row.sla.label }} Age {{ row.sla.age_days }} day(s) |
| No SLA breaches. |
Due within seven days.
| {{ row.client_name }} — {{ row.service_name }} {{ row.financial_year }} · {{ row.progress_percent }}% complete | {{ row.sla.label }} |
| No engagements in warning window. |
Unassigned, overdue and client-pending tasks remain visible.
Completed or review-ready work waiting for manager action.
Prioritised by review dependency, weighted completion and SLA ageing.
| Engagement | Review Pending | Weighted Progress | SLA / Age | Action |
|---|---|---|---|---|
{{ row.client_name }} {{ row.service_name }} · {{ row.financial_year }} | {{ row.review_pending }} | {{ row.progress_percent }}% | {{ row.sla.label }} {{ row.sla.age_days }} day(s) | Open Workflow |
| No engagement review dependencies. | ||||
Existing task-level review workflow remains available.
Derived from open task weight, review load and overdue work. Existing assignments remain unchanged.
| Team Member | Engagements | Open Tasks | Weighted Load | Review | Overdue | Capacity |
|---|---|---|---|---|---|---|
| {{ row.name }} | {{ row.engagement_count }} | {{ row.open_tasks }} | {{ row.open_weight }} | {{ row.review }} | {{ row.overdue }} | {{ row.load_status }} |
| No visible workload. | ||||||
One row per engagement using existing Partner, Manager, Staff and Review Partner assignments.
| Engagement | Team | Progress | SLA | Status |
|---|---|---|---|---|
| {{ row.client_name }} — {{ row.service_name }} {{ row.financial_year }} | Staff: {{ row.staff_name }} Manager: {{ row.manager_name }} Partner: {{ row.partner_name }} | {{ row.progress_percent }}% {{ row.completed_weight }}/{{ row.total_weight }} weighted | {{ row.sla.label }} Age {{ row.sla.age_days }} day(s) | {{ row.status.replace('_',' ').title() }} |
Top 100 visible tasks. Use detailed board for assignment changes.