Add engagement-level employee workflow board phase 1

This commit is contained in:
A R R R Associates
2026-07-19 23:36:38 +05:30
parent df52845194
commit f25f67389e
4 changed files with 317 additions and 74 deletions
+216 -51
View File
@@ -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 = (