Add grouped employee engagement workflow workspace phase 2
This commit is contained in:
@@ -22,7 +22,13 @@ from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeR
|
||||
from app.modules.clients.models import Client
|
||||
from app.modules.documents.models import EngagementDocument
|
||||
from app.modules.services.models import ClientServiceTaskInstance, ClientServiceSubscription, ServiceCatalogue, ServiceTaskComment
|
||||
from app.modules.services.execution import CLOSED_TASK_STATUSES, TASK_PRIORITIES, TASK_STATUSES
|
||||
from app.modules.services.execution import (
|
||||
CLOSED_TASK_STATUSES,
|
||||
TASK_PRIORITIES,
|
||||
TASK_STATUSES,
|
||||
apply_task_checklist_response,
|
||||
recalculate_task_aqmm_status,
|
||||
)
|
||||
|
||||
EMPLOYEE_STATUS = ["active", "inactive", "relieved"]
|
||||
EMPLOYMENT_TYPES = ["full_time", "part_time", "article_assistant", "intern", "consultant", "contract"]
|
||||
@@ -2994,9 +3000,37 @@ def list_employee_engagement_documents(db: Session, scope: EmployeeScope, engage
|
||||
return db.execute(stmt).scalars().unique().all()
|
||||
|
||||
|
||||
def get_employee_engagement_work_board(db: Session, scope: EmployeeScope, engagement_id: int, *, financial_year: str | None = None) -> dict[str, Any]:
|
||||
def _employee_task_category(task: ClientServiceTaskInstance) -> str:
|
||||
value = (getattr(task, "task_category", None) or "").strip()
|
||||
return value or "General Workflow"
|
||||
|
||||
|
||||
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)]
|
||||
return next(
|
||||
(row for row in ordered if (row.status or "pending").strip().lower() not in CLOSED_TASK_STATUSES),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def get_employee_engagement_work_board(
|
||||
db: Session,
|
||||
scope: EmployeeScope,
|
||||
engagement_id: int,
|
||||
*,
|
||||
financial_year: str | None = None,
|
||||
active_task_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the employee engagement workspace grouped by task_category.
|
||||
|
||||
Only tasks assigned to the logged-in employee are returned. Existing task,
|
||||
document, checklist, AQMM and lock controls remain the source of truth.
|
||||
"""
|
||||
today = date.today()
|
||||
stmt = _employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year).where(ClientServiceTaskInstance.subscription_id == engagement_id)
|
||||
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(
|
||||
ClientServiceTaskInstance.sequence_no.asc(),
|
||||
@@ -3006,18 +3040,23 @@ def get_employee_engagement_work_board(db: Session, scope: EmployeeScope, engage
|
||||
)
|
||||
).scalars().all()
|
||||
if not tasks:
|
||||
raise HTTPException(status_code=404, detail="Engagement work not found or not assigned to you")
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Engagement work not found or not assigned to you",
|
||||
)
|
||||
|
||||
subscription = getattr(tasks[0], "subscription", None)
|
||||
client = getattr(tasks[0], "client", None)
|
||||
summary = {"total": len(tasks), "open": 0, "pending": 0, "in_progress": 0, "blocked": 0, "completed": 0, "overdue": 0, "due_today": 0}
|
||||
columns = [
|
||||
{"code": "pending", "label": "Pending", "tasks": []},
|
||||
{"code": "in_progress", "label": "In Progress", "tasks": []},
|
||||
{"code": "blocked", "label": "Blocked", "tasks": []},
|
||||
{"code": "completed", "label": "Completed", "tasks": []},
|
||||
]
|
||||
column_lookup = {c["code"]: c for c in columns}
|
||||
summary = {
|
||||
"total": len(tasks),
|
||||
"open": 0,
|
||||
"pending": 0,
|
||||
"in_progress": 0,
|
||||
"blocked": 0,
|
||||
"completed": 0,
|
||||
"overdue": 0,
|
||||
"due_today": 0,
|
||||
}
|
||||
category_lookup: dict[str, dict[str, Any]] = {}
|
||||
categories: list[dict[str, Any]] = []
|
||||
|
||||
for task in tasks:
|
||||
_phase7i_task_card_enrich(task, today=today)
|
||||
@@ -3030,23 +3069,148 @@ def get_employee_engagement_work_board(db: Session, scope: EmployeeScope, engage
|
||||
summary["overdue"] += 1
|
||||
if task.is_due_today:
|
||||
summary["due_today"] += 1
|
||||
column_code = "completed" if is_closed else status_code
|
||||
if column_code not in column_lookup:
|
||||
column_code = "pending"
|
||||
column_lookup[column_code]["tasks"].append(task)
|
||||
|
||||
category_name = _employee_task_category(task)
|
||||
category = category_lookup.get(category_name)
|
||||
if category is None:
|
||||
category = {
|
||||
"name": category_name,
|
||||
"tasks": [],
|
||||
"total": 0,
|
||||
"completed": 0,
|
||||
"blocked": 0,
|
||||
"in_progress": 0,
|
||||
"progress_percent": 0,
|
||||
"status": "pending",
|
||||
}
|
||||
category_lookup[category_name] = category
|
||||
categories.append(category)
|
||||
category["tasks"].append(task)
|
||||
category["total"] += 1
|
||||
if is_closed:
|
||||
category["completed"] += 1
|
||||
elif status_code == "blocked":
|
||||
category["blocked"] += 1
|
||||
elif status_code == "in_progress":
|
||||
category["in_progress"] += 1
|
||||
|
||||
for category in categories:
|
||||
category["progress_percent"] = round(
|
||||
(category["completed"] / category["total"]) * 100
|
||||
) if category["total"] else 0
|
||||
if category["completed"] == category["total"] and category["total"]:
|
||||
category["status"] = "completed"
|
||||
elif category["blocked"]:
|
||||
category["status"] = "blocked"
|
||||
elif category["in_progress"] or category["completed"]:
|
||||
category["status"] = "in_progress"
|
||||
|
||||
selected_task = None
|
||||
if active_task_id is not None:
|
||||
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"),
|
||||
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],
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
return {
|
||||
"engagement_id": engagement_id,
|
||||
"subscription": subscription,
|
||||
"client": client,
|
||||
"label": _subscription_label(subscription, tasks[0]),
|
||||
"subscription": getattr(tasks[0], "subscription", None),
|
||||
"client": getattr(tasks[0], "client", None),
|
||||
"label": _subscription_label(getattr(tasks[0], "subscription", None), tasks[0]),
|
||||
"summary": summary,
|
||||
"columns": columns,
|
||||
"documents": list_employee_engagement_documents(db, scope, engagement_id, financial_year=financial_year),
|
||||
"progress_percent": overall_progress,
|
||||
"categories": categories,
|
||||
"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
|
||||
),
|
||||
"today": today,
|
||||
}
|
||||
|
||||
|
||||
def save_employee_workflow_task(
|
||||
db: Session,
|
||||
scope: EmployeeScope,
|
||||
task_id: int,
|
||||
*,
|
||||
status: str,
|
||||
remarks: str,
|
||||
checklist_response: str,
|
||||
checklist_text_response: str,
|
||||
checklist_number_response: str,
|
||||
checklist_date_response: str,
|
||||
checklist_remarks: str,
|
||||
actor_user_id: int,
|
||||
financial_year: str | None = None,
|
||||
) -> tuple[ClientServiceTaskInstance, ClientServiceTaskInstance | None]:
|
||||
"""Save one assigned workflow task and return its next open task.
|
||||
|
||||
The operation is atomic: checklist validation, task status, AQMM status and
|
||||
timestamps are committed together. Locked tasks and locked engagements are
|
||||
never modified.
|
||||
"""
|
||||
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")
|
||||
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
|
||||
raise ValueError("Locked task cannot be changed.")
|
||||
|
||||
normalised_status = (status or "pending").strip().lower()
|
||||
if normalised_status not in {"pending", "in_progress", "blocked", "completed"}:
|
||||
raise ValueError("Invalid task status.")
|
||||
|
||||
apply_task_checklist_response(
|
||||
db,
|
||||
task,
|
||||
checklist_response=checklist_response,
|
||||
checklist_text_response=checklist_text_response,
|
||||
checklist_number_response=checklist_number_response,
|
||||
checklist_date_response=checklist_date_response,
|
||||
checklist_remarks=checklist_remarks,
|
||||
requested_status=normalised_status,
|
||||
)
|
||||
|
||||
task.status = normalised_status
|
||||
if normalised_status == "completed":
|
||||
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()
|
||||
task.updated_by_user_id = actor_user_id
|
||||
recalculate_task_aqmm_status(db, task)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
engagement_tasks = 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()
|
||||
return task, _employee_workflow_next_task(engagement_tasks, task.id)
|
||||
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user