Implement I1 simplified engagement checklist
This commit is contained in:
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user