350 lines
16 KiB
Python
350 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
from decimal import Decimal
|
|
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 Role, UserRole
|
|
from app.modules.core.tenancy.models import Branch, Tenant
|
|
from app.modules.employees.models import Employee
|
|
from app.modules.services.execution import CLOSED_TASK_STATUSES
|
|
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance, ServiceCatalogue
|
|
|
|
try:
|
|
from app.modules.billing.models import BillingInvoice
|
|
except Exception: # pragma: no cover
|
|
BillingInvoice = None
|
|
|
|
PARTNER_ROLES = {"Partner", "System Admin"}
|
|
|
|
|
|
def _count(db: Session, stmt) -> int:
|
|
return int(db.execute(stmt).scalar() or 0)
|
|
|
|
|
|
def _money(value: Any) -> Decimal:
|
|
return Decimal(str(value 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 can_access_partner_dashboard(db: Session, current_user) -> bool:
|
|
roles = set(get_user_role_names(db, current_user.id))
|
|
return bool(roles.intersection(PARTNER_ROLES))
|
|
|
|
|
|
def _active_scope(db: Session, request, current_user, roles: set[str]) -> dict[str, Any]:
|
|
tenant_id = request.session.get("active_tenant_id") or getattr(current_user, "tenant_id", None)
|
|
branch_id = request.session.get("active_branch_id") or getattr(current_user, "branch_id", None)
|
|
|
|
if "System Admin" not in roles:
|
|
tenant_id = getattr(current_user, "tenant_id", None)
|
|
|
|
# Partner operations are branch-centric. For normal partner users, do not allow
|
|
# accidental all-branch view unless the branch context is genuinely missing.
|
|
if "System Admin" not in roles:
|
|
branch_id = getattr(current_user, "branch_id", None) or branch_id
|
|
|
|
if branch_id in (None, "", 0, "0"):
|
|
branch_id = None
|
|
|
|
tenant = db.get(Tenant, int(tenant_id)) if tenant_id else None
|
|
branch = db.get(Branch, int(branch_id)) if branch_id else None
|
|
return {
|
|
"tenant_id": int(tenant_id) if tenant_id else None,
|
|
"branch_id": int(branch_id) if branch_id else None,
|
|
"tenant": tenant,
|
|
"branch": branch,
|
|
}
|
|
|
|
|
|
def _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 _task_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None = None):
|
|
if tenant_id:
|
|
stmt = stmt.where(ClientServiceTaskInstance.tenant_id == tenant_id)
|
|
stmt = stmt.where(ClientServiceTaskInstance.is_active.is_(True))
|
|
if branch_id is not None:
|
|
stmt = stmt.where(ClientServiceTaskInstance.branch_id == branch_id)
|
|
if fy:
|
|
stmt = stmt.where(ClientServiceTaskInstance.financial_year == fy)
|
|
if "Partner" in roles and "System Admin" not in roles and branch_id is None:
|
|
stmt = stmt.where(
|
|
ClientServiceTaskInstance.subscription.has(
|
|
or_(
|
|
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
|
|
ClientServiceSubscription.review_partner_user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
return stmt
|
|
|
|
|
|
def _subscription_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None = None):
|
|
if tenant_id:
|
|
stmt = stmt.where(ClientServiceSubscription.tenant_id == tenant_id)
|
|
stmt = stmt.where(ClientServiceSubscription.is_active.is_(True))
|
|
if branch_id is not None:
|
|
stmt = stmt.where(ClientServiceSubscription.branch_id == branch_id)
|
|
if fy:
|
|
stmt = stmt.where(ClientServiceSubscription.financial_year == fy)
|
|
if "Partner" in roles and "System Admin" not in roles and branch_id is None:
|
|
stmt = stmt.where(
|
|
or_(
|
|
ClientServiceSubscription.assigned_partner_user_id == current_user.id,
|
|
ClientServiceSubscription.review_partner_user_id == current_user.id,
|
|
)
|
|
)
|
|
return stmt
|
|
|
|
|
|
def _client_scope(stmt, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str]):
|
|
if tenant_id:
|
|
stmt = stmt.where(Client.tenant_id == tenant_id)
|
|
stmt = stmt.where(Client.is_active.is_(True))
|
|
if branch_id is not None:
|
|
stmt = stmt.where(Client.branch_id == branch_id)
|
|
elif "Partner" in roles and "System Admin" not in roles:
|
|
stmt = stmt.where(
|
|
or_(
|
|
Client.partner_id == current_user.id,
|
|
Client.default_review_partner_user_id == current_user.id,
|
|
)
|
|
)
|
|
return stmt
|
|
|
|
|
|
def _is_open_status(status: str | None) -> bool:
|
|
return (status or "pending").strip().lower() not in CLOSED_TASK_STATUSES
|
|
|
|
|
|
def _task_label(task: ClientServiceTaskInstance) -> dict[str, Any]:
|
|
client = getattr(task, "client", None)
|
|
catalogue = getattr(task, "catalogue", None)
|
|
assignee = getattr(task, "assigned_to", None)
|
|
status = (task.status or "pending").replace("_", " ").title()
|
|
return {
|
|
"id": task.id,
|
|
"subscription_id": task.subscription_id,
|
|
"client_name": getattr(client, "client_name", None) or "Unlinked Client",
|
|
"client_code": getattr(client, "client_code", None) or "",
|
|
"service_name": getattr(catalogue, "service_name", None) or "Service",
|
|
"task_name": task.task_name,
|
|
"period": task.financial_year or "-",
|
|
"due_date": task.internal_target_date,
|
|
"status": task.status or "pending",
|
|
"status_label": status,
|
|
"priority": task.priority or "normal",
|
|
"assigned_to": getattr(assignee, "full_name", None) or getattr(assignee, "email", None) or "Unassigned",
|
|
"is_overdue": bool(task.internal_target_date and task.internal_target_date < date.today() and _is_open_status(task.status)),
|
|
"href": f"/work/engagements/{task.subscription_id}" if task.subscription_id else "/partner/reviews",
|
|
}
|
|
|
|
|
|
def _load_tasks(db: Session, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str], fy: str | None) -> list[ClientServiceTaskInstance]:
|
|
stmt = (
|
|
select(ClientServiceTaskInstance)
|
|
.options(
|
|
selectinload(ClientServiceTaskInstance.client),
|
|
selectinload(ClientServiceTaskInstance.catalogue),
|
|
selectinload(ClientServiceTaskInstance.assigned_to),
|
|
selectinload(ClientServiceTaskInstance.subscription).selectinload(ClientServiceSubscription.catalogue),
|
|
)
|
|
)
|
|
stmt = _task_scope(stmt, tenant_id, branch_id, current_user, roles, fy)
|
|
stmt = stmt.order_by(
|
|
ClientServiceTaskInstance.internal_target_date.is_(None),
|
|
ClientServiceTaskInstance.internal_target_date.asc(),
|
|
ClientServiceTaskInstance.priority.desc(),
|
|
ClientServiceTaskInstance.id.desc(),
|
|
).limit(300)
|
|
return list(db.execute(stmt).scalars().all())
|
|
|
|
|
|
def _load_clients(db: Session, tenant_id: int | None, branch_id: int | None, current_user, roles: set[str]) -> list[dict[str, Any]]:
|
|
stmt = _client_scope(select(Client), tenant_id, branch_id, current_user, roles)
|
|
clients = db.execute(stmt.order_by(Client.client_name.asc()).limit(100)).scalars().all()
|
|
out: list[dict[str, Any]] = []
|
|
for client in clients:
|
|
task_count = _count(db, _task_scope(select(func.count(ClientServiceTaskInstance.id)), tenant_id, branch_id, current_user, roles).where(ClientServiceTaskInstance.client_id == client.id))
|
|
overdue_count = _count(
|
|
db,
|
|
_task_scope(select(func.count(ClientServiceTaskInstance.id)), tenant_id, branch_id, current_user, roles)
|
|
.where(ClientServiceTaskInstance.client_id == client.id, ClientServiceTaskInstance.internal_target_date < date.today(), ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES))),
|
|
)
|
|
service_count = _count(db, _subscription_scope(select(func.count(ClientServiceSubscription.id)), tenant_id, branch_id, current_user, roles).where(ClientServiceSubscription.client_id == client.id))
|
|
out.append({
|
|
"id": client.id,
|
|
"client_name": client.client_name,
|
|
"client_code": client.client_code,
|
|
"pan": client.pan,
|
|
"gstin": client.gstin,
|
|
"email": client.email,
|
|
"mobile": client.mobile,
|
|
"service_count": service_count,
|
|
"task_count": task_count,
|
|
"overdue_count": overdue_count,
|
|
"href": f"/clients/{client.id}/edit",
|
|
})
|
|
return out
|
|
|
|
|
|
def _staff_rows(db: Session, tenant_id: int | None, branch_id: int | None, tasks: list[ClientServiceTaskInstance]) -> list[dict[str, Any]]:
|
|
if not tenant_id:
|
|
return []
|
|
stmt = select(User).where(User.tenant_id == tenant_id, User.is_active.is_(True))
|
|
if branch_id is not None:
|
|
stmt = stmt.where(User.branch_id == branch_id)
|
|
users = db.execute(stmt.order_by(User.full_name.asc(), User.email.asc()).limit(100)).scalars().all()
|
|
by_user: dict[int, dict[str, int]] = {}
|
|
for task in tasks:
|
|
uid = getattr(task, "assigned_to_user_id", None)
|
|
if not uid:
|
|
continue
|
|
bucket = by_user.setdefault(int(uid), {"active": 0, "overdue": 0, "review": 0, "client_pending": 0})
|
|
if _is_open_status(task.status):
|
|
bucket["active"] += 1
|
|
if task.internal_target_date and task.internal_target_date < date.today() and _is_open_status(task.status):
|
|
bucket["overdue"] += 1
|
|
if (task.status or "").lower() == "completed":
|
|
bucket["review"] += 1
|
|
if (task.status or "").lower() == "blocked":
|
|
bucket["client_pending"] += 1
|
|
rows: list[dict[str, Any]] = []
|
|
for user in users:
|
|
stats = by_user.get(int(user.id), {"active": 0, "overdue": 0, "review": 0, "client_pending": 0})
|
|
total = stats["active"] + stats["review"]
|
|
rows.append({
|
|
"id": user.id,
|
|
"name": user.full_name or user.email,
|
|
"email": user.email,
|
|
"designation": user.designation,
|
|
"active": stats["active"],
|
|
"overdue": stats["overdue"],
|
|
"review": stats["review"],
|
|
"client_pending": stats["client_pending"],
|
|
"load_status": "Heavy" if total >= 40 else ("Balanced" if total >= 10 else "Light"),
|
|
})
|
|
rows.sort(key=lambda r: (r["active"] + r["review"], r["overdue"]), reverse=True)
|
|
return rows[:25]
|
|
|
|
|
|
def _billing_rows(db: Session, tenant_id: int | None, branch_id: int | None, fy: str | None) -> dict[str, Any]:
|
|
if BillingInvoice is None or not tenant_id:
|
|
return {"available": False, "invoices": [], "invoice_count": 0, "draft_count": 0, "outstanding": Decimal("0")}
|
|
stmt = select(BillingInvoice).where(BillingInvoice.tenant_id == tenant_id)
|
|
if branch_id is not None:
|
|
stmt = stmt.where(BillingInvoice.branch_id == branch_id)
|
|
if fy:
|
|
stmt = stmt.where(BillingInvoice.financial_year == fy)
|
|
invoices = list(db.execute(stmt.order_by(BillingInvoice.invoice_date.desc(), BillingInvoice.id.desc()).limit(25)).scalars().all())
|
|
outstanding = sum((_money(getattr(inv, "balance_amount", 0)) for inv in invoices), Decimal("0"))
|
|
return {
|
|
"available": True,
|
|
"invoices": invoices,
|
|
"invoice_count": len(invoices),
|
|
"draft_count": len([i for i in invoices if (getattr(i, "status", "") or "").upper() == "DRAFT"]),
|
|
"outstanding": outstanding,
|
|
}
|
|
|
|
|
|
def build_partner_dashboard_payload(db: Session, request, current_user) -> dict[str, Any]:
|
|
roles = set(get_user_role_names(db, current_user.id))
|
|
scope = _active_scope(db, request, current_user, roles)
|
|
tenant_id = scope["tenant_id"]
|
|
branch_id = scope["branch_id"]
|
|
fy = _financial_year(request)
|
|
today = date.today()
|
|
next_week = today + timedelta(days=7)
|
|
|
|
tasks = _load_tasks(db, tenant_id, branch_id, current_user, roles, fy)
|
|
task_rows = [_task_label(t) for t in tasks]
|
|
open_rows = [r for r in task_rows if r["status"] not in CLOSED_TASK_STATUSES]
|
|
overdue_rows = [r for r in open_rows if r["due_date"] and r["due_date"] < today]
|
|
due_today_rows = [r for r in open_rows if r["due_date"] == today]
|
|
due_week_rows = [r for r in open_rows if r["due_date"] and today <= r["due_date"] <= next_week]
|
|
client_pending_rows = [r for r in open_rows if r["status"] == "blocked"]
|
|
review_rows = [r for r in task_rows if r["status"] == "completed"]
|
|
|
|
clients = _load_clients(db, tenant_id, branch_id, current_user, roles)
|
|
staff = _staff_rows(db, tenant_id, branch_id, tasks)
|
|
billing = _billing_rows(db, tenant_id, branch_id, fy)
|
|
|
|
subscriptions_count = _count(db, _subscription_scope(select(func.count(ClientServiceSubscription.id)), tenant_id, branch_id, current_user, roles, fy)) if tenant_id else 0
|
|
|
|
overview = {
|
|
"tenant": scope["tenant"],
|
|
"branch": scope["branch"],
|
|
"tenant_id": tenant_id,
|
|
"branch_id": branch_id,
|
|
"financial_year": fy,
|
|
"client_count": len(clients),
|
|
"subscription_count": subscriptions_count,
|
|
"open_task_count": len(open_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),
|
|
"invoice_count": billing["invoice_count"],
|
|
"outstanding": billing["outstanding"],
|
|
"today": today,
|
|
}
|
|
|
|
return {
|
|
"roles": sorted(roles),
|
|
"overview": overview,
|
|
"branch_work": task_rows[:80],
|
|
"due_today": due_today_rows[:25],
|
|
"due_week": due_week_rows[:25],
|
|
"overdue": overdue_rows[:25],
|
|
"client_pending": client_pending_rows[:25],
|
|
"review_queue": review_rows[:50],
|
|
"clients": clients,
|
|
"staff_rows": staff,
|
|
"billing": billing,
|
|
"reports": _report_cards(),
|
|
"wizards": _wizard_cards(),
|
|
}
|
|
|
|
|
|
def _report_cards() -> list[dict[str, str]]:
|
|
return [
|
|
{"group": "Work Reports", "title": "Branch Work Status", "desc": "Due today, due this week, overdue and blocked work for the active branch.", "href": "/partner/dashboard?tab=branch-work"},
|
|
{"group": "Work Reports", "title": "Overdue Task Report", "desc": "Tasks past internal target date requiring partner escalation.", "href": "/partner/dashboard?tab=branch-work"},
|
|
{"group": "Review Reports", "title": "Partner Review Queue", "desc": "Completed work waiting for partner review or final sign-off.", "href": "/partner/dashboard?tab=review"},
|
|
{"group": "Client Reports", "title": "Branch Client Health", "desc": "Clients, services, open tasks and overdue items in the branch.", "href": "/partner/dashboard?tab=clients"},
|
|
{"group": "Staff Reports", "title": "Staff Workload", "desc": "Branch team workload, overdue count and client pending count.", "href": "/partner/dashboard?tab=staff"},
|
|
{"group": "Billing Reports", "title": "Billing Control", "desc": "Invoices raised, drafts and outstanding amount for the active branch.", "href": "/partner/dashboard?tab=billing"},
|
|
]
|
|
|
|
|
|
def _wizard_cards() -> list[dict[str, str]]:
|
|
return [
|
|
{"title": "Add / Manage Clients", "desc": "Open client master for branch client creation and edits.", "href": "/clients"},
|
|
{"title": "Client Service Subscriptions", "desc": "Assign firm services to branch clients and monitor engagements.", "href": "/services/engagements"},
|
|
{"title": "Generate / Track Tasks", "desc": "Use work tracker for generated compliance and audit tasks.", "href": "/work"},
|
|
{"title": "Partner Review Board", "desc": "Review completed work and send rework or clarifications.", "href": "/partner/reviews"},
|
|
{"title": "Documents", "desc": "Open branch and engagement documents.", "href": "/documents"},
|
|
{"title": "Billing", "desc": "Raise invoices and track collection follow-up.", "href": "/billing"},
|
|
]
|