diff --git a/app/modules/employees/service.py b/app/modules/employees/service.py index 05c70e9..c941dd8 100644 --- a/app/modules/employees/service.py +++ b/app/modules/employees/service.py @@ -31,6 +31,7 @@ from app.modules.services.execution import ( TASK_STATUSES, apply_task_checklist_response, recalculate_task_aqmm_status, + submit_task_for_review, ) EMPLOYEE_STATUS = ["active", "inactive", "relieved"] @@ -2611,6 +2612,7 @@ def list_employee_work_dashboard( selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.assigned_staff), selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.review_partner), selectinload(ClientServiceTaskInstance.assigned_to), + selectinload(ClientServiceTaskInstance.template), selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by), ) .where( @@ -3450,6 +3452,112 @@ def _employee_task_category(task: ClientServiceTaskInstance) -> str: return value or "General Workflow" + +def _i1_required_review_levels(task: ClientServiceTaskInstance) -> list[str]: + """Return configured review levels for I1 using existing task-template controls.""" + levels: list[str] = [] + if getattr(task, "aqmm_manager_review_required", False): + levels.append("manager") + if getattr(task, "aqmm_partner_review_required", False): + levels.append("partner") + if getattr(task, "aqmm_review_partner_required", False): + levels.append("review_partner") + + # Preserve the pre-existing generic requires_review flag. Where no explicit + # reviewer flag was configured, route to Manager when available, otherwise + # Engagement Partner. No new review table/state is introduced. + template = getattr(task, "template", None) + if not levels and getattr(template, "requires_review", False): + subscription = getattr(task, "subscription", None) + if getattr(subscription, "assigned_manager_user_id", None): + levels.append("manager") + elif getattr(subscription, "assigned_partner_user_id", None): + levels.append("partner") + return levels + + +def _i1_review_level_pending(task: ClientServiceTaskInstance, level: str) -> bool: + if level == "manager": + return (getattr(task, "manager_review_status", None) or "not_required") != "reviewed" + if level == "partner": + return (getattr(task, "partner_review_status", None) or "not_required") != "reviewed" + if level == "review_partner": + return (getattr(task, "review_partner_review_status", None) or "not_required") != "reviewed" + return False + + +def _i1_review_label(task: ClientServiceTaskInstance) -> str | None: + levels = _i1_required_review_levels(task) + if not levels: + return None + if getattr(task, "rework_status", "none") == "open": + return "Returned for correction" + pending = [level for level in levels if _i1_review_level_pending(task, level)] + if not pending: + return "Approved" + labels = { + "manager": "Manager", + "partner": "Partner", + "review_partner": "Review Partner", + } + return "Awaiting " + " + ".join(labels[level] for level in pending) + " approval" + + +def _i1_approval_blocker( + tasks: list[ClientServiceTaskInstance], + target_task: ClientServiceTaskInstance, +) -> dict[str, Any] | None: + """Lock downstream staff work until required approval on an earlier task is complete.""" + ordered = sorted(tasks, key=lambda row: (int(getattr(row, "sequence_no", 0) or 0), int(row.id))) + for row in ordered: + if row.id == target_task.id: + break + levels = _i1_required_review_levels(row) + if not levels: + continue + pending = [level for level in levels if _i1_review_level_pending(row, level)] + if not pending: + continue + labels = { + "manager": "Manager", + "partner": "Partner", + "review_partner": "Review Partner", + } + return { + "task_id": int(row.id), + "task_name": row.task_name, + "label": _i1_review_label(row) or "Approval pending", + "levels": [labels[level] for level in pending], + } + return None + + +def _i1_prepare_task_display( + task: ClientServiceTaskInstance, + *, + tasks: list[ClientServiceTaskInstance], + task_document_counts: dict[int, int], +) -> 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_has_evidence = task_document_counts.get(int(task.id), 0) > 0 + task.i1_needs_response = bool( + getattr(task, "response_required", False) + or (getattr(task, "response_type", "NONE") or "NONE").strip().upper() != "NONE" + ) + task.i1_needs_evidence = bool(getattr(task, "evidence_required", False)) + task.i1_quick_complete = bool( + 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) + and not getattr(getattr(task, "subscription", None), "is_locked", False) + ) + + def _employee_workflow_next_task(tasks: list[ClientServiceTaskInstance], current_task_id: int) -> ClientServiceTaskInstance | None: current_index = next((index for index, row in enumerate(tasks) if row.id == current_task_id), -1) ordered = tasks[current_index + 1 :] + tasks[: max(current_index, 0)] @@ -3729,6 +3837,28 @@ def get_employee_engagement_work_board( manual_blocker = _workflow_manual_blocker(subscription) automatic_blocker = _workflow_automatic_blocker(subscription, tasks) + engagement_documents = list_employee_engagement_documents( + db, scope, engagement_id, financial_year=financial_year + ) + task_document_counts: dict[int, int] = {} + for document in engagement_documents: + task_instance_id = getattr(document, "task_instance_id", None) + if task_instance_id: + task_document_counts[int(task_instance_id)] = task_document_counts.get(int(task_instance_id), 0) + 1 + + for task in tasks: + _i1_prepare_task_display( + task, + tasks=tasks, + task_document_counts=task_document_counts, + ) + + scope_display = _employee_work_scope_display_map( + db, + tenant_id=scope.tenant_id, + subscriptions=[subscription] if subscription is not None else [], + ).get(int(subscription.id), {}) if subscription is not None else {} + return { "engagement_id": engagement_id, "subscription": subscription, @@ -3748,9 +3878,9 @@ def get_employee_engagement_work_board( "active_task": selected_task, "active_category": selected_category, "next_task": next_task, - "documents": list_employee_engagement_documents( - db, scope, engagement_id, financial_year=financial_year - ), + "documents": engagement_documents, + "scope_display": scope_display, + "i1_enabled": True, "today": today, } @@ -3786,9 +3916,26 @@ def save_employee_workflow_task( raise ValueError("Locked task cannot be changed.") normalised_status = (status or "pending").strip().lower() - if normalised_status not in {"pending", "in_progress", "blocked", "completed"}: + if normalised_status not in {"pending", "in_progress", "blocked", "completed", "not_applicable"}: raise ValueError("Invalid task status.") + engagement_tasks_for_gate = db.execute( + _employee_work_task_query( + db, scope, assigned_only=True, financial_year=financial_year + ) + .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) + if approval_blocker and normalised_status in {"in_progress", "completed", "not_applicable"}: + raise ValueError(approval_blocker["label"]) + + clean_remarks = (remarks or "").strip() + if normalised_status == "blocked" and not clean_remarks: + raise ValueError("Select or enter a blocker reason.") + if normalised_status == "not_applicable" and not clean_remarks: + raise ValueError("A short reason is required for Not Applicable.") + apply_task_checklist_response( db, task, @@ -3801,16 +3948,27 @@ def save_employee_workflow_task( ) task.status = normalised_status - if normalised_status == "completed": + if normalised_status in CLOSED_TASK_STATUSES: task.completed_at_utc = datetime.now(timezone.utc) else: task.completed_at_utc = None if normalised_status == "in_progress" and not getattr(task, "started_at_utc", None): task.started_at_utc = datetime.now(timezone.utc) - if remarks.strip(): - task.remarks = remarks.strip() + if clean_remarks: + task.remarks = clean_remarks task.updated_by_user_id = actor_user_id recalculate_task_aqmm_status(db, task) + + # I1 approval gate: completing a task configured for Manager/Partner review + # automatically sends the existing task to the existing review workflow. + if normalised_status == "completed" and _i1_required_review_levels(task): + submit_task_for_review( + db, + task=task, + note=clean_remarks or "Completed by staff and submitted for approval.", + user_id=actor_user_id, + ) + db.add(task) db.commit() db.refresh(task) @@ -3826,6 +3984,64 @@ def save_employee_workflow_task( +def quick_update_employee_workflow_task( + db: Session, + scope: EmployeeScope, + task_id: int, + *, + action: str, + reason: str, + actor_user_id: int, + financial_year: str | None = None, +) -> tuple[ClientServiceTaskInstance, ClientServiceTaskInstance | None]: + """I1 one-click task action using the existing task/checklist validation path.""" + action_code = (action or "").strip().lower() + status_map = { + "done": "completed", + "blocked": "blocked", + "na": "not_applicable", + } + if action_code not in status_map: + raise ValueError("Invalid checklist action.") + + stmt = _employee_work_task_query( + db, scope, assigned_only=True, financial_year=financial_year + ).where(ClientServiceTaskInstance.id == task_id) + task = db.execute(stmt).scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="Task not found or not assigned to you") + + reason_text = (reason or "").strip() + if action_code == "blocked" and not reason_text: + raise ValueError("A blocker reason is required.") + if action_code == "na" and not reason_text: + raise ValueError("A short reason is required for Not Applicable.") + + return save_employee_workflow_task( + db, + scope, + task_id, + status=status_map[action_code], + remarks=reason_text or (getattr(task, "remarks", None) or ""), + checklist_response=getattr(task, "checklist_response", None) or "", + checklist_text_response=getattr(task, "checklist_text_response", None) or "", + checklist_number_response=( + str(getattr(task, "checklist_number_response", "")) + if getattr(task, "checklist_number_response", None) is not None + else "" + ), + checklist_date_response=( + str(getattr(task, "checklist_date_response", "")) + if getattr(task, "checklist_date_response", None) is not None + else "" + ), + checklist_remarks=getattr(task, "checklist_remarks", None) or "", + actor_user_id=actor_user_id, + financial_year=financial_year, + ) + + + def list_employee_work_assignable_users(db: Session, scope: EmployeeScope) -> list[User]: """Users that can be assigned engagement/service tasks in the active employee scope.""" stmt = ( diff --git a/app/modules/employees/templates/employees/work_engagement_board.html b/app/modules/employees/templates/employees/work_engagement_board.html index 81dcaa0..ac789ea 100644 --- a/app/modules/employees/templates/employees/work_engagement_board.html +++ b/app/modules/employees/templates/employees/work_engagement_board.html @@ -6,10 +6,12 @@
- {{ board.client.client_name if board.client else 'Unlinked Client' }} - {% if board.client and board.client.client_code %} · {{ board.client.client_code }}{% endif %} + {% set scope_display = board.scope_display or {} %} +
+ {{ scope_display.primary or (board.client.trade_name if board.client and board.client.trade_name else (board.client.client_name if board.client else 'Unlinked Client')) }} + {% if scope_display.secondary %} · {{ scope_display.secondary }}{% elif board.client and board.client.client_code %} · {{ board.client.client_code }}{% endif %}
+ {% if scope_display.context %}{{ scope_display.context }}
{% endif %}