Add engagement-level employee workflow board phase 1
This commit is contained in:
@@ -2703,113 +2703,278 @@ def list_employee_work_kanban(
|
||||
status: str = "open",
|
||||
financial_year: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return staff self-work as engagement cards in kanban columns.
|
||||
"""Return exactly one employee-board card per assigned engagement.
|
||||
|
||||
Phase 7I does not introduce a new task table. It reuses existing
|
||||
client_service_task_instances and groups assigned tasks by engagement/service
|
||||
subscription so staff can open one engagement board and work through tasks.
|
||||
The task engine remains unchanged. The board derives an engagement state from
|
||||
the staff member's assigned tasks using this precedence:
|
||||
|
||||
completed -> all assigned tasks are closed
|
||||
blocked -> at least one assigned task is blocked
|
||||
in_progress -> at least one assigned task is started/completed
|
||||
pending -> no assigned task has started
|
||||
"""
|
||||
today = date.today()
|
||||
status_filter = (status or "open").strip().lower()
|
||||
stmt = _employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year)
|
||||
|
||||
if status_filter == "open":
|
||||
stmt = stmt.where(ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)))
|
||||
elif status_filter == "closed":
|
||||
stmt = stmt.where(ClientServiceTaskInstance.status.in_(list(CLOSED_TASK_STATUSES)))
|
||||
elif status_filter in {code for code, _ in TASK_STATUSES}:
|
||||
stmt = stmt.where(ClientServiceTaskInstance.status == status_filter)
|
||||
valid_filters = {"open", "closed", "pending", "in_progress", "blocked", "completed"}
|
||||
if status_filter not in valid_filters:
|
||||
status_filter = "open"
|
||||
|
||||
stmt = _employee_work_task_query(
|
||||
db,
|
||||
scope,
|
||||
assigned_only=True,
|
||||
financial_year=financial_year,
|
||||
)
|
||||
if q.strip():
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
ClientServiceTaskInstance.task_name.ilike(like),
|
||||
ClientServiceTaskInstance.description.ilike(like),
|
||||
ClientServiceTaskInstance.client.has(or_(Client.client_name.ilike(like), Client.client_code.ilike(like))),
|
||||
ClientServiceTaskInstance.catalogue.has(or_(ServiceCatalogue.service_name.ilike(like), ServiceCatalogue.service_code.ilike(like))),
|
||||
ClientServiceTaskInstance.client.has(
|
||||
or_(Client.client_name.ilike(like), Client.client_code.ilike(like))
|
||||
),
|
||||
ClientServiceTaskInstance.catalogue.has(
|
||||
or_(
|
||||
ServiceCatalogue.service_name.ilike(like),
|
||||
ServiceCatalogue.service_code.ilike(like),
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
tasks = db.execute(
|
||||
stmt.order_by(
|
||||
ClientServiceTaskInstance.subscription_id.asc(),
|
||||
ClientServiceTaskInstance.sequence_no.asc(),
|
||||
ClientServiceTaskInstance.internal_target_date.is_(None),
|
||||
ClientServiceTaskInstance.internal_target_date.asc(),
|
||||
ClientServiceTaskInstance.priority.desc(),
|
||||
ClientServiceTaskInstance.sequence_no.asc(),
|
||||
ClientServiceTaskInstance.id.desc(),
|
||||
ClientServiceTaskInstance.id.asc(),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
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", "cards": []},
|
||||
{"code": "in_progress", "label": "In Progress", "cards": []},
|
||||
{"code": "blocked", "label": "Blocked", "cards": []},
|
||||
{"code": "completed", "label": "Completed", "cards": []},
|
||||
]
|
||||
column_lookup = {c["code"]: c for c in columns}
|
||||
card_lookup: dict[tuple[str, int], dict[str, Any]] = {}
|
||||
column_lookup = {column["code"]: column for column in columns}
|
||||
engagement_lookup: dict[int, dict[str, Any]] = {}
|
||||
|
||||
for task in tasks:
|
||||
_phase7i_task_card_enrich(task, today=today)
|
||||
status_code = (task.status or "pending").strip().lower()
|
||||
is_closed = status_code in CLOSED_TASK_STATUSES
|
||||
summary["completed" if is_closed else "open"] += 1
|
||||
if status_code in summary:
|
||||
summary[status_code] += 1
|
||||
if task.is_overdue:
|
||||
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"
|
||||
subscription = getattr(task, "subscription", None)
|
||||
engagement_id = getattr(subscription, "id", None) or getattr(task, "subscription_id", 0) or 0
|
||||
card_key = (column_code, engagement_id)
|
||||
if card_key not in card_lookup:
|
||||
engagement_id = (
|
||||
getattr(subscription, "id", None)
|
||||
or getattr(task, "subscription_id", 0)
|
||||
or 0
|
||||
)
|
||||
if engagement_id not in engagement_lookup:
|
||||
client = getattr(task, "client", None)
|
||||
card = {
|
||||
engagement_lookup[engagement_id] = {
|
||||
"engagement_id": engagement_id,
|
||||
"subscription": subscription,
|
||||
"label": _subscription_label(subscription, task),
|
||||
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
|
||||
"client_code": getattr(client, "client_code", None) or "",
|
||||
"service_name": getattr(getattr(subscription, "catalogue", None), "service_name", None) or getattr(getattr(task, "catalogue", None), "service_name", None) or "Service Engagement",
|
||||
"financial_year": getattr(subscription, "financial_year", None) or getattr(task, "financial_year", None) or "-",
|
||||
"due_date": getattr(subscription, "current_due_date", None) if subscription else getattr(task, "internal_target_date", None),
|
||||
"status": getattr(subscription, "status", None) or "active",
|
||||
"service_name": (
|
||||
getattr(getattr(subscription, "catalogue", None), "service_name", None)
|
||||
or getattr(getattr(task, "catalogue", None), "service_name", None)
|
||||
or "Service Engagement"
|
||||
),
|
||||
"financial_year": (
|
||||
getattr(subscription, "financial_year", None)
|
||||
or getattr(task, "financial_year", None)
|
||||
or "-"
|
||||
),
|
||||
"due_date": (
|
||||
getattr(subscription, "current_due_date", None)
|
||||
if subscription
|
||||
else getattr(task, "internal_target_date", None)
|
||||
),
|
||||
"task_count": 0,
|
||||
"open_count": 0,
|
||||
"completed_count": 0,
|
||||
"pending_count": 0,
|
||||
"in_progress_count": 0,
|
||||
"blocked_count": 0,
|
||||
"completed_count": 0,
|
||||
"overdue_count": 0,
|
||||
"due_today_count": 0,
|
||||
"latest_comment": None,
|
||||
"blocked_reason": None,
|
||||
"blocked_task_name": None,
|
||||
"next_task_name": None,
|
||||
"tasks": [],
|
||||
}
|
||||
card_lookup[card_key] = card
|
||||
column_lookup[column_code]["cards"].append(card)
|
||||
card = card_lookup[card_key]
|
||||
|
||||
card = engagement_lookup[engagement_id]
|
||||
task_status = (task.status or "pending").strip().lower()
|
||||
is_closed = task_status in CLOSED_TASK_STATUSES
|
||||
card["task_count"] += 1
|
||||
card["tasks"].append(task)
|
||||
if is_closed:
|
||||
card["completed_count"] += 1
|
||||
else:
|
||||
card["open_count"] += 1
|
||||
if status_code == "blocked":
|
||||
elif task_status == "blocked":
|
||||
card["blocked_count"] += 1
|
||||
if not card["blocked_reason"]:
|
||||
card["blocked_reason"] = (
|
||||
(getattr(task, "remarks", None) or "").strip()
|
||||
or (
|
||||
getattr(task.latest_comment, "message", None)
|
||||
if task.latest_comment
|
||||
else None
|
||||
)
|
||||
or "Work is awaiting a dependency or clarification."
|
||||
)
|
||||
card["blocked_task_name"] = task.task_name
|
||||
elif task_status == "in_progress":
|
||||
card["in_progress_count"] += 1
|
||||
else:
|
||||
card["pending_count"] += 1
|
||||
|
||||
if task.is_overdue:
|
||||
card["overdue_count"] += 1
|
||||
if task.is_due_today:
|
||||
card["due_today_count"] += 1
|
||||
if task.latest_comment and not card.get("latest_comment"):
|
||||
if task.latest_comment and not card["latest_comment"]:
|
||||
card["latest_comment"] = task.latest_comment
|
||||
if not is_closed and not card["next_task_name"]:
|
||||
card["next_task_name"] = task.task_name
|
||||
|
||||
return {"summary": summary, "columns": columns, "q": q, "status": status_filter, "today": today}
|
||||
summary = {
|
||||
"total": 0,
|
||||
"open": 0,
|
||||
"pending": 0,
|
||||
"in_progress": 0,
|
||||
"blocked": 0,
|
||||
"completed": 0,
|
||||
"overdue": 0,
|
||||
"due_today": 0,
|
||||
}
|
||||
|
||||
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
|
||||
card["open_count"] = max(total - completed, 0)
|
||||
|
||||
if total and completed == total:
|
||||
card_status = "completed"
|
||||
card["action_label"] = "View"
|
||||
elif card["blocked_count"]:
|
||||
card_status = "blocked"
|
||||
card["action_label"] = "Open / Follow Up"
|
||||
elif card["in_progress_count"] or completed:
|
||||
card_status = "in_progress"
|
||||
card["action_label"] = "Continue"
|
||||
else:
|
||||
card_status = "pending"
|
||||
card["action_label"] = "Start"
|
||||
|
||||
card["workflow_status"] = card_status
|
||||
card["is_overdue"] = bool(card["overdue_count"])
|
||||
card["is_due_today"] = bool(card["due_today_count"])
|
||||
|
||||
include = (
|
||||
status_filter == card_status
|
||||
or (status_filter == "open" and card_status != "completed")
|
||||
or (status_filter == "closed" and card_status == "completed")
|
||||
)
|
||||
if not include:
|
||||
continue
|
||||
|
||||
column_lookup[card_status]["cards"].append(card)
|
||||
summary["total"] += 1
|
||||
summary[card_status] += 1
|
||||
summary["completed" if card_status == "completed" else "open"] += 1
|
||||
if card["is_overdue"]:
|
||||
summary["overdue"] += 1
|
||||
if card["is_due_today"]:
|
||||
summary["due_today"] += 1
|
||||
|
||||
for column in columns:
|
||||
column["cards"].sort(
|
||||
key=lambda card: (
|
||||
card["due_date"] is None,
|
||||
card["due_date"] or date.max,
|
||||
card["client_name"].lower(),
|
||||
card["service_name"].lower(),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"columns": columns,
|
||||
"q": q,
|
||||
"status": status_filter,
|
||||
"today": today,
|
||||
}
|
||||
|
||||
|
||||
def start_employee_engagement_workflow(
|
||||
db: Session,
|
||||
scope: EmployeeScope,
|
||||
engagement_id: int,
|
||||
*,
|
||||
actor_user_id: int,
|
||||
financial_year: str | None = None,
|
||||
) -> ClientServiceTaskInstance:
|
||||
"""Start the first pending task assigned to the employee in an engagement.
|
||||
|
||||
Existing task status, lock, tenant, branch and financial-year controls are
|
||||
reused. No engagement or task records are duplicated.
|
||||
"""
|
||||
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(),
|
||||
ClientServiceTaskInstance.internal_target_date.is_(None),
|
||||
ClientServiceTaskInstance.internal_target_date.asc(),
|
||||
ClientServiceTaskInstance.id.asc(),
|
||||
)
|
||||
).scalars().all()
|
||||
if not tasks:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Engagement work not found or not assigned to you",
|
||||
)
|
||||
|
||||
already_started = next(
|
||||
(
|
||||
task
|
||||
for task in tasks
|
||||
if (task.status or "").strip().lower() == "in_progress"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if already_started:
|
||||
return already_started
|
||||
|
||||
first_pending = next(
|
||||
(
|
||||
task
|
||||
for task in tasks
|
||||
if (task.status or "pending").strip().lower() == "pending"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not first_pending:
|
||||
return tasks[0]
|
||||
|
||||
return update_own_service_task_status(
|
||||
db,
|
||||
scope,
|
||||
first_pending.id,
|
||||
status="in_progress",
|
||||
remarks=None,
|
||||
actor_user_id=actor_user_id,
|
||||
financial_year=financial_year,
|
||||
)
|
||||
|
||||
def list_employee_engagement_documents(db: Session, scope: EmployeeScope, engagement_id: int, *, financial_year: str | None = None) -> list[EngagementDocument]:
|
||||
stmt = (
|
||||
|
||||
Reference in New Issue
Block a user