374 lines
18 KiB
Python
374 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.orm import Session, selectinload
|
|
|
|
from app.modules.clients.models import Client
|
|
from app.modules.core.iam.models import User
|
|
from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole
|
|
from app.modules.employees.service import build_employee_scope, list_employee_work_assignable_users
|
|
from app.modules.services.execution import CLOSED_TASK_STATUSES
|
|
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue, ServiceTaskComment
|
|
|
|
MANAGER_ROLES = {"Manager", "Branch Manager", "System Admin", "Firm Admin", "Partner"}
|
|
MANAGER_PERMISSIONS = {"employees.work.manage", "services.tasks.review", "services.tasks.assign"}
|
|
REVIEW_STATUSES = {"completed", "ready_review", "review_pending", "pending_review", "manager_review"}
|
|
CLIENT_PENDING_STATUSES = {"blocked", "client_pending", "waiting_client", "documents_pending"}
|
|
|
|
|
|
def _count(db: Session, stmt) -> int:
|
|
return int(db.execute(stmt).scalar() or 0)
|
|
|
|
|
|
def get_user_role_names(db: Session, user_id: int) -> list[str]:
|
|
return list(
|
|
db.execute(
|
|
select(Role.name)
|
|
.join(UserRole, UserRole.role_id == Role.id)
|
|
.where(UserRole.user_id == int(user_id), Role.is_active.is_(True))
|
|
.order_by(Role.name.asc())
|
|
).scalars().all()
|
|
)
|
|
|
|
|
|
def get_user_permission_names(db: Session, user_id: int) -> set[str]:
|
|
"""Return active permission codes granted through the user's active roles.
|
|
|
|
The ERP stores permissions in the normalized RolePermission mapping table;
|
|
Role itself intentionally has no ``permissions`` column.
|
|
"""
|
|
rows = db.execute(
|
|
select(Permission.code)
|
|
.join(RolePermission, RolePermission.permission_id == Permission.id)
|
|
.join(Role, Role.id == RolePermission.role_id)
|
|
.join(UserRole, UserRole.role_id == Role.id)
|
|
.where(
|
|
UserRole.user_id == int(user_id),
|
|
Role.is_active.is_(True),
|
|
Permission.is_active.is_(True),
|
|
)
|
|
.distinct()
|
|
).scalars().all()
|
|
return {str(code).strip() for code in rows if code and str(code).strip()}
|
|
|
|
|
|
def can_access_manager_dashboard(db: Session, current_user) -> bool:
|
|
roles = set(get_user_role_names(db, current_user.id))
|
|
if roles.intersection(MANAGER_ROLES):
|
|
return True
|
|
return bool(get_user_permission_names(db, current_user.id).intersection(MANAGER_PERMISSIONS))
|
|
|
|
|
|
def _active_financial_year(request) -> str | None:
|
|
value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
|
value = (value or "").strip()
|
|
return value or None
|
|
|
|
|
|
def _scope(db: Session, request, current_user):
|
|
return build_employee_scope(
|
|
db,
|
|
current_user,
|
|
tenant_id=request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None),
|
|
branch_id=request.session.get("active_branch_id"),
|
|
)
|
|
|
|
|
|
def _task_scope(stmt, scope, fy: str | None = None):
|
|
stmt = stmt.where(
|
|
ClientServiceTaskInstance.tenant_id == scope.tenant_id,
|
|
ClientServiceTaskInstance.is_active.is_(True),
|
|
)
|
|
if getattr(scope, "branch_id", None) is not None:
|
|
stmt = stmt.where(ClientServiceTaskInstance.branch_id == scope.branch_id)
|
|
if fy:
|
|
stmt = stmt.where(ClientServiceTaskInstance.financial_year == fy)
|
|
return stmt
|
|
|
|
|
|
def _client_scope(stmt, scope):
|
|
stmt = stmt.where(Client.tenant_id == scope.tenant_id, Client.is_active.is_(True))
|
|
if getattr(scope, "branch_id", None) is not None:
|
|
stmt = stmt.where(Client.branch_id == scope.branch_id)
|
|
return stmt
|
|
|
|
|
|
def _is_open(status: str | None) -> bool:
|
|
return (status or "pending").strip().lower() not in CLOSED_TASK_STATUSES
|
|
|
|
|
|
def _status_label(status: str | None) -> str:
|
|
return (status or "pending").replace("_", " ").replace("-", " ").title()
|
|
|
|
|
|
|
|
|
|
def _days_overdue(due_date) -> int:
|
|
if not due_date:
|
|
return 0
|
|
delta = (date.today() - due_date).days
|
|
return delta if delta > 0 else 0
|
|
|
|
|
|
def _age_bucket(days: int) -> str:
|
|
if days >= 30:
|
|
return "30+ days"
|
|
if days >= 15:
|
|
return "15-29 days"
|
|
if days >= 8:
|
|
return "8-14 days"
|
|
if days >= 4:
|
|
return "4-7 days"
|
|
if days >= 1:
|
|
return "1-3 days"
|
|
return "Current"
|
|
|
|
def _task_href(task: ClientServiceTaskInstance) -> str:
|
|
task_id = getattr(task, "id", None)
|
|
if task_id:
|
|
return f"/employees/work/tasks/{task_id}/communication"
|
|
return "/manager/work"
|
|
|
|
|
|
def _task_row(task: ClientServiceTaskInstance) -> dict[str, Any]:
|
|
today = date.today()
|
|
client = getattr(task, "client", None)
|
|
catalogue = getattr(task, "catalogue", None)
|
|
subscription = getattr(task, "subscription", None)
|
|
assignee = getattr(task, "assigned_to", None)
|
|
status = (getattr(task, "status", None) or "pending").strip().lower()
|
|
due_date = getattr(task, "internal_target_date", None)
|
|
service_name = getattr(catalogue, "service_name", None) or getattr(catalogue, "name", None)
|
|
if not service_name and subscription is not None:
|
|
sub_catalogue = getattr(subscription, "catalogue", None)
|
|
service_name = getattr(sub_catalogue, "service_name", None) or getattr(sub_catalogue, "name", None)
|
|
return {
|
|
"id": getattr(task, "id", None),
|
|
"subscription_id": getattr(task, "subscription_id", None),
|
|
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
|
|
"client_code": getattr(client, "client_code", None) or "",
|
|
"service_name": service_name or "Service",
|
|
"task_name": getattr(task, "task_name", None) or "Task",
|
|
"period": getattr(task, "financial_year", None) or "-",
|
|
"due_date": due_date,
|
|
"status": status,
|
|
"status_label": _status_label(status),
|
|
"priority": getattr(task, "priority", None) or "normal",
|
|
"assigned_to": getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned",
|
|
"comment_count": len([c for c in getattr(task, "comments", []) if not getattr(c, "is_deleted", False)]),
|
|
"is_overdue": bool(due_date and due_date < today and _is_open(status)),
|
|
"is_due_today": bool(due_date and due_date == today and _is_open(status)),
|
|
"days_overdue": _days_overdue(due_date) if _is_open(status) else 0,
|
|
"age_bucket": _age_bucket(_days_overdue(due_date)) if _is_open(status) else "Closed",
|
|
"href": _task_href(task),
|
|
}
|
|
|
|
|
|
def _load_tasks(db: Session, scope, fy: str | None) -> list[ClientServiceTaskInstance]:
|
|
stmt = (
|
|
select(ClientServiceTaskInstance)
|
|
.options(
|
|
selectinload(ClientServiceTaskInstance.client),
|
|
selectinload(ClientServiceTaskInstance.catalogue),
|
|
selectinload(ClientServiceTaskInstance.assigned_to),
|
|
selectinload(ClientServiceTaskInstance.comments).selectinload(ServiceTaskComment.created_by),
|
|
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
|
|
)
|
|
)
|
|
stmt = _task_scope(stmt, scope, fy)
|
|
stmt = stmt.order_by(
|
|
ClientServiceTaskInstance.internal_target_date.is_(None),
|
|
ClientServiceTaskInstance.internal_target_date.asc(),
|
|
ClientServiceTaskInstance.priority.desc(),
|
|
ClientServiceTaskInstance.id.desc(),
|
|
).limit(350)
|
|
return list(db.execute(stmt).scalars().all())
|
|
|
|
|
|
def _staff_rows(db: Session, scope, task_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
try:
|
|
users = list_employee_work_assignable_users(db, scope)
|
|
except Exception:
|
|
stmt = select(User).where(User.tenant_id == scope.tenant_id, User.is_active.is_(True))
|
|
if getattr(scope, "branch_id", None) is not None:
|
|
stmt = stmt.where(User.branch_id == scope.branch_id)
|
|
users = list(db.execute(stmt.order_by(User.full_name.asc(), User.email.asc()).limit(100)).scalars().all())
|
|
|
|
stats_by_name: dict[str, dict[str, int]] = {}
|
|
for row in task_rows:
|
|
name = row.get("assigned_to") or "Unassigned"
|
|
bucket = stats_by_name.setdefault(name, {"active": 0, "overdue": 0, "review": 0, "client_pending": 0, "due_today": 0})
|
|
if _is_open(row.get("status")):
|
|
bucket["active"] += 1
|
|
if row.get("is_overdue"):
|
|
bucket["overdue"] += 1
|
|
if row.get("is_due_today"):
|
|
bucket["due_today"] += 1
|
|
if row.get("status") in REVIEW_STATUSES:
|
|
bucket["review"] += 1
|
|
if row.get("status") in CLIENT_PENDING_STATUSES:
|
|
bucket["client_pending"] += 1
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
seen = set()
|
|
for user in users:
|
|
name = getattr(user, "full_name", None) or getattr(user, "email", None) or "User"
|
|
seen.add(name)
|
|
stats = stats_by_name.get(name, {"active": 0, "overdue": 0, "review": 0, "client_pending": 0, "due_today": 0})
|
|
total = stats["active"] + stats["review"]
|
|
rows.append({
|
|
"name": name,
|
|
"email": getattr(user, "email", None) or "",
|
|
"designation": getattr(user, "designation", None) or "",
|
|
**stats,
|
|
"load_status": "Heavy" if total >= 40 else ("Balanced" if total >= 10 else "Light"),
|
|
})
|
|
if "Unassigned" in stats_by_name and "Unassigned" not in seen:
|
|
stats = stats_by_name["Unassigned"]
|
|
rows.append({"name": "Unassigned", "email": "", "designation": "Allocation pending", **stats, "load_status": "Needs allocation"})
|
|
rows.sort(key=lambda r: (r["active"] + r["review"], r["overdue"], r["due_today"]), reverse=True)
|
|
return rows[:30]
|
|
|
|
|
|
def _client_rows(db: Session, scope, task_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
clients = db.execute(_client_scope(select(Client), scope).order_by(Client.client_name.asc()).limit(100)).scalars().all()
|
|
by_client: dict[str, dict[str, int]] = {}
|
|
for row in task_rows:
|
|
key = row["client_name"]
|
|
bucket = by_client.setdefault(key, {"open": 0, "overdue": 0, "client_pending": 0, "review": 0})
|
|
if _is_open(row.get("status")):
|
|
bucket["open"] += 1
|
|
if row.get("is_overdue"):
|
|
bucket["overdue"] += 1
|
|
if row.get("status") in CLIENT_PENDING_STATUSES:
|
|
bucket["client_pending"] += 1
|
|
if row.get("status") in REVIEW_STATUSES:
|
|
bucket["review"] += 1
|
|
out: list[dict[str, Any]] = []
|
|
for client in clients:
|
|
name = getattr(client, "client_name", None) or "Client"
|
|
stats = by_client.get(name, {"open": 0, "overdue": 0, "client_pending": 0, "review": 0})
|
|
out.append({
|
|
"id": getattr(client, "id", None),
|
|
"client_name": name,
|
|
"client_code": getattr(client, "client_code", None) or "",
|
|
"pan": getattr(client, "pan", None) or "",
|
|
"gstin": getattr(client, "gstin", None) or "",
|
|
**stats,
|
|
"href": f"/clients/{getattr(client, 'id', '')}/edit" if getattr(client, "id", None) else "/clients",
|
|
})
|
|
return out[:50]
|
|
|
|
|
|
def _report_cards() -> list[dict[str, str]]:
|
|
return [
|
|
{"group": "Execution", "title": "Team Work Status", "desc": "Open, overdue, in-progress and blocked assignments for the active branch/team.", "href": "/manager/dashboard?tab=team-work"},
|
|
{"group": "Due Control", "title": "Due Calendar", "desc": "Due today, due this week and overdue work in one control view.", "href": "/manager/dashboard?tab=due-calendar"},
|
|
{"group": "Review", "title": "Manager Review Queue", "desc": "Completed or review-ready work waiting for manager action.", "href": "/manager/dashboard?tab=review-queue"},
|
|
{"group": "Client Follow-up", "title": "Client Pending Report", "desc": "Tasks blocked due to documents, clarification or client data pending.", "href": "/manager/dashboard?tab=client-pending"},
|
|
{"group": "Escalation", "title": "Escalation Register", "desc": "Unassigned, overdue and aged client-pending items requiring manager intervention.", "href": "/manager/dashboard?tab=escalations"},
|
|
{"group": "Team", "title": "Staff Workload Report", "desc": "Staff-wise active, overdue, review and client-pending workload.", "href": "/manager/dashboard?tab=team-work"},
|
|
{"group": "Documents", "title": "Document Checklist Report", "desc": "Document-related blocked tasks and communication timeline shortcuts.", "href": "/manager/dashboard?tab=documents"},
|
|
]
|
|
|
|
|
|
def _wizard_cards() -> list[dict[str, str]]:
|
|
return [
|
|
{"title": "Task Assignment Wizard", "desc": "Allocate unassigned tasks and rebalance heavy workload using the existing manager work board.", "href": "/manager/work"},
|
|
{"title": "Due Control Wizard", "desc": "Review due today, due this week and overdue items before escalation.", "href": "/manager/dashboard?tab=due-calendar"},
|
|
{"title": "Review Wizard", "desc": "Open review-ready tasks and proceed through the existing task communication timeline.", "href": "/manager/dashboard?tab=review-queue"},
|
|
{"title": "Client Query Wizard", "desc": "Handle client-pending items and record clarification/document requirements.", "href": "/manager/dashboard?tab=client-pending"},
|
|
{"title": "Document Checklist Wizard", "desc": "Verify document-related blocked tasks and open the existing documents area.", "href": "/documents"},
|
|
{"title": "Escalation Wizard", "desc": "Review aged overdue, unassigned and client-pending items requiring partner/branch attention.", "href": "/manager/dashboard?tab=escalations"},
|
|
{"title": "Work Closure Wizard", "desc": "Open engagement progress and close work after review completion.", "href": "/employees/progress"},
|
|
{"title": "Alert / Follow-up Wizard", "desc": "Open alerts for reminders, escalations and follow-up actions.", "href": "/alerts"},
|
|
]
|
|
|
|
|
|
def build_manager_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]:
|
|
scope = _scope(db, request, current_user)
|
|
fy = _active_financial_year(request)
|
|
today = date.today()
|
|
next_week = today + timedelta(days=7)
|
|
tasks = _load_tasks(db, scope, fy)
|
|
task_rows = [_task_row(t) for t in tasks]
|
|
open_rows = [r for r in task_rows if _is_open(r["status"])]
|
|
overdue_rows = [r for r in open_rows if r["is_overdue"]]
|
|
due_today_rows = [r for r in open_rows if r["is_due_today"]]
|
|
due_week_rows = [r for r in open_rows if r["due_date"] and today <= r["due_date"] <= next_week]
|
|
review_rows = [r for r in task_rows if r["status"] in REVIEW_STATUSES]
|
|
client_pending_rows = [r for r in open_rows if r["status"] in CLIENT_PENDING_STATUSES]
|
|
unassigned_rows = [r for r in open_rows if (r.get("assigned_to") or "Unassigned") == "Unassigned"]
|
|
in_progress_rows = [r for r in open_rows if r["status"] == "in_progress"]
|
|
|
|
staff_rows = _staff_rows(db, scope, task_rows)
|
|
client_rows = _client_rows(db, scope, task_rows)
|
|
|
|
tenant = getattr(scope, "tenant", None)
|
|
branch = getattr(scope, "branch", None)
|
|
age_buckets = [
|
|
{"label": "1-3 days", "count": len([r for r in overdue_rows if r.get("age_bucket") == "1-3 days"])},
|
|
{"label": "4-7 days", "count": len([r for r in overdue_rows if r.get("age_bucket") == "4-7 days"])},
|
|
{"label": "8-14 days", "count": len([r for r in overdue_rows if r.get("age_bucket") == "8-14 days"])},
|
|
{"label": "15-29 days", "count": len([r for r in overdue_rows if r.get("age_bucket") == "15-29 days"])},
|
|
{"label": "30+ days", "count": len([r for r in overdue_rows if r.get("age_bucket") == "30+ days"])},
|
|
]
|
|
|
|
status_summary: dict[str, int] = {}
|
|
service_summary: dict[str, int] = {}
|
|
for row in open_rows:
|
|
status_summary[row.get("status_label") or "Pending"] = status_summary.get(row.get("status_label") or "Pending", 0) + 1
|
|
service_summary[row.get("service_name") or "Service"] = service_summary.get(row.get("service_name") or "Service", 0) + 1
|
|
|
|
escalation_rows = sorted(
|
|
(unassigned_rows + overdue_rows + client_pending_rows),
|
|
key=lambda r: (r.get("days_overdue") or 0, r.get("is_overdue") or False, r.get("due_date") or date.max),
|
|
reverse=True,
|
|
)
|
|
|
|
overview = {
|
|
"tenant": tenant,
|
|
"branch": branch,
|
|
"tenant_id": getattr(scope, "tenant_id", None),
|
|
"branch_id": getattr(scope, "branch_id", None),
|
|
"financial_year": fy,
|
|
"total_tasks": len(task_rows),
|
|
"open_task_count": len(open_rows),
|
|
"unassigned_count": len(unassigned_rows),
|
|
"in_progress_count": len(in_progress_rows),
|
|
"overdue_count": len(overdue_rows),
|
|
"due_today_count": len(due_today_rows),
|
|
"due_week_count": len(due_week_rows),
|
|
"client_pending_count": len(client_pending_rows),
|
|
"review_pending_count": len(review_rows),
|
|
"staff_count": len(staff_rows),
|
|
"client_count": len(client_rows),
|
|
"escalation_count": len(escalation_rows),
|
|
"today": today,
|
|
}
|
|
|
|
return {
|
|
"roles": sorted(get_user_role_names(db, current_user.id)),
|
|
"overview": overview,
|
|
"team_work": task_rows[:100],
|
|
"due_today": due_today_rows[:30],
|
|
"due_week": due_week_rows[:30],
|
|
"overdue": overdue_rows[:30],
|
|
"unassigned": unassigned_rows[:30],
|
|
"in_progress": in_progress_rows[:30],
|
|
"review_queue": review_rows[:60],
|
|
"client_pending": client_pending_rows[:60],
|
|
"documents_pending": client_pending_rows[:40],
|
|
"escalations": escalation_rows[:80],
|
|
"age_buckets": age_buckets,
|
|
"status_summary": sorted([{"label": k, "count": v} for k, v in status_summary.items()], key=lambda x: x["count"], reverse=True)[:10],
|
|
"service_summary": sorted([{"label": k, "count": v} for k, v in service_summary.items()], key=lambda x: x["count"], reverse=True)[:10],
|
|
"staff_rows": staff_rows,
|
|
"clients": client_rows,
|
|
"reports": _report_cards(),
|
|
"wizards": _wizard_cards(),
|
|
}
|