Add advanced employee engagement workflow controls phase 4

This commit is contained in:
A R R R Associates
2026-07-20 00:47:34 +05:30
parent 031c278e8f
commit fe23b2b253
8 changed files with 330 additions and 32 deletions
+165 -3
View File
@@ -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,
@@ -78,10 +78,10 @@
<div class="mt-4">
<div class="flex items-center justify-between text-xs">
<span class="font-semibold text-slate-600">My progress</span>
<span class="font-semibold text-slate-900">{{ card.progress_percent }}%</span>
<span class="font-semibold text-slate-900">{{ card.progress_percent }}% weighted</span>
</div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-200">
<div class="h-full rounded-full bg-brand-600" style="width: {{ card.progress_percent }}%"></div>
<div class="h-full rounded-full bg-brand-600" style="width: {{ card.progress_percent }}% weighted"></div>
</div>
<div class="mt-2 text-xs text-slate-500">{{ card.completed_count }} of {{ card.task_count }} assigned tasks completed</div>
</div>
@@ -30,6 +30,15 @@
{% if request.query_params.get('resumed') %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Engagement workflow resumed.</div>{% endif %}
{% if request.query_params.get('pause_error') or request.query_params.get('resume_error') %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">The workflow status could not be changed. Confirm the reason and that the engagement is not locked.</div>{% endif %}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Weighted Progress</div><div class="mt-2 text-3xl font-semibold">{{ board.progress_percent }}%</div><div class="mt-1 text-xs text-slate-500">{{ board.weighted_progress.completed_weight }} of {{ board.weighted_progress.total_weight }} weighted points</div></div>
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">SLA &amp; Ageing</div><div class="mt-2 text-lg font-semibold {% if board.sla.status == 'breached' %}text-red-700{% elif board.sla.status in ['critical','warning'] %}text-amber-700{% else %}text-emerald-700{% endif %}">{{ board.sla.label }}</div><div class="mt-1 text-xs text-slate-500">Age {{ board.sla.age_days }} day(s){% if board.sla.due_date %} · Due {{ board.sla.due_date }}{% endif %}</div></div>
<div class="af-metric-card md:col-span-2"><div class="text-xs font-semibold uppercase text-slate-500">Engagement Team</div><div class="mt-3 flex flex-wrap gap-2">{% for member in board.engagement_team %}<span class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs"><strong>{{ member.role }}:</strong> {{ member.name }}</span>{% else %}<span class="text-sm text-slate-500">No engagement team assigned.</span>{% endfor %}</div></div>
</section>
{% if request.query_params.get('escalated') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Escalation alert sent successfully.</div>{% endif %}
{% if request.query_params.get('escalation_error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Escalation could not be sent. Confirm the selected reviewer is assigned and enter details.</div>{% endif %}
{% if board.workflow_blocker %}
<div class="rounded-2xl border {% if board.workflow_blocker.type == 'manual' %}border-amber-300 bg-amber-50{% else %}border-violet-300 bg-violet-50{% endif %} p-4">
<div class="font-semibold text-slate-900">{{ board.workflow_blocker.label }}</div>
@@ -200,6 +209,15 @@
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-5">
<details class="w-full rounded-xl border border-amber-200 bg-amber-50 p-3 text-left">
<summary class="cursor-pointer text-sm font-semibold text-amber-900">Escalate dependency or review delay</summary>
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/escalate" class="mt-3 grid gap-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="level" class="w-full rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm" required>{% for code, label in board.escalation_levels.items() %}<option value="{{ code }}">{{ label }}</option>{% endfor %}</select>
<textarea name="message" rows="3" maxlength="1000" required class="w-full rounded-xl border border-amber-300 bg-white px-3 py-2 text-sm" placeholder="Explain the dependency, ageing or review delay"></textarea>
<button type="submit" class="rounded-xl border border-amber-300 bg-white px-4 py-2 text-sm font-semibold text-amber-900">Send Escalation</button>
</form>
</details>
<a href="/employee/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Close Workspace</a>
<div class="flex flex-wrap gap-2">
<button type="submit" name="workflow_action" value="save" class="rounded-xl border border-brand-300 bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50" {% if task.is_locked or (task.subscription and task.subscription.is_locked) %}disabled{% endif %}>Save</button>
+32
View File
@@ -51,6 +51,7 @@ from app.modules.employees.service import (
save_employee_workflow_task,
pause_employee_engagement_workflow,
resume_employee_engagement_workflow,
escalate_employee_engagement_workflow,
list_employee_work_assignable_users,
list_visible_work_assignment_dashboard,
list_engagement_progress_dashboard,
@@ -2169,6 +2170,37 @@ def employee_workflow_resume(request: Request, engagement_id: int, csrf_token: s
finally: db.close()
@portal_router.post("/work/engagements/{engagement_id}/escalate")
def employee_workflow_escalate(
request: Request,
engagement_id: int,
level: str = Form("manager"),
message: str = Form(""),
csrf_token: str = Form(""),
):
if not validate_csrf(request, csrf_token):
return _csrf_rejected(request)
with CommonSessionLocal() as db:
current_user = get_current_user(request, db)
if not current_user:
return RedirectResponse(url="/login", status_code=303)
scope = build_employee_scope(
db, current_user,
tenant_id=request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None),
branch_id=request.session.get("active_branch_id"),
)
try:
escalate_employee_engagement_workflow(
db, scope, engagement_id, level=level, message=message,
actor_user_id=current_user.id,
financial_year=request.session.get("active_financial_year"),
)
except ValueError:
db.rollback()
return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?escalation_error=1", status_code=303)
return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?escalated=1", status_code=303)
@portal_router.post("/work/engagements/{engagement_id}/tasks/{task_id}/save")
def employee_workflow_task_save(
request: Request,