Files
arrr-erp/app/modules/consultants/service.py
T
2026-07-31 12:22:24 +05:30

1579 lines
63 KiB
Python

from __future__ import annotations
from datetime import date, datetime, timezone, timedelta
import secrets
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.modules.clients.models import Client, ClientAuditLog
from app.modules.consultants.models import ClientConsultantLink, ConsultantManagedClient, ConsultantProfile, ConsultantWorkspace, ConsultantServiceRequest
from app.core.security.passwords import hash_password
from app.modules.core.iam.invite_service import issue_invite_token
from app.modules.core.iam.models import User
from app.modules.core.tenancy.models import Branch
from app.modules.core.rbac.models import Role, UserRole
from app.modules.services.models import (
ClientServiceSubscription,
ClientServiceTaskInstance,
ServiceCatalogue,
ServiceTaskComment,
)
CONSULTANT_TYPES = [
("external_consultant", "External Consultant"),
("gst_consultant", "GST Consultant"),
("tax_consultant", "Tax Consultant"),
("roc_consultant", "ROC Consultant"),
("payroll_consultant", "Payroll Consultant"),
("franchise_partner", "Franchise Partner"),
("saas_customer", "SaaS Customer"),
]
CONSULTANT_RELATIONSHIP_TYPES = [
("accounts_consultant", "Accounts Consultant"),
("gst_consultant", "GST Consultant"),
("tax_consultant", "Tax Consultant"),
("roc_consultant", "ROC Consultant"),
("payroll_consultant", "Payroll Consultant"),
("audit_coordination", "Audit Coordination"),
("other", "Other"),
]
CONSULTANT_MANAGED_CLIENT_STATUSES = [
("active", "Active"),
("prospect", "Prospect"),
("on_hold", "On Hold"),
("closed", "Closed"),
]
CONSULTANT_MANAGED_CLIENT_STAGES = [
("managed", "Managed Client"),
("lead", "Lead / Prospect"),
("referred_to_firm", "Referred to Audit Firm"),
("linked_to_firm", "Linked to Firm Client"),
]
CONSULTANT_CLIENT_CONVERSION_STATUSES = [
("not_requested", "Not Requested"),
("requested", "Requested"),
("under_review", "Under Review"),
("approved", "Approved / Converted"),
("rejected", "Rejected"),
]
CONSULTANT_WORKSPACE_TYPES = [
("consultant_saas", "Consultant SaaS Workspace"),
("franchise_partner", "Franchise / Referral Partner"),
("platform_partner", "Platform Ecosystem Partner"),
]
CONSULTANT_WORKSPACE_PLANS = [
("starter", "Starter"),
("professional", "Professional"),
("business", "Business"),
("franchise", "Franchise"),
]
CONSULTANT_SUBSCRIPTION_STATUSES = [
("trial", "Trial"),
("active", "Active"),
("suspended", "Suspended"),
("cancelled", "Cancelled"),
]
CONSULTANT_BILLING_CYCLES = [
("manual", "Manual / Not Billed"),
("monthly", "Monthly"),
("quarterly", "Quarterly"),
("yearly", "Yearly"),
]
CONSULTANT_ONBOARDING_STATUSES = [
("draft", "Draft"),
("invited", "Invited"),
("active", "Active"),
("approved", "Approved"),
("suspended", "Suspended"),
("inactive", "Inactive"),
]
def normalise_text(value: str | None) -> str | None:
text = (value or "").strip()
return text or None
def _first_branch_id_for_tenant(db: Session, tenant_id: int) -> int | None:
return db.execute(
select(Branch.id).where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True)).order_by(Branch.id.asc())
).scalar_one_or_none()
def _consultant_role(db: Session) -> Role | None:
return db.execute(select(Role).where(Role.name == "Consultant", Role.is_active.is_(True))).scalar_one_or_none()
def _user_has_role(db: Session, *, user_id: int, role_id: int) -> bool:
return db.execute(
select(UserRole.id).where(UserRole.user_id == user_id, UserRole.role_id == role_id)
).scalar_one_or_none() is not None
def ensure_consultant_login_user(
db: Session,
*,
tenant_id: int,
branch_id: int | None,
email: str,
full_name: str,
password: str | None,
invite_user: bool,
) -> tuple[User, str | None]:
"""Create or update a portal login for a consultant and assign Consultant role.
Returns (user, invite_url). Existing users are not overwritten except that the
Consultant role is added if missing and basic login flags are enabled.
"""
login_email = (email or "").strip().lower()
if not login_email:
raise ValueError("Login email is required to create consultant login.")
resolved_branch_id = branch_id or _first_branch_id_for_tenant(db, tenant_id)
if not resolved_branch_id:
raise ValueError("A branch is required to create consultant login user.")
user = db.execute(select(User).where(User.email == login_email)).scalar_one_or_none()
if user and int(user.tenant_id) != int(tenant_id):
raise ValueError("A user with this login email already exists in another tenant.")
if user is None:
temp_password = (password or "").strip() or secrets.token_urlsafe(12)
user = User(
email=login_email,
full_name=(full_name or login_email).strip(),
password_hash=hash_password(temp_password),
tenant_id=tenant_id,
branch_id=resolved_branch_id,
is_active=True,
allow_login=True,
is_locked=False,
deleted_at=None,
must_change_password=True,
password_changed_at_utc=None,
)
db.add(user)
db.flush()
else:
user.full_name = (full_name or user.full_name or login_email).strip()
user.branch_id = resolved_branch_id
user.is_active = True
user.allow_login = True
user.is_locked = False
if password and password.strip():
user.password_hash = hash_password(password.strip())
user.must_change_password = True
role = _consultant_role(db)
if not role:
raise ValueError("Consultant role is not available. Run startup/permission seeding first.")
if not _user_has_role(db, user_id=int(user.id), role_id=int(role.id)):
db.add(UserRole(user_id=int(user.id), role_id=int(role.id)))
invite_url = None
if invite_user:
db.flush()
token = issue_invite_token(db, user)
invite_url = f"/invite/accept?token={token}"
return user, invite_url
def list_consultant_role_users(db: Session, *, tenant_id: int, branch_id: int | None = None) -> list[User]:
query = (
select(User)
.join(UserRole, UserRole.user_id == User.id)
.join(Role, Role.id == UserRole.role_id)
.where(User.tenant_id == tenant_id, User.is_active.is_(True), Role.name == "Consultant")
)
if branch_id:
query = query.where(or_(User.branch_id == branch_id, User.branch_id.is_(None)))
return db.execute(query.order_by(User.full_name.asc(), User.email.asc())).scalars().all()
def list_consultants(
db: Session,
*,
tenant_id: int,
branch_id: int | None = None,
q: str = "",
include_inactive: bool = False,
) -> list[ConsultantProfile]:
query = (
select(ConsultantProfile)
.options(selectinload(ConsultantProfile.user))
.where(ConsultantProfile.tenant_id == tenant_id)
)
if branch_id:
query = query.where(or_(ConsultantProfile.branch_id == branch_id, ConsultantProfile.branch_id.is_(None)))
if not include_inactive:
query = query.where(ConsultantProfile.is_active.is_(True))
if q.strip():
term = f"%{q.strip()}%"
query = query.where(
or_(
ConsultantProfile.contact_person.ilike(term),
ConsultantProfile.firm_name.ilike(term),
ConsultantProfile.email.ilike(term),
ConsultantProfile.mobile.ilike(term),
ConsultantProfile.specialisation.ilike(term),
)
)
return db.execute(query.order_by(ConsultantProfile.contact_person.asc())).scalars().all()
def get_consultant(db: Session, *, tenant_id: int, consultant_id: int, branch_id: int | None = None) -> ConsultantProfile | None:
query = (
select(ConsultantProfile)
.options(selectinload(ConsultantProfile.user), selectinload(ConsultantProfile.links))
.where(ConsultantProfile.id == consultant_id, ConsultantProfile.tenant_id == tenant_id)
)
if branch_id:
query = query.where(or_(ConsultantProfile.branch_id == branch_id, ConsultantProfile.branch_id.is_(None)))
return db.execute(query).scalar_one_or_none()
def get_consultant_by_user(db: Session, *, tenant_id: int, user_id: int) -> ConsultantProfile | None:
return db.execute(
select(ConsultantProfile)
.options(selectinload(ConsultantProfile.user))
.where(
ConsultantProfile.tenant_id == tenant_id,
ConsultantProfile.user_id == user_id,
ConsultantProfile.is_active.is_(True),
)
).scalar_one_or_none()
def create_or_update_consultant(db: Session, *, payload: dict, user_id: int, consultant: ConsultantProfile | None = None) -> ConsultantProfile:
if consultant is None:
consultant = ConsultantProfile(
tenant_id=payload["tenant_id"],
branch_id=payload.get("branch_id"),
user_id=payload.get("user_id"),
contact_person=payload["contact_person"],
created_by_user_id=user_id,
)
db.add(consultant)
consultant.branch_id = payload.get("branch_id")
consultant.user_id = payload.get("user_id")
consultant.consultant_type = payload.get("consultant_type") or "external_consultant"
consultant.firm_name = normalise_text(payload.get("firm_name"))
consultant.contact_person = payload["contact_person"].strip()
consultant.email = normalise_text(payload.get("email"))
consultant.mobile = normalise_text(payload.get("mobile"))
consultant.specialisation = normalise_text(payload.get("specialisation"))
consultant.gstin = normalise_text(payload.get("gstin"))
consultant.pan = normalise_text(payload.get("pan"))
consultant.address = normalise_text(payload.get("address"))
consultant.status = payload.get("status") or "active"
consultant.onboarding_status = payload.get("onboarding_status") or "approved"
consultant.is_platform_partner = bool(payload.get("is_platform_partner"))
consultant.is_franchise_partner = bool(payload.get("is_franchise_partner"))
consultant.is_saas_customer = bool(payload.get("is_saas_customer"))
consultant.is_active = bool(payload.get("is_active", True))
consultant.remarks = normalise_text(payload.get("remarks"))
consultant.updated_by_user_id = user_id
return consultant
def update_consultant_own_profile(db: Session, *, consultant: ConsultantProfile, payload: dict, user_id: int) -> ConsultantProfile:
consultant.firm_name = normalise_text(payload.get("firm_name"))
consultant.contact_person = (payload.get("contact_person") or consultant.contact_person or "").strip()
consultant.email = normalise_text(payload.get("email"))
consultant.mobile = normalise_text(payload.get("mobile"))
consultant.specialisation = normalise_text(payload.get("specialisation"))
consultant.gstin = normalise_text(payload.get("gstin"))
consultant.pan = normalise_text(payload.get("pan"))
consultant.address = normalise_text(payload.get("address"))
consultant.updated_by_user_id = user_id
return consultant
def list_clients_available_for_link(
db: Session,
*,
tenant_id: int,
branch_id: int | None = None,
partner_user_id: int | None = None,
) -> list[Client]:
query = select(Client).where(Client.tenant_id == tenant_id, Client.is_archived.is_(False), Client.is_active.is_(True))
if branch_id:
query = query.where(Client.branch_id == branch_id)
if partner_user_id:
query = query.where(Client.partner_id == partner_user_id)
return db.execute(query.order_by(Client.client_name.asc())).scalars().all()
def list_client_links_for_consultant(db: Session, *, consultant_id: int) -> list[ClientConsultantLink]:
return db.execute(
select(ClientConsultantLink)
.options(selectinload(ClientConsultantLink.client), selectinload(ClientConsultantLink.service_catalogue))
.where(ClientConsultantLink.consultant_id == consultant_id)
.order_by(ClientConsultantLink.is_active.desc(), ClientConsultantLink.id.desc())
).scalars().all()
def consultant_linked_clients_page(
db: Session,
*,
consultant: ConsultantProfile,
q: str = "",
client_group_id: int | None = None,
status: str = "active",
page: int = 1,
per_page: int = 25,
sort_by: str = "client_name",
sort_order: str = "asc",
) -> dict:
"""Return a compact, searchable and paginated list of linked firm clients.
Link permission columns remain unchanged and continue to govern the existing
service, due-date, communication and document workflows. They are deliberately
not exposed in the client-list UI.
"""
q = (q or "").strip()
status = (status or "").strip().lower()
per_page = min(max(int(per_page or 25), 1), 100)
page = max(int(page or 1), 1)
stmt = (
select(ClientConsultantLink, Client, ClientGroup)
.join(Client, Client.id == ClientConsultantLink.client_id)
.outerjoin(ClientGroup, ClientGroup.id == Client.client_group_id)
.where(
ClientConsultantLink.tenant_id == consultant.tenant_id,
ClientConsultantLink.consultant_id == consultant.id,
ClientConsultantLink.is_active.is_(True),
Client.tenant_id == consultant.tenant_id,
Client.is_archived.is_(False),
)
)
if status:
stmt = stmt.where(Client.status == status)
if client_group_id:
stmt = stmt.where(Client.client_group_id == int(client_group_id))
if q:
like = f"%{q}%"
stmt = stmt.where(
or_(
Client.client_code.ilike(like),
Client.client_name.ilike(like),
Client.trade_name.ilike(like),
Client.pan.ilike(like),
Client.gstin.ilike(like),
ClientGroup.group_name.ilike(like),
ClientGroup.group_code.ilike(like),
)
)
total = int(db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one() or 0)
pages = max(ceil(total / per_page), 1)
page = min(page, pages)
sort_columns = {
"client_code": Client.client_code,
"client_name": Client.client_name,
"pan": Client.pan,
"client_group": ClientGroup.group_name,
"status": Client.status,
}
sort_column = sort_columns.get(sort_by, Client.client_name)
ordering = sort_column.desc() if (sort_order or "asc").lower() == "desc" else sort_column.asc()
rows = []
result = db.execute(
stmt.order_by(ordering, Client.id.asc())
.offset((page - 1) * per_page)
.limit(per_page)
).all()
for link, client, group in result:
rows.append({
"link": link,
"client": client,
"client_group_id": getattr(group, "id", None),
"client_group_code": getattr(group, "group_code", None),
"client_group_name": getattr(group, "group_name", None),
"relationship_label": (link.relationship_type or "accounts_consultant").replace("_", " ").title(),
})
groups_stmt = (
select(ClientGroup.id, ClientGroup.group_code, ClientGroup.group_name)
.join(Client, Client.client_group_id == ClientGroup.id)
.join(ClientConsultantLink, ClientConsultantLink.client_id == Client.id)
.where(
ClientConsultantLink.tenant_id == consultant.tenant_id,
ClientConsultantLink.consultant_id == consultant.id,
ClientConsultantLink.is_active.is_(True),
Client.tenant_id == consultant.tenant_id,
Client.is_archived.is_(False),
ClientGroup.is_active.is_(True),
)
.distinct()
.order_by(ClientGroup.group_name.asc())
)
groups = [
{"id": int(group_id), "code": code, "name": name}
for group_id, code, name in db.execute(groups_stmt).all()
]
return {
"rows": rows,
"groups": groups,
"meta": {"total": total, "page": page, "per_page": per_page, "pages": pages},
"filters": {
"q": q,
"client_group_id": client_group_id,
"status": status,
"sort_by": sort_by if sort_by in sort_columns else "client_name",
"sort_order": "desc" if (sort_order or "asc").lower() == "desc" else "asc",
},
}
def link_client_to_consultant(
db: Session,
*,
tenant_id: int,
consultant_id: int,
client: Client,
relationship_type: str,
is_primary: bool,
can_view_client: bool,
can_view_services: bool,
can_view_due_dates: bool,
can_view_communications: bool,
remarks: str | None,
user_id: int,
) -> ClientConsultantLink:
link = db.execute(
select(ClientConsultantLink).where(
ClientConsultantLink.tenant_id == tenant_id,
ClientConsultantLink.client_id == client.id,
ClientConsultantLink.consultant_id == consultant_id,
)
).scalar_one_or_none()
if link is None:
link = ClientConsultantLink(
tenant_id=tenant_id,
branch_id=client.branch_id,
client_id=client.id,
consultant_id=consultant_id,
created_by_user_id=user_id,
)
db.add(link)
link.relationship_type = relationship_type or "accounts_consultant"
link.is_primary = bool(is_primary)
link.can_view_client = bool(can_view_client)
link.can_view_services = bool(can_view_services)
link.can_view_due_dates = bool(can_view_due_dates)
link.can_view_communications = bool(can_view_communications)
link.is_active = True
link.remarks = normalise_text(remarks)
link.updated_by_user_id = user_id
return link
def set_link_active(db: Session, *, tenant_id: int, link_id: int, active: bool, user_id: int) -> ClientConsultantLink | None:
link = db.execute(
select(ClientConsultantLink).where(ClientConsultantLink.id == link_id, ClientConsultantLink.tenant_id == tenant_id)
).scalar_one_or_none()
if link:
link.is_active = active
link.updated_by_user_id = user_id
return link
def linked_client_ids_for_consultant(db: Session, *, consultant_id: int, require_communications: bool = False) -> list[int]:
query = select(ClientConsultantLink.client_id).where(
ClientConsultantLink.consultant_id == consultant_id,
ClientConsultantLink.is_active.is_(True),
)
if require_communications:
query = query.where(ClientConsultantLink.can_view_communications.is_(True))
return [int(x) for x in db.execute(query).scalars().all()]
def consultant_dashboard_payload(db: Session, *, consultant: ConsultantProfile) -> dict:
"""Build the consultant portal dashboard payload.
This function is intentionally read-only. It does not create or update any
consultant records. It only aggregates already available data from consultant
links, task communications, service requests, conversion requests and the
consultant workspace.
"""
links = list_client_links_for_consultant(db, consultant_id=consultant.id)
active_links = [link for link in links if link.is_active]
communication_client_ids = [int(link.client_id) for link in active_links if link.can_view_communications]
due_date_client_ids = [int(link.client_id) for link in active_links if link.can_view_due_dates]
comments: list[tuple[ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue]] = []
if communication_client_ids:
comments = db.execute(
select(ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
.join(ClientServiceSubscription, ClientServiceSubscription.id == ServiceTaskComment.subscription_id)
.join(Client, Client.id == ClientServiceTaskInstance.client_id)
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
.where(
ServiceTaskComment.tenant_id == consultant.tenant_id,
ServiceTaskComment.visibility == "consultant",
ServiceTaskComment.is_deleted.is_(False),
ClientServiceTaskInstance.client_id.in_(communication_client_ids),
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
ClientServiceTaskInstance.is_active.is_(True),
)
.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
.limit(50)
).all()
today = date.today()
next_30_days = today + timedelta(days=30)
upcoming_due = 0
overdue = 0
due_items: list[tuple[ClientServiceSubscription, Client, ServiceCatalogue]] = []
overdue_items: list[tuple[ClientServiceSubscription, Client, ServiceCatalogue]] = []
if due_date_client_ids:
due_query = (
select(ClientServiceSubscription, Client, ServiceCatalogue)
.join(Client, Client.id == ClientServiceSubscription.client_id)
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id)
.where(
ClientServiceSubscription.tenant_id == consultant.tenant_id,
ClientServiceSubscription.client_id.in_(due_date_client_ids),
ClientServiceSubscription.is_active.is_(True),
ClientServiceSubscription.current_due_date.is_not(None),
)
)
due_items = db.execute(
due_query.where(
ClientServiceSubscription.current_due_date >= today,
ClientServiceSubscription.current_due_date <= next_30_days,
)
.order_by(ClientServiceSubscription.current_due_date.asc(), Client.client_name.asc())
.limit(8)
).all()
overdue_items = db.execute(
due_query.where(
ClientServiceSubscription.current_due_date < today,
ClientServiceSubscription.status != "completed",
)
.order_by(ClientServiceSubscription.current_due_date.asc(), Client.client_name.asc())
.limit(8)
).all()
upcoming_due = len(due_items)
overdue = len(overdue_items)
managed_clients = list_consultant_managed_clients(
db,
tenant_id=consultant.tenant_id,
consultant_id=consultant.id,
include_inactive=False,
)
managed_stats = consultant_managed_clients_stats(db, consultant=consultant)
workspace_summary = consultant_workspace_summary(db, consultant=consultant)
service_requests = list_consultant_service_requests(db, consultant=consultant)
open_service_requests = [r for r in service_requests if r.status in {"submitted", "under_review", "accepted"}]
pending_service_requests = [r for r in service_requests if r.status in {"submitted", "under_review"}]
conversion_requests = [
r
for r in managed_clients
if getattr(r, "conversion_status", "not_requested") in {"requested", "under_review", "approved", "rejected"}
]
pending_conversions = [r for r in conversion_requests if r.conversion_status in {"requested", "under_review"}]
converted_clients = [r for r in managed_clients if getattr(r, "linked_firm_client_id", None)]
consultant_user_id = int(getattr(consultant, "user_id", 0) or 0)
firm_messages = [row for row in comments if int(getattr(row[0], "created_by_user_id", 0) or 0) != consultant_user_id]
consultant_replies = [row for row in comments if int(getattr(row[0], "created_by_user_id", 0) or 0) == consultant_user_id]
workspace = workspace_summary.get("workspace")
workspace_alerts: list[str] = []
remaining = workspace_summary.get("managed_clients_remaining")
limit = workspace_summary.get("managed_clients_limit")
usage_percent = int(workspace_summary.get("usage_percent") or 0)
if workspace is None:
workspace_alerts.append("Workspace setup is pending.")
elif not getattr(workspace, "is_active", True):
workspace_alerts.append("Workspace is inactive.")
elif getattr(workspace, "subscription_status", "") in {"suspended", "cancelled"}:
workspace_alerts.append(f"Subscription status is {workspace.subscription_status.replace('_', ' ').title()}.")
if limit and remaining is not None and remaining <= 0:
workspace_alerts.append("Managed client limit has been reached.")
elif limit and usage_percent >= 80:
workspace_alerts.append("Managed client usage is above 80% of the workspace limit.")
return {
"links": links,
"active_links": active_links,
"comments": comments[:8],
"recent_firm_messages": firm_messages[:6],
"recent_consultant_replies": consultant_replies[:6],
"managed_clients": managed_clients[:8],
"workspace_summary": workspace_summary,
"workspace": workspace,
"workspace_alerts": workspace_alerts,
"service_requests": service_requests[:6],
"open_service_requests": open_service_requests[:6],
"pending_service_requests": pending_service_requests[:6],
"conversion_requests": conversion_requests[:6],
"pending_conversions": pending_conversions[:6],
"due_items": due_items,
"overdue_items": overdue_items,
"stats": {
"linked_clients": len(active_links),
"communication_enabled_clients": len(communication_client_ids),
"due_date_enabled_clients": len(due_date_client_ids),
"pending_clarifications": len(firm_messages),
"consultant_replies": len(consultant_replies),
"upcoming_due": int(upcoming_due or 0),
"overdue": int(overdue or 0),
"open_service_requests": len(open_service_requests),
"pending_service_requests": len(pending_service_requests),
"pending_conversions": len(pending_conversions),
"converted_clients": len(converted_clients),
**managed_stats,
},
}
def _next_workspace_code(db: Session, *, tenant_id: int) -> str:
count = db.execute(
select(func.count(ConsultantWorkspace.id)).where(ConsultantWorkspace.tenant_id == tenant_id)
).scalar_one()
return f"CW-{int(count or 0) + 1:04d}"
def get_workspace_by_consultant(
db: Session,
*,
tenant_id: int,
consultant_id: int,
) -> ConsultantWorkspace | None:
return db.execute(
select(ConsultantWorkspace).where(
ConsultantWorkspace.tenant_id == tenant_id,
ConsultantWorkspace.consultant_id == consultant_id,
)
).scalar_one_or_none()
def ensure_consultant_workspace(
db: Session,
*,
consultant: ConsultantProfile,
user_id: int | None = None,
) -> ConsultantWorkspace:
workspace = get_workspace_by_consultant(db, tenant_id=consultant.tenant_id, consultant_id=consultant.id)
if workspace:
return workspace
workspace = ConsultantWorkspace(
tenant_id=consultant.tenant_id,
branch_id=consultant.branch_id,
consultant_id=consultant.id,
workspace_code=_next_workspace_code(db, tenant_id=consultant.tenant_id),
workspace_name=consultant.firm_name or consultant.contact_person,
workspace_type="franchise_partner" if consultant.is_franchise_partner else "consultant_saas",
plan_code="franchise" if consultant.is_franchise_partner else "starter",
subscription_status="active" if consultant.is_saas_customer or consultant.is_franchise_partner else "trial",
billing_cycle="manual",
max_managed_clients=100 if consultant.is_franchise_partner else 25,
max_user_accounts=5 if consultant.is_franchise_partner else 1,
allow_client_portal=False,
allow_firm_referrals=True,
allow_service_marketplace=bool(consultant.is_franchise_partner or consultant.is_platform_partner),
is_active=True,
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
db.add(workspace)
return workspace
def parse_optional_date(value: str | None) -> date | None:
if not value:
return None
text = value.strip()
if not text:
return None
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def update_consultant_workspace(
db: Session,
*,
workspace: ConsultantWorkspace,
payload: dict,
user_id: int,
admin_mode: bool = False,
) -> ConsultantWorkspace:
"""Update consultant workspace.
Normal consultant portal users may update only safe self-service fields
(workspace name and remarks). Commercial controls such as plan, billing,
subscription status, client/user limits and feature switches remain firm/admin
controlled unless admin_mode=True is explicitly used by a future internal route.
"""
workspace.workspace_name = normalise_text(payload.get("workspace_name")) or workspace.workspace_name
workspace.remarks = normalise_text(payload.get("remarks"))
if admin_mode:
workspace.workspace_type = payload.get("workspace_type") or workspace.workspace_type
workspace.plan_code = payload.get("plan_code") or workspace.plan_code
workspace.billing_cycle = payload.get("billing_cycle") or workspace.billing_cycle
workspace.subscription_status = payload.get("subscription_status") or workspace.subscription_status
workspace.subscription_start_date = payload.get("subscription_start_date")
workspace.subscription_end_date = payload.get("subscription_end_date")
workspace.max_managed_clients = int(payload.get("max_managed_clients") or workspace.max_managed_clients or 25)
workspace.max_user_accounts = int(payload.get("max_user_accounts") or workspace.max_user_accounts or 1)
workspace.allow_client_portal = bool(payload.get("allow_client_portal"))
workspace.allow_firm_referrals = bool(payload.get("allow_firm_referrals"))
workspace.allow_service_marketplace = bool(payload.get("allow_service_marketplace"))
workspace.is_active = bool(payload.get("is_active", True))
workspace.updated_by_user_id = user_id
return workspace
def consultant_workspace_summary(db: Session, *, consultant: ConsultantProfile) -> dict:
workspace = get_workspace_by_consultant(db, tenant_id=consultant.tenant_id, consultant_id=consultant.id)
managed_total = db.execute(
select(func.count(ConsultantManagedClient.id)).where(
ConsultantManagedClient.tenant_id == consultant.tenant_id,
ConsultantManagedClient.consultant_id == consultant.id,
ConsultantManagedClient.is_active.is_(True),
)
).scalar_one()
if not workspace:
return {
"workspace": None,
"managed_clients_used": int(managed_total or 0),
"managed_clients_limit": None,
"managed_clients_remaining": None,
"usage_percent": 0,
}
limit = max(int(workspace.max_managed_clients or 0), 0)
used = int(managed_total or 0)
remaining = max(limit - used, 0) if limit else None
usage_percent = int(round((used / limit) * 100)) if limit else 0
return {
"workspace": workspace,
"managed_clients_used": used,
"managed_clients_limit": limit,
"managed_clients_remaining": remaining,
"usage_percent": usage_percent,
}
def consultant_can_add_managed_client(db: Session, *, consultant: ConsultantProfile) -> tuple[bool, dict]:
"""Return whether the consultant can add one more managed client under workspace limits."""
summary = consultant_workspace_summary(db, consultant=consultant)
limit = summary.get("managed_clients_limit")
used = int(summary.get("managed_clients_used") or 0)
if limit is None or int(limit or 0) <= 0:
return True, summary
return used < int(limit), summary
def _next_consultant_client_code(db: Session, *, tenant_id: int, consultant_id: int) -> str:
count = db.execute(
select(func.count(ConsultantManagedClient.id)).where(
ConsultantManagedClient.tenant_id == tenant_id,
ConsultantManagedClient.consultant_id == consultant_id,
)
).scalar_one()
return f"CMC-{consultant_id}-{int(count or 0) + 1:04d}"
def list_consultant_managed_clients(
db: Session,
*,
tenant_id: int,
consultant_id: int,
q: str = "",
status: str = "",
include_inactive: bool = False,
) -> list[ConsultantManagedClient]:
query = select(ConsultantManagedClient).where(
ConsultantManagedClient.tenant_id == tenant_id,
ConsultantManagedClient.consultant_id == consultant_id,
)
if not include_inactive:
query = query.where(ConsultantManagedClient.is_active.is_(True))
if status.strip():
query = query.where(ConsultantManagedClient.status == status.strip())
if q.strip():
term = f"%{q.strip()}%"
query = query.where(
or_(
ConsultantManagedClient.client_name.ilike(term),
ConsultantManagedClient.trade_name.ilike(term),
ConsultantManagedClient.client_code.ilike(term),
ConsultantManagedClient.pan.ilike(term),
ConsultantManagedClient.gstin.ilike(term),
ConsultantManagedClient.email.ilike(term),
ConsultantManagedClient.mobile.ilike(term),
)
)
return db.execute(query.order_by(ConsultantManagedClient.client_name.asc())).scalars().all()
def get_consultant_managed_client(
db: Session,
*,
tenant_id: int,
consultant_id: int,
managed_client_id: int,
) -> ConsultantManagedClient | None:
return db.execute(
select(ConsultantManagedClient).where(
ConsultantManagedClient.id == managed_client_id,
ConsultantManagedClient.tenant_id == tenant_id,
ConsultantManagedClient.consultant_id == consultant_id,
)
).scalar_one_or_none()
def create_or_update_managed_client(
db: Session,
*,
consultant: ConsultantProfile,
payload: dict,
user_id: int,
managed_client: ConsultantManagedClient | None = None,
) -> ConsultantManagedClient:
if managed_client is None:
can_add, summary = consultant_can_add_managed_client(db, consultant=consultant)
if not can_add:
raise ValueError(
"Managed client limit reached for this consultant workspace "
f"({summary.get('managed_clients_used')}/{summary.get('managed_clients_limit')})."
)
managed_client = ConsultantManagedClient(
tenant_id=consultant.tenant_id,
branch_id=consultant.branch_id,
consultant_id=consultant.id,
created_by_user_id=user_id,
)
db.add(managed_client)
managed_client.client_code = normalise_text(payload.get("client_code")) or managed_client.client_code or _next_consultant_client_code(
db, tenant_id=consultant.tenant_id, consultant_id=consultant.id
)
managed_client.client_name = (payload.get("client_name") or "").strip()
managed_client.trade_name = normalise_text(payload.get("trade_name"))
managed_client.client_type = payload.get("client_type") or "Other"
managed_client.pan = normalise_text(payload.get("pan"))
managed_client.gstin = normalise_text(payload.get("gstin"))
managed_client.tan = normalise_text(payload.get("tan"))
managed_client.contact_person_name = normalise_text(payload.get("contact_person_name"))
managed_client.mobile = normalise_text(payload.get("mobile"))
managed_client.email = normalise_text(payload.get("email"))
managed_client.address_line_1 = normalise_text(payload.get("address_line_1"))
managed_client.address_line_2 = normalise_text(payload.get("address_line_2"))
managed_client.city = normalise_text(payload.get("city"))
managed_client.state = normalise_text(payload.get("state"))
managed_client.pincode = normalise_text(payload.get("pincode"))
managed_client.country = normalise_text(payload.get("country")) or "India"
managed_client.service_interest = normalise_text(payload.get("service_interest"))
managed_client.relationship_stage = payload.get("relationship_stage") or "managed"
managed_client.status = payload.get("status") or "active"
managed_client.is_active = bool(payload.get("is_active", True))
managed_client.notes = normalise_text(payload.get("notes"))
managed_client.updated_by_user_id = user_id
return managed_client
def consultant_managed_clients_stats(db: Session, *, consultant: ConsultantProfile) -> dict:
rows = list_consultant_managed_clients(
db,
tenant_id=consultant.tenant_id,
consultant_id=consultant.id,
include_inactive=True,
)
return {
"managed_clients": len([r for r in rows if r.is_active]),
"prospects": len([r for r in rows if r.relationship_stage == "lead" and r.is_active]),
"referred_to_firm": len([r for r in rows if r.relationship_stage == "referred_to_firm" and r.is_active]),
}
CONSULTANT_SERVICE_REQUEST_STATUSES = [
("submitted", "Submitted"),
("under_review", "Under Review"),
("accepted", "Accepted"),
("rejected", "Rejected"),
("converted", "Converted to Engagement"),
("closed", "Closed"),
]
CONSULTANT_SERVICE_REQUEST_PRIORITIES = [
("low", "Low"),
("normal", "Normal"),
("high", "High"),
("urgent", "Urgent"),
]
def _allowed_consultant_client_ids(db: Session, *, consultant: ConsultantProfile, require_communications: bool = True) -> list[int]:
query = select(ClientConsultantLink.client_id).where(
ClientConsultantLink.tenant_id == consultant.tenant_id,
ClientConsultantLink.consultant_id == consultant.id,
ClientConsultantLink.is_active.is_(True),
)
if require_communications:
query = query.where(ClientConsultantLink.can_view_communications.is_(True))
return [int(x) for x in db.execute(query).scalars().all()]
def list_consultant_visible_communications(
db: Session,
*,
consultant: ConsultantProfile,
q: str = "",
limit: int = 100,
) -> list[tuple[ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue]]:
client_ids = _allowed_consultant_client_ids(db, consultant=consultant, require_communications=True)
if not client_ids:
return []
safe_limit = max(1, min(int(limit or 100), 200))
query = (
select(ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
.join(ClientServiceSubscription, ClientServiceSubscription.id == ServiceTaskComment.subscription_id)
.join(Client, Client.id == ClientServiceTaskInstance.client_id)
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
.where(
ServiceTaskComment.tenant_id == consultant.tenant_id,
ServiceTaskComment.visibility == "consultant",
ServiceTaskComment.is_deleted.is_(False),
ClientServiceTaskInstance.client_id.in_(client_ids),
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
ClientServiceTaskInstance.is_active.is_(True),
)
)
if q.strip():
term = f"%{q.strip()}%"
query = query.where(
or_(
Client.client_name.ilike(term),
Client.client_code.ilike(term),
ServiceCatalogue.service_name.ilike(term),
ServiceCatalogue.service_code.ilike(term),
ClientServiceTaskInstance.task_name.ilike(term),
ServiceTaskComment.message.ilike(term),
)
)
return db.execute(query.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc()).limit(safe_limit)).all()
def get_consultant_visible_communication(
db: Session,
*,
consultant: ConsultantProfile,
comment_id: int,
) -> tuple[ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue] | None:
client_ids = _allowed_consultant_client_ids(db, consultant=consultant, require_communications=True)
if not client_ids:
return None
return db.execute(
select(ServiceTaskComment, ClientServiceTaskInstance, ClientServiceSubscription, Client, ServiceCatalogue)
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
.join(ClientServiceSubscription, ClientServiceSubscription.id == ServiceTaskComment.subscription_id)
.join(Client, Client.id == ClientServiceTaskInstance.client_id)
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
.where(
ServiceTaskComment.id == comment_id,
ServiceTaskComment.tenant_id == consultant.tenant_id,
ServiceTaskComment.visibility == "consultant",
ServiceTaskComment.is_deleted.is_(False),
ClientServiceTaskInstance.client_id.in_(client_ids),
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
ClientServiceTaskInstance.is_active.is_(True),
)
).first()
def list_consultant_task_timeline(
db: Session,
*,
consultant: ConsultantProfile,
task_id: int,
) -> list[ServiceTaskComment]:
client_ids = _allowed_consultant_client_ids(db, consultant=consultant, require_communications=True)
if not client_ids:
return []
task = db.execute(
select(ClientServiceTaskInstance).where(
ClientServiceTaskInstance.id == task_id,
ClientServiceTaskInstance.tenant_id == consultant.tenant_id,
ClientServiceTaskInstance.client_id.in_(client_ids),
ClientServiceTaskInstance.is_active.is_(True),
)
).scalar_one_or_none()
if not task:
return []
return db.execute(
select(ServiceTaskComment)
.options(selectinload(ServiceTaskComment.created_by))
.where(
ServiceTaskComment.tenant_id == consultant.tenant_id,
ServiceTaskComment.task_instance_id == task_id,
ServiceTaskComment.visibility == "consultant",
ServiceTaskComment.is_deleted.is_(False),
)
.order_by(ServiceTaskComment.created_at_utc.asc(), ServiceTaskComment.id.asc())
).scalars().all()
def add_consultant_task_reply(
db: Session,
*,
consultant: ConsultantProfile,
task: ClientServiceTaskInstance,
message: str,
user_id: int,
) -> ServiceTaskComment | None:
client_ids = _allowed_consultant_client_ids(db, consultant=consultant, require_communications=True)
if int(task.client_id) not in client_ids:
return None
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
return None
clean_message = (message or "").strip()
if not clean_message:
return None
row = ServiceTaskComment(
tenant_id=task.tenant_id,
branch_id=task.branch_id,
subscription_id=task.subscription_id,
task_instance_id=task.id,
comment_type="consultant_clarification",
visibility="consultant",
message=clean_message,
created_by_user_id=user_id,
)
db.add(row)
task.updated_by_user_id = user_id
return row
def _next_service_request_no(db: Session, *, tenant_id: int) -> str:
count = db.execute(
select(func.count(ConsultantServiceRequest.id)).where(ConsultantServiceRequest.tenant_id == tenant_id)
).scalar_one()
return f"CSR-{int(count or 0) + 1:05d}"
def list_consultant_requestable_services(db: Session) -> list[ServiceCatalogue]:
flagged = db.execute(
select(ServiceCatalogue)
.where(ServiceCatalogue.is_active.is_(True), ServiceCatalogue.is_consultant_requestable.is_(True))
.order_by(ServiceCatalogue.service_name.asc())
).scalars().all()
if flagged:
return flagged
return db.execute(
select(ServiceCatalogue)
.where(ServiceCatalogue.is_active.is_(True))
.order_by(ServiceCatalogue.service_name.asc())
).scalars().all()
def _valid_managed_client_for_consultant(db: Session, *, consultant: ConsultantProfile, managed_client_id: int | None) -> ConsultantManagedClient | None:
if not managed_client_id:
return None
return get_consultant_managed_client(
db,
tenant_id=consultant.tenant_id,
consultant_id=consultant.id,
managed_client_id=int(managed_client_id),
)
def _valid_firm_client_for_consultant(db: Session, *, consultant: ConsultantProfile, firm_client_id: int | None) -> Client | None:
if not firm_client_id:
return None
allowed_client_ids = _allowed_consultant_client_ids(db, consultant=consultant, require_communications=False)
if int(firm_client_id) not in allowed_client_ids:
return None
return db.execute(
select(Client).where(
Client.id == int(firm_client_id),
Client.tenant_id == consultant.tenant_id,
Client.is_archived.is_(False),
)
).scalar_one_or_none()
def create_consultant_service_request(
db: Session,
*,
consultant: ConsultantProfile,
payload: dict,
user_id: int,
) -> ConsultantServiceRequest:
service_id = payload.get("service_catalogue_id")
service = None
if service_id:
service = db.execute(
select(ServiceCatalogue).where(ServiceCatalogue.id == int(service_id), ServiceCatalogue.is_active.is_(True))
).scalar_one_or_none()
managed_client = _valid_managed_client_for_consultant(
db, consultant=consultant, managed_client_id=payload.get("managed_client_id")
)
firm_client = _valid_firm_client_for_consultant(
db, consultant=consultant, firm_client_id=payload.get("firm_client_id")
)
if not managed_client and not firm_client:
raise ValueError("Select either one managed client or one linked firm client for the service request.")
requested_service_name = normalise_text(payload.get("requested_service_name")) or (service.service_name if service else None)
if not requested_service_name:
raise ValueError("Service name is required.")
subject = normalise_text(payload.get("subject")) or requested_service_name
row = ConsultantServiceRequest(
tenant_id=consultant.tenant_id,
branch_id=consultant.branch_id,
consultant_id=consultant.id,
managed_client_id=managed_client.id if managed_client else None,
firm_client_id=firm_client.id if firm_client else None,
service_catalogue_id=service.id if service else None,
request_no=_next_service_request_no(db, tenant_id=consultant.tenant_id),
request_type=payload.get("request_type") or "service_request",
status="submitted",
priority=payload.get("priority") or "normal",
requested_service_name=requested_service_name,
requested_due_date=payload.get("requested_due_date"),
subject=subject,
description=normalise_text(payload.get("description")),
consultant_notes=normalise_text(payload.get("consultant_notes")),
created_by_user_id=user_id,
updated_by_user_id=user_id,
is_active=True,
)
db.add(row)
return row
def list_consultant_service_requests(
db: Session,
*,
consultant: ConsultantProfile,
status: str = "",
) -> list[ConsultantServiceRequest]:
query = (
select(ConsultantServiceRequest)
.options(
selectinload(ConsultantServiceRequest.managed_client),
selectinload(ConsultantServiceRequest.firm_client),
selectinload(ConsultantServiceRequest.service_catalogue),
selectinload(ConsultantServiceRequest.reviewed_by),
)
.where(
ConsultantServiceRequest.tenant_id == consultant.tenant_id,
ConsultantServiceRequest.consultant_id == consultant.id,
ConsultantServiceRequest.is_active.is_(True),
)
)
if status.strip():
query = query.where(ConsultantServiceRequest.status == status.strip())
return db.execute(query.order_by(ConsultantServiceRequest.created_at_utc.desc(), ConsultantServiceRequest.id.desc())).scalars().all()
def get_consultant_service_request(
db: Session,
*,
tenant_id: int,
request_id: int,
consultant_id: int | None = None,
) -> ConsultantServiceRequest | None:
query = (
select(ConsultantServiceRequest)
.options(
selectinload(ConsultantServiceRequest.consultant),
selectinload(ConsultantServiceRequest.managed_client),
selectinload(ConsultantServiceRequest.firm_client),
selectinload(ConsultantServiceRequest.service_catalogue),
selectinload(ConsultantServiceRequest.created_by),
selectinload(ConsultantServiceRequest.reviewed_by),
)
.where(ConsultantServiceRequest.id == request_id, ConsultantServiceRequest.tenant_id == tenant_id)
)
if consultant_id:
query = query.where(ConsultantServiceRequest.consultant_id == consultant_id)
return db.execute(query).scalar_one_or_none()
def list_all_consultant_service_requests(
db: Session,
*,
tenant_id: int,
branch_id: int | None = None,
status: str = "",
) -> list[ConsultantServiceRequest]:
query = (
select(ConsultantServiceRequest)
.options(
selectinload(ConsultantServiceRequest.consultant),
selectinload(ConsultantServiceRequest.managed_client),
selectinload(ConsultantServiceRequest.firm_client),
selectinload(ConsultantServiceRequest.service_catalogue),
)
.where(ConsultantServiceRequest.tenant_id == tenant_id, ConsultantServiceRequest.is_active.is_(True))
)
if branch_id:
query = query.where(ConsultantServiceRequest.branch_id == branch_id)
if status.strip():
query = query.where(ConsultantServiceRequest.status == status.strip())
return db.execute(query.order_by(ConsultantServiceRequest.created_at_utc.desc(), ConsultantServiceRequest.id.desc())).scalars().all()
def update_consultant_service_request_status(
db: Session,
*,
request: ConsultantServiceRequest,
status: str,
firm_response: str | None,
user_id: int,
) -> ConsultantServiceRequest:
allowed = {code for code, _label in CONSULTANT_SERVICE_REQUEST_STATUSES}
clean_status = (status or "under_review").strip()
if clean_status not in allowed:
clean_status = "under_review"
request.status = clean_status
request.firm_response = normalise_text(firm_response)
request.reviewed_by_user_id = user_id
request.reviewed_at_utc = datetime.now(timezone.utc)
request.updated_by_user_id = user_id
return request
# ---------------------------------------------------------------------------
# Phase 5A.7 / 5A.8 helpers
# Consultant-managed client conversion + workspace limit enforcement
# ---------------------------------------------------------------------------
def request_managed_client_conversion(
db: Session,
*,
consultant: ConsultantProfile,
managed_client: ConsultantManagedClient,
notes: str | None,
user_id: int,
) -> ConsultantManagedClient:
if managed_client.tenant_id != consultant.tenant_id or managed_client.consultant_id != consultant.id:
raise ValueError("Managed client is not available for this consultant.")
if managed_client.linked_firm_client_id:
raise ValueError("This managed client is already linked to a firm client.")
if managed_client.conversion_status in {"requested", "under_review"}:
raise ValueError("Conversion request is already pending review.")
managed_client.conversion_status = "requested"
managed_client.relationship_stage = "conversion_requested"
managed_client.conversion_requested_at_utc = datetime.now(timezone.utc)
managed_client.conversion_requested_by_user_id = user_id
managed_client.conversion_notes = normalise_text(notes)
managed_client.updated_by_user_id = user_id
return managed_client
def list_consultant_conversion_requests(
db: Session,
*,
tenant_id: int,
branch_id: int | None = None,
status: str = "",
) -> list[ConsultantManagedClient]:
query = (
select(ConsultantManagedClient)
.options(
selectinload(ConsultantManagedClient.consultant),
selectinload(ConsultantManagedClient.linked_firm_client),
)
.where(
ConsultantManagedClient.tenant_id == tenant_id,
ConsultantManagedClient.conversion_status != "not_requested",
)
)
if branch_id:
query = query.where(ConsultantManagedClient.branch_id == branch_id)
if status.strip():
query = query.where(ConsultantManagedClient.conversion_status == status.strip())
return db.execute(
query.order_by(
ConsultantManagedClient.conversion_requested_at_utc.desc().nullslast(),
ConsultantManagedClient.id.desc(),
)
).scalars().all()
def get_client_conversion_request(
db: Session,
*,
tenant_id: int,
managed_client_id: int,
branch_id: int | None = None,
) -> ConsultantManagedClient | None:
query = (
select(ConsultantManagedClient)
.options(
selectinload(ConsultantManagedClient.consultant),
selectinload(ConsultantManagedClient.linked_firm_client),
)
.where(
ConsultantManagedClient.id == managed_client_id,
ConsultantManagedClient.tenant_id == tenant_id,
ConsultantManagedClient.conversion_status != "not_requested",
)
)
if branch_id:
query = query.where(ConsultantManagedClient.branch_id == branch_id)
return db.execute(query).scalar_one_or_none()
def _next_converted_client_code(db: Session, *, tenant_id: int, source_code: str | None) -> str:
base = (normalise_text(source_code) or "CONSULTANT").replace(" ", "-").upper()[:35]
candidate = f"FC-{base}"
existing = db.execute(select(Client.id).where(Client.tenant_id == tenant_id, Client.client_code == candidate)).first()
if not existing:
return candidate
count = db.execute(select(func.count(Client.id)).where(Client.tenant_id == tenant_id)).scalar_one()
return f"FC-{base}-{int(count or 0) + 1:04d}"[:50]
def approve_managed_client_conversion(
db: Session,
*,
managed_client: ConsultantManagedClient,
user_id: int,
partner_user_id: int | None = None,
client_code: str | None = None,
firm_notes: str | None = None,
) -> Client:
if managed_client.linked_firm_client_id:
existing_client = db.get(Client, int(managed_client.linked_firm_client_id))
if existing_client:
return existing_client
if managed_client.conversion_status not in {"requested", "under_review", "rejected"}:
raise ValueError("Only requested/under-review conversion records can be approved.")
if not managed_client.branch_id:
raise ValueError("Managed client has no branch. Set branch before approving conversion.")
pan = normalise_text(managed_client.pan)
gstin = normalise_text(managed_client.gstin)
if pan:
duplicate_pan = db.execute(
select(Client).where(Client.tenant_id == managed_client.tenant_id, Client.pan == pan, Client.is_archived.is_(False))
).scalar_one_or_none()
if duplicate_pan:
raise ValueError(f"A firm client with PAN {pan} already exists: {duplicate_pan.client_name}.")
if gstin:
duplicate_gstin = db.execute(
select(Client).where(Client.tenant_id == managed_client.tenant_id, Client.gstin == gstin, Client.is_archived.is_(False))
).scalar_one_or_none()
if duplicate_gstin:
raise ValueError(f"A firm client with GSTIN {gstin} already exists: {duplicate_gstin.client_name}.")
clean_code = normalise_text(client_code) or _next_converted_client_code(
db, tenant_id=managed_client.tenant_id, source_code=managed_client.client_code
)
duplicate_code = db.execute(
select(Client).where(Client.tenant_id == managed_client.tenant_id, Client.client_code == clean_code)
).scalar_one_or_none()
if duplicate_code:
raise ValueError(f"Client code {clean_code} already exists.")
client = Client(
tenant_id=managed_client.tenant_id,
branch_id=int(managed_client.branch_id),
partner_id=partner_user_id,
engagement_mode="hybrid",
client_code=clean_code,
client_name=managed_client.client_name,
trade_name=managed_client.trade_name,
client_type=managed_client.client_type or "Other",
pan=pan,
gstin=gstin,
tan=normalise_text(managed_client.tan),
contact_person_name=managed_client.contact_person_name,
mobile=managed_client.mobile,
email=managed_client.email,
address_line_1=managed_client.address_line_1,
address_line_2=managed_client.address_line_2,
city=managed_client.city,
state=managed_client.state,
pincode=managed_client.pincode,
country=managed_client.country or "India",
status="active",
client_category="Consultant Referral",
notes=(managed_client.notes or "") + ("\n\nConverted from consultant-managed client."),
is_active=True,
is_archived=False,
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
db.add(client)
db.flush()
managed_client.linked_firm_client_id = client.id
managed_client.conversion_status = "approved"
managed_client.relationship_stage = "converted_to_firm_client"
managed_client.conversion_reviewed_at_utc = datetime.now(timezone.utc)
managed_client.conversion_reviewed_by_user_id = user_id
managed_client.conversion_firm_notes = normalise_text(firm_notes)
managed_client.updated_by_user_id = user_id
link_client_to_consultant(
db,
tenant_id=managed_client.tenant_id,
consultant_id=managed_client.consultant_id,
client=client,
relationship_type="audit_coordination",
is_primary=True,
can_view_client=True,
can_view_services=True,
can_view_due_dates=True,
can_view_communications=True,
remarks="Auto-linked after consultant-managed client conversion.",
user_id=user_id,
)
db.add(
ClientAuditLog(
client_id=client.id,
tenant_id=client.tenant_id,
branch_id=client.branch_id,
actor_user_id=user_id,
action="converted_from_consultant_client",
summary="Client created from consultant-managed client conversion request.",
payload_json={
"consultant_id": managed_client.consultant_id,
"managed_client_id": managed_client.id,
"partner_id": partner_user_id,
},
)
)
return client
def mark_conversion_under_review(
db: Session,
*,
managed_client: ConsultantManagedClient,
user_id: int,
firm_notes: str | None = None,
) -> ConsultantManagedClient:
if managed_client.conversion_status != "requested":
raise ValueError("Only requested conversions can be marked under review.")
managed_client.conversion_status = "under_review"
managed_client.conversion_reviewed_by_user_id = user_id
managed_client.conversion_reviewed_at_utc = datetime.now(timezone.utc)
managed_client.conversion_firm_notes = normalise_text(firm_notes)
managed_client.updated_by_user_id = user_id
return managed_client
def reject_managed_client_conversion(
db: Session,
*,
managed_client: ConsultantManagedClient,
user_id: int,
firm_notes: str | None,
) -> ConsultantManagedClient:
if managed_client.conversion_status not in {"requested", "under_review"}:
raise ValueError("Only pending conversion requests can be rejected.")
managed_client.conversion_status = "rejected"
managed_client.relationship_stage = "managed"
managed_client.conversion_reviewed_by_user_id = user_id
managed_client.conversion_reviewed_at_utc = datetime.now(timezone.utc)
managed_client.conversion_firm_notes = normalise_text(firm_notes)
managed_client.updated_by_user_id = user_id
return managed_client
def get_primary_client_consultant_link(db: Session, *, tenant_id: int, client_id: int) -> ClientConsultantLink | None:
return db.execute(
select(ClientConsultantLink)
.where(
ClientConsultantLink.tenant_id == tenant_id,
ClientConsultantLink.client_id == client_id,
ClientConsultantLink.is_primary.is_(True),
ClientConsultantLink.is_active.is_(True),
)
.order_by(ClientConsultantLink.id.asc())
).scalars().first()
def sync_primary_client_consultant_link(
db: Session, *, client: Client, consultant_id: int | None, actor_user_id: int
) -> ClientConsultantLink | None:
"""Synchronise the client's unrestricted primary consultant link without deleting history."""
links = db.execute(
select(ClientConsultantLink).where(
ClientConsultantLink.tenant_id == client.tenant_id,
ClientConsultantLink.client_id == client.id,
)
).scalars().all()
selected = None
for link in links:
if consultant_id and int(link.consultant_id) == int(consultant_id) and link.service_catalogue_id is None:
selected = link
elif link.is_primary:
link.is_primary = False
link.updated_by_user_id = actor_user_id
if consultant_id is None:
db.flush()
return None
consultant = get_consultant(db, tenant_id=int(client.tenant_id), consultant_id=int(consultant_id))
if not consultant or not consultant.is_active:
raise ValueError("Selected primary consultant is not active in this firm.")
if selected is None:
selected = ClientConsultantLink(
tenant_id=client.tenant_id, branch_id=client.branch_id, client_id=client.id,
consultant_id=consultant.id, service_catalogue_id=None,
relationship_type="accounts_consultant", is_primary=True, is_active=True,
can_view_client=True, can_view_services=True, can_view_due_dates=True,
can_view_communications=True, can_view_engagements=True, can_view_task_status=True,
can_view_assignee=True, can_view_document_requests=True, can_upload_documents=True,
can_reply_to_clarifications=True, can_view_filing_details=True,
can_view_final_documents=True, can_view_permanent_documents=False,
can_receive_notifications=True, can_act_for_client=True,
effective_from=getattr(client, "referral_date", None) or date.today(),
created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id,
)
db.add(selected)
else:
selected.branch_id = client.branch_id
selected.is_primary = True
selected.is_active = True
selected.updated_by_user_id = actor_user_id
db.flush()
return selected
def get_client_consultant_summary(db: Session, *, tenant_id: int, client_id: int) -> dict:
primary = get_primary_client_consultant_link(db, tenant_id=tenant_id, client_id=client_id)
referred_id = db.execute(select(Client.referred_by_consultant_id).where(Client.id == client_id)).scalar_one_or_none()
ids = {int(x) for x in (getattr(primary, "consultant_id", None), referred_id) if x}
profiles = db.execute(select(ConsultantProfile).where(ConsultantProfile.id.in_(ids))).scalars().all() if ids else []
by_id = {int(x.id): x for x in profiles}
return {
"primary": by_id.get(int(primary.consultant_id)) if primary else None,
"referred_by": by_id.get(int(referred_id)) if referred_id else None,
}