diff --git a/app/modules/employees/service.py b/app/modules/employees/service.py index e661966..9e4308b 100644 --- a/app/modules/employees/service.py +++ b/app/modules/employees/service.py @@ -3573,17 +3573,77 @@ def _i1_approval_blocker( return None +def _i1_review_execution_blocker( + tasks: list[ClientServiceTaskInstance], + target_task: ClientServiceTaskInstance, +) -> dict[str, Any] | None: + """Lock downstream staff work behind an unfinished Manager/Partner review-step task. + + Some service templates model the review itself as a separate execution task + (for example, "Review of working and draft filing") rather than as a review + status on the preceding Staff task. Such tasks must remain visible to Staff + for workflow context, but Staff cannot execute them and downstream Staff work + must wait until the reviewer completes the review-step task. + """ + ordered = sorted(tasks, key=lambda row: (int(getattr(row, "sequence_no", 0) or 0), int(row.id))) + reviewer_roles = {"manager", "partner", "review partner"} + + for row in ordered: + if row.id == target_task.id: + break + + status = (getattr(row, "status", None) or "pending").strip().lower() + if status in CLOSED_TASK_STATUSES: + continue + + default_role = (getattr(row, "default_role_name", None) or "").strip().lower() + if default_role not in reviewer_roles: + continue + + eligible = [ + item.strip() + for item in (getattr(row, "eligible_role_names", None) or "").split(",") + if item.strip() + ] + reviewer_label = " / ".join( + role for role in ("Manager", "Partner", "Review Partner") if role in eligible + ) + if not reviewer_label: + reviewer_label = (getattr(row, "default_role_name", None) or "Reviewer").strip() + + return { + "task_id": int(row.id), + "task_name": row.task_name, + "label": f"Awaiting {reviewer_label} review step", + "levels": [reviewer_label], + } + return None + + +def _i1_workflow_blocker( + tasks: list[ClientServiceTaskInstance], + target_task: ClientServiceTaskInstance, +) -> dict[str, Any] | None: + """Return the first applicable normal/AQMM approval or reviewer-task gate.""" + return ( + _i1_approval_blocker(tasks, target_task) + or _i1_review_execution_blocker(tasks, target_task) + ) + + def _i1_prepare_task_display( task: ClientServiceTaskInstance, *, tasks: list[ClientServiceTaskInstance], task_document_counts: dict[int, int], + read_only: bool = False, ) -> None: status = (getattr(task, "status", None) or "pending").strip().lower() task.i1_status = status task.i1_review_levels = _i1_required_review_levels(task) task.i1_review_label = _i1_review_label(task) - task.i1_approval_blocker = _i1_approval_blocker(tasks, task) + task.i1_approval_blocker = _i1_workflow_blocker(tasks, task) + task.i1_read_only = bool(read_only) task.i1_has_evidence = task_document_counts.get(int(task.id), 0) > 0 task.i1_documents = [] task.i1_communications = [ @@ -3597,7 +3657,8 @@ def _i1_prepare_task_display( ) task.i1_needs_evidence = bool(getattr(task, "evidence_required", False)) task.i1_quick_complete = bool( - not task.i1_approval_blocker + not task.i1_read_only + and not task.i1_approval_blocker and not task.i1_needs_response and (not task.i1_needs_evidence or task.i1_has_evidence) and not getattr(task, "is_locked", False) @@ -3785,23 +3846,48 @@ def get_employee_engagement_work_board( document, checklist, AQMM and lock controls remain the source of truth. """ today = date.today() - stmt = _employee_work_task_query( + assigned_stmt = _employee_work_task_query( db, scope, assigned_only=True, financial_year=financial_year ).where(ClientServiceTaskInstance.subscription_id == engagement_id) - tasks = db.execute( - stmt.order_by( + assigned_tasks = db.execute( + assigned_stmt.order_by( ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.internal_target_date.is_(None), ClientServiceTaskInstance.internal_target_date.asc(), ClientServiceTaskInstance.id.asc(), ) ).scalars().all() - if not tasks: + if not assigned_tasks: raise HTTPException( status_code=404, detail="Engagement work not found or not assigned to you", ) + # Keep the Staff assignment boundary, but include Manager/Partner review-step + # tasks from the same engagement as read-only workflow context. This keeps + # sequence numbers visible and allows downstream Staff tasks to show the + # exact reviewer dependency without giving Staff execution rights. + all_stmt = _employee_work_task_query( + db, scope, assigned_only=False, financial_year=financial_year + ).where(ClientServiceTaskInstance.subscription_id == engagement_id) + all_tasks = db.execute( + all_stmt.order_by( + ClientServiceTaskInstance.sequence_no.asc(), + ClientServiceTaskInstance.internal_target_date.is_(None), + ClientServiceTaskInstance.internal_target_date.asc(), + ClientServiceTaskInstance.id.asc(), + ) + ).scalars().all() + + assigned_ids = {int(row.id) for row in assigned_tasks} + reviewer_roles = {"manager", "partner", "review partner"} + tasks = [ + row + for row in all_tasks + if int(row.id) in assigned_ids + or (getattr(row, "default_role_name", None) or "").strip().lower() in reviewer_roles + ] + summary = { "total": len(tasks), "open": 0, @@ -3869,17 +3955,23 @@ def get_employee_engagement_work_board( selected_task = next((row for row in tasks if row.id == active_task_id), None) if selected_task is None: selected_task = next( - (row for row in tasks if (row.status or "pending").strip().lower() == "in_progress"), + ( + row for row in assigned_tasks + if (row.status or "pending").strip().lower() == "in_progress" + ), None, ) if selected_task is None: selected_task = next( - (row for row in tasks if (row.status or "pending").strip().lower() not in CLOSED_TASK_STATUSES), - tasks[0], + ( + row for row in assigned_tasks + if (row.status or "pending").strip().lower() not in CLOSED_TASK_STATUSES + ), + assigned_tasks[0], ) selected_category = _employee_task_category(selected_task) - next_task = _employee_workflow_next_task(tasks, selected_task.id) + next_task = _employee_workflow_next_task(assigned_tasks, selected_task.id) weighted_overall = _weighted_progress(tasks) overall_progress = weighted_overall["progress_percent"] subscription = getattr(tasks[0], "subscription", None) @@ -3904,8 +3996,9 @@ def get_employee_engagement_work_board( for task in tasks: _i1_prepare_task_display( task, - tasks=tasks, + tasks=all_tasks, task_document_counts=task_document_counts, + read_only=int(task.id) not in assigned_ids, ) task.i1_documents = task_documents.get(int(task.id), []) @@ -3984,7 +4077,7 @@ def save_employee_workflow_task( .where(ClientServiceTaskInstance.subscription_id == task.subscription_id) .order_by(ClientServiceTaskInstance.sequence_no.asc(), ClientServiceTaskInstance.id.asc()) ).scalars().all() - approval_blocker = _i1_approval_blocker(engagement_tasks_for_gate, task) + approval_blocker = _i1_workflow_blocker(engagement_tasks_for_gate, task) if approval_blocker and normalised_status in {"in_progress", "completed", "not_applicable"}: raise ValueError(approval_blocker["label"]) diff --git a/app/modules/employees/templates/employees/work_engagement_board.html b/app/modules/employees/templates/employees/work_engagement_board.html index e0153ea..c26ae3d 100644 --- a/app/modules/employees/templates/employees/work_engagement_board.html +++ b/app/modules/employees/templates/employees/work_engagement_board.html @@ -130,6 +130,9 @@
#{{ task.sequence_no }}

{{ task.task_name }}

+ {% if task.i1_read_only %} + Read Only + {% endif %} {% if task.i1_review_label %} {% if task.i1_review_label == 'Approved' %} ◆ Approved @@ -159,7 +162,9 @@
- {% if not task.i1_approval_blocker and not task.is_locked and not (task.subscription and task.subscription.is_locked) %} + {% if task.i1_read_only %} + Review step + {% elif not task.i1_approval_blocker and not task.is_locked and not (task.subscription and task.subscription.is_locked) %} {% if status not in ['completed', 'not_applicable'] %} {% if task.i1_quick_complete %}