Add phase 1 client consultant referral and linkage

This commit is contained in:
A R R R Associates
2026-07-22 23:45:05 +05:30
parent 02a549bd05
commit 8f51d16c7d
10 changed files with 290 additions and 5 deletions
@@ -0,0 +1,53 @@
"""Phase 1 client-consultant referral and communication linkage.
Revision ID: 20260722_phase1_client_consultant_linkage
Revises: 20260720_document_vps_auto_cleanup
"""
from alembic import op
import sqlalchemy as sa
revision = "20260722_phase1_client_consultant_linkage"
down_revision = "20260720_document_vps_auto_cleanup"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("clients") as batch:
batch.add_column(sa.Column("referred_by_consultant_id", sa.Integer(), nullable=True))
batch.add_column(sa.Column("referral_date", sa.Date(), nullable=True))
batch.add_column(sa.Column("referral_reference", sa.String(length=200), nullable=True))
batch.add_column(sa.Column("referral_status", sa.String(length=30), nullable=False, server_default="active"))
batch.add_column(sa.Column("communication_routing_mode", sa.String(length=30), nullable=False, server_default="client_and_consultant"))
batch.create_foreign_key("fk_clients_referred_by_consultant", "consultant_profiles", ["referred_by_consultant_id"], ["id"], ondelete="SET NULL")
batch.create_index("ix_clients_referred_by_consultant_id", ["referred_by_consultant_id"], unique=False)
batch.create_index("ix_clients_referral_status", ["referral_status"], unique=False)
batch.create_index("ix_clients_communication_routing_mode", ["communication_routing_mode"], unique=False)
additions = [
("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),
]
with op.batch_alter_table("client_consultant_links") as batch:
for name, default in additions:
batch.add_column(sa.Column(name, sa.Boolean(), nullable=False, server_default=sa.true() if default else sa.false()))
batch.add_column(sa.Column("effective_from", sa.Date(), nullable=True))
batch.add_column(sa.Column("effective_to", sa.Date(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("client_consultant_links") as batch:
batch.drop_column("effective_to")
batch.drop_column("effective_from")
for name in ["can_act_for_client", "can_receive_notifications", "can_view_permanent_documents", "can_view_final_documents", "can_view_filing_details", "can_reply_to_clarifications", "can_upload_documents", "can_view_document_requests", "can_view_assignee", "can_view_task_status", "can_view_engagements"]:
batch.drop_column(name)
with op.batch_alter_table("clients") as batch:
batch.drop_index("ix_clients_communication_routing_mode")
batch.drop_index("ix_clients_referral_status")
batch.drop_index("ix_clients_referred_by_consultant_id")
batch.drop_constraint("fk_clients_referred_by_consultant", type_="foreignkey")
batch.drop_column("communication_routing_mode")
batch.drop_column("referral_status")
batch.drop_column("referral_reference")
batch.drop_column("referral_date")
batch.drop_column("referred_by_consultant_id")
+27
View File
@@ -12,11 +12,18 @@ from sqlalchemy.orm import Session
from app.modules.clients import repository from app.modules.clients import repository
from app.modules.clients.schemas import ClientCreate from app.modules.clients.schemas import ClientCreate
from app.modules.clients.service import create_client_service from app.modules.clients.service import create_client_service
from app.modules.consultants.service import get_consultant
TEMPLATE_COLUMNS = [ TEMPLATE_COLUMNS = [
"uploader_user_id", "uploader_user_id",
"firm_tenant_id", "firm_tenant_id",
"partner_user_id", "partner_user_id",
"referred_by_consultant_id",
"primary_consultant_id",
"referral_date",
"referral_reference",
"referral_status",
"communication_routing_mode",
"branch_id", "branch_id",
"client_code", "client_code",
"client_name", "client_name",
@@ -104,6 +111,9 @@ def build_client_import_template_bytes(*, current_user, tenant_id: int, partner_
ref.append(['firm_tenant_id', 'Must match the active firm/tenant context of the upload.']) ref.append(['firm_tenant_id', 'Must match the active firm/tenant context of the upload.'])
ref.append(['partner_user_id', 'Must be an active Partner user mapped to the same firm.']) ref.append(['partner_user_id', 'Must be an active Partner user mapped to the same firm.'])
ref.append(['branch_id', 'Optional. If blank, uploader branch or partner branch will be used.']) ref.append(['branch_id', 'Optional. If blank, uploader branch or partner branch will be used.'])
ref.append(['referred_by_consultant_id', 'Optional active consultant profile id who introduced the client.'])
ref.append(['primary_consultant_id', 'Optional active consultant profile id for the operational client link.'])
ref.append(['communication_routing_mode', 'client_direct, consultant_primary, client_and_consultant, or firm_only.'])
ref.append(['email', 'Used as the client frontend login email.']) ref.append(['email', 'Used as the client frontend login email.'])
ref.append(['portal_password', 'Minimum 8 characters.']) ref.append(['portal_password', 'Minimum 8 characters.'])
ref.append(['portal_password_confirm', 'Must match portal_password.']) ref.append(['portal_password_confirm', 'Must match portal_password.'])
@@ -174,10 +184,27 @@ def build_preview(db: Session, *, current_user, scope, role_names: set[str], upl
if not branch_id: if not branch_id:
msgs.append('branch_id is required when uploader and partner have no branch mapped.') msgs.append('branch_id is required when uploader and partner have no branch mapped.')
for consultant_field in ("referred_by_consultant_id", "primary_consultant_id"):
raw_consultant_id = cleaned.get(consultant_field)
if raw_consultant_id:
try:
consultant_id = int(raw_consultant_id)
except Exception:
consultant_id = 0
consultant = get_consultant(db, tenant_id=firm_tenant_id, consultant_id=consultant_id) if consultant_id else None
if not consultant or not consultant.is_active:
msgs.append(f"{consultant_field} must be an active consultant profile id in the same firm.")
payload = { payload = {
'tenant_id': firm_tenant_id, 'tenant_id': firm_tenant_id,
'branch_id': branch_id, 'branch_id': branch_id,
'partner_id': partner_user_id or None, 'partner_id': partner_user_id or None,
'referred_by_consultant_id': int(cleaned.get('referred_by_consultant_id')) if cleaned.get('referred_by_consultant_id') else None,
'primary_consultant_id': int(cleaned.get('primary_consultant_id')) if cleaned.get('primary_consultant_id') else None,
'referral_date': cleaned.get('referral_date'),
'referral_reference': cleaned.get('referral_reference'),
'referral_status': cleaned.get('referral_status') or 'active',
'communication_routing_mode': cleaned.get('communication_routing_mode') or 'client_and_consultant',
'engagement_mode': cleaned.get('engagement_mode') or 'internal_managed', 'engagement_mode': cleaned.get('engagement_mode') or 'internal_managed',
'client_code': cleaned.get('client_code') or '', 'client_code': cleaned.get('client_code') or '',
'client_name': cleaned.get('client_name') or '', 'client_name': cleaned.get('client_name') or '',
+5
View File
@@ -21,6 +21,11 @@ class Client(CommonBase):
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True) branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), nullable=False, index=True)
partner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) partner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
default_review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) default_review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
referred_by_consultant_id: Mapped[int | None] = mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True, index=True)
referral_date: Mapped[date | None] = mapped_column(Date, nullable=True)
referral_reference: Mapped[str | None] = mapped_column(String(200), nullable=True)
referral_status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
communication_routing_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="client_and_consultant", index=True)
engagement_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="internal_managed", index=True) engagement_mode: Mapped[str] = mapped_column(String(30), nullable=False, default="internal_managed", index=True)
client_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) client_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True) client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
+53 -2
View File
@@ -22,6 +22,12 @@ class ClientBase(BaseModel):
branch_id: int branch_id: int
partner_id: Optional[int] = None partner_id: Optional[int] = None
default_review_partner_user_id: Optional[int] = None default_review_partner_user_id: Optional[int] = None
referred_by_consultant_id: Optional[int] = None
primary_consultant_id: Optional[int] = None
referral_date: Optional[date] = None
referral_reference: Optional[str] = None
referral_status: str = "active"
communication_routing_mode: str = "client_and_consultant"
engagement_mode: str = "internal_managed" engagement_mode: str = "internal_managed"
client_code: str client_code: str
client_name: str client_name: str
@@ -82,7 +88,7 @@ class ClientBase(BaseModel):
@field_validator( @field_validator(
"trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation", "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation",
"address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "acceptance_review_notes", "acceptance_rejection_reason", "notes", "address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "referral_reference", "acceptance_review_notes", "acceptance_rejection_reason", "notes",
mode="before", mode="before",
) )
@classmethod @classmethod
@@ -94,6 +100,25 @@ class ClientBase(BaseModel):
def uppercase_codes(cls, value): def uppercase_codes(cls, value):
return normalize_upper(value) return normalize_upper(value)
@field_validator("referral_status", "communication_routing_mode", mode="before")
@classmethod
def clean_referral_fields(cls, value):
return (normalize_text(value) or "").lower()
@field_validator("referral_status")
@classmethod
def validate_referral_status(cls, value):
if value not in {"active", "inactive", "ended"}:
raise ValueError("Invalid referral status.")
return value
@field_validator("communication_routing_mode")
@classmethod
def validate_communication_routing_mode(cls, value):
if value not in {"client_direct", "consultant_primary", "client_and_consultant", "firm_only"}:
raise ValueError("Invalid communication routing mode.")
return value
@field_validator("engagement_mode", mode="before") @field_validator("engagement_mode", mode="before")
@classmethod @classmethod
def clean_engagement_mode(cls, value): def clean_engagement_mode(cls, value):
@@ -189,6 +214,12 @@ class ClientUpdate(BaseModel):
branch_id: Optional[int] = None branch_id: Optional[int] = None
partner_id: Optional[int] = None partner_id: Optional[int] = None
default_review_partner_user_id: Optional[int] = None default_review_partner_user_id: Optional[int] = None
referred_by_consultant_id: Optional[int] = None
primary_consultant_id: Optional[int] = None
referral_date: Optional[date] = None
referral_reference: Optional[str] = None
referral_status: Optional[str] = None
communication_routing_mode: Optional[str] = None
engagement_mode: Optional[str] = None engagement_mode: Optional[str] = None
client_name: Optional[str] = None client_name: Optional[str] = None
trade_name: Optional[str] = None trade_name: Optional[str] = None
@@ -241,7 +272,7 @@ class ClientUpdate(BaseModel):
@field_validator( @field_validator(
"client_name", "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation", "client_name", "trade_name", "cin_llpin", "msme_no", "iec_code", "contact_person_name", "contact_person_designation",
"address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "acceptance_review_notes", "acceptance_rejection_reason", "notes", "address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "referral_reference", "acceptance_review_notes", "acceptance_rejection_reason", "notes",
mode="before", mode="before",
) )
@classmethod @classmethod
@@ -253,6 +284,26 @@ class ClientUpdate(BaseModel):
def uppercase_codes(cls, value): def uppercase_codes(cls, value):
return normalize_upper(value) return normalize_upper(value)
@field_validator("referral_status", "communication_routing_mode", mode="before")
@classmethod
def clean_optional_referral_fields(cls, value):
value = normalize_text(value)
return value.lower() if value else None
@field_validator("referral_status")
@classmethod
def validate_optional_referral_status(cls, value):
if value is not None and value not in {"active", "inactive", "ended"}:
raise ValueError("Invalid referral status.")
return value
@field_validator("communication_routing_mode")
@classmethod
def validate_optional_communication_routing_mode(cls, value):
if value is not None and value not in {"client_direct", "consultant_primary", "client_and_consultant", "firm_only"}:
raise ValueError("Invalid communication routing mode.")
return value
@field_validator("engagement_mode", mode="before") @field_validator("engagement_mode", mode="before")
@classmethod @classmethod
def clean_engagement_mode(cls, value): def clean_engagement_mode(cls, value):
+7 -1
View File
@@ -29,11 +29,15 @@ from app.modules.alerts.service import create_alert
from app.modules.core.iam.models import User from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole from app.modules.core.rbac.models import Role, UserRole
from app.modules.documents.models import PermanentClientDocument from app.modules.documents.models import PermanentClientDocument
from app.modules.consultants.service import sync_primary_client_consultant_link
def _payload_from_schema(data): def _payload_from_schema(data):
return data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data.dict(exclude_none=True) payload = data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data.dict(exclude_none=True)
# primary_consultant_id belongs to ClientConsultantLink, not the clients table.
payload.pop("primary_consultant_id", None)
return payload
@@ -269,6 +273,7 @@ def create_client_service(db, *, data, actor_user_id: int, scope, current_user_r
_enforce_client_acceptance_controls(payload) _enforce_client_acceptance_controls(payload)
row = repository.create_client(db, payload) row = repository.create_client(db, payload)
row = _sync_client_portal_user(db, row=row, portal_password=portal_password, portal_password_confirm=portal_password_confirm) row = _sync_client_portal_user(db, row=row, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
sync_primary_client_consultant_link(db, client=row, consultant_id=getattr(data, "primary_consultant_id", None), actor_user_id=actor_user_id)
_write_association_from_client(db, row, actor_user_id=actor_user_id, current_user_roles=current_user_roles) _write_association_from_client(db, row, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
repository.write_audit_log( repository.write_audit_log(
@@ -309,6 +314,7 @@ def update_client_service(db, *, row, data, actor_user_id: int, scope, current_u
updated = repository.update_client(db, row, payload) updated = repository.update_client(db, row, payload)
updated = _sync_client_portal_user(db, row=updated, portal_password=portal_password, portal_password_confirm=portal_password_confirm) updated = _sync_client_portal_user(db, row=updated, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
sync_primary_client_consultant_link(db, client=updated, consultant_id=getattr(data, "primary_consultant_id", None), actor_user_id=actor_user_id)
_write_association_from_client(db, updated, actor_user_id=actor_user_id, current_user_roles=current_user_roles) _write_association_from_client(db, updated, actor_user_id=actor_user_id, current_user_roles=current_user_roles)
repository.write_audit_log( repository.write_audit_log(
@@ -116,7 +116,10 @@
<div><span class="font-medium">Branch:</span> {{ row.branch_id or '-' }}</div> <div><span class="font-medium">Branch:</span> {{ row.branch_id or '-' }}</div>
<div><span class="font-medium">Partner:</span> {{ row.assoc_partner_user_id or row.partner_id or '-' }}</div> <div><span class="font-medium">Partner:</span> {{ row.assoc_partner_user_id or row.partner_id or '-' }}</div>
<div><span class="font-medium">Default Review Partner:</span> {{ row.default_review_partner_user_id or '-' }}</div> <div><span class="font-medium">Default Review Partner:</span> {{ row.default_review_partner_user_id or '-' }}</div>
<div><span class="font-medium">Consultant:</span> {{ row.assoc_consultant_id or '-' }}</div> <div><span class="font-medium">Referred by:</span> {{ consultant_summary.referred_by.contact_person if consultant_summary and consultant_summary.referred_by else 'Direct / Not recorded' }}</div>
<div><span class="font-medium">Primary consultant:</span> {{ consultant_summary.primary.contact_person if consultant_summary and consultant_summary.primary else 'Firm managed' }}</div>
<div><span class="font-medium">Communication:</span> {{ (row.communication_routing_mode or 'client_and_consultant')|replace('_', ' ')|title }}</div>
<div><span class="font-medium">Referral status:</span> {{ (row.referral_status or 'active')|title }}</div>
</div> </div>
</section> </section>
</div> </div>
@@ -255,6 +255,49 @@
<div class="md:col-span-2 border-t border-slate-200 pt-4">
<h4 class="text-sm font-semibold text-slate-900">Consultant Referral & Communication</h4>
<p class="mt-1 text-xs text-slate-500">Referral records who introduced the client. Primary consultant controls the operational client link.</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Referred By Consultant</label>
<select name="referred_by_consultant_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">-- Direct / No consultant --</option>
{% for c in form_options.consultants or [] %}
<option value="{{ c.id }}" {% if (form_data.referred_by_consultant_id or (row.referred_by_consultant_id if is_edit else None)) == c.id %}selected{% endif %}>{{ c.contact_person }}{% if c.firm_name %} — {{ c.firm_name }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Primary Consultant</label>
<select name="primary_consultant_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">-- Firm managed only --</option>
{% for c in form_options.consultants or [] %}
<option value="{{ c.id }}" {% if form_data.primary_consultant_id == c.id %}selected{% endif %}>{{ c.contact_person }}{% if c.firm_name %} — {{ c.firm_name }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Referral Date</label>
<input type="date" name="referral_date" value="{{ form_data.referral_date or (row.referral_date if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Referral Status</label>
<select name="referral_status" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
{% for value, label in [('active','Active'),('inactive','Inactive'),('ended','Ended')] %}<option value="{{ value }}" {% if (form_data.referral_status or (row.referral_status if is_edit else 'active')) == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Communication Routing</label>
<select name="communication_routing_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
{% for value, label in [('client_direct','Client Direct'),('consultant_primary','Primary Consultant'),('client_and_consultant','Client and Consultant'),('firm_only','Firm Only')] %}<option value="{{ value }}" {% if (form_data.communication_routing_mode or (row.communication_routing_mode if is_edit else 'client_and_consultant')) == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Referral Reference</label>
<input name="referral_reference" value="{{ form_data.referral_reference or (row.referral_reference if is_edit else '') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Source, campaign, agreement or note">
</div>
<div> <div>
<label class="block text-sm font-medium text-slate-700">Default Review Partner</label> <label class="block text-sm font-medium text-slate-700">Default Review Partner</label>
<select name="default_review_partner_user_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"> <select name="default_review_partner_user_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
+13 -1
View File
@@ -76,6 +76,7 @@ from app.modules.billing.client_portal_service import (
from app.modules.billing.services import build_invoice_print_context, create_cashfree_transaction, create_payumoney_transaction, process_cashfree_return, process_cashfree_webhook, process_payumoney_response from app.modules.billing.services import build_invoice_print_context, create_cashfree_transaction, create_payumoney_transaction, process_cashfree_return, process_cashfree_webhook, process_payumoney_response
from app.modules.email_integration.services import send_auth_otp_email from app.modules.email_integration.services import send_auth_otp_email
from app.modules.documents.models import PermanentClientDocument from app.modules.documents.models import PermanentClientDocument
from app.modules.consultants.service import list_consultants, get_primary_client_consultant_link, get_client_consultant_summary
from app.modules.documents.services import ( from app.modules.documents.services import (
get_permanent_version, get_permanent_version,
get_version, get_version,
@@ -173,6 +174,12 @@ def _build_form_payload(request: Request, user, scope, *, include_client_code: b
"branch_id": branch_id, "branch_id": branch_id,
"partner_id": partner_id, "partner_id": partner_id,
"default_review_partner_user_id": int(form.get("default_review_partner_user_id")) if form.get("default_review_partner_user_id") not in (None, "", "None") else None, "default_review_partner_user_id": int(form.get("default_review_partner_user_id")) if form.get("default_review_partner_user_id") not in (None, "", "None") else None,
"referred_by_consultant_id": int(form.get("referred_by_consultant_id")) if form.get("referred_by_consultant_id") not in (None, "", "None") else None,
"primary_consultant_id": int(form.get("primary_consultant_id")) if form.get("primary_consultant_id") not in (None, "", "None") else None,
"referral_date": form.get("referral_date") or None,
"referral_reference": form.get("referral_reference"),
"referral_status": form.get("referral_status") or "active",
"communication_routing_mode": form.get("communication_routing_mode") or "client_and_consultant",
"engagement_mode": form.get("engagement_mode") or "internal_managed", "engagement_mode": form.get("engagement_mode") or "internal_managed",
"client_name": form.get("client_name", ""), "client_name": form.get("client_name", ""),
"trade_name": form.get("trade_name"), "trade_name": form.get("trade_name"),
@@ -252,6 +259,7 @@ def _form_options(db, scope, form_mode: str):
"review_partners": repository.list_all_partners(db), "review_partners": repository.list_all_partners(db),
"active_tenant_id": tenant_id, "active_tenant_id": tenant_id,
"active_branch_id": branch_id, "active_branch_id": branch_id,
"consultants": list_consultants(db, tenant_id=tenant_id, include_inactive=False) if tenant_id else [],
} }
return { return {
@@ -262,6 +270,7 @@ def _form_options(db, scope, form_mode: str):
tenant_id=tenant_id, tenant_id=tenant_id,
branch_id=None if scope.allow_cross_branch else branch_id, branch_id=None if scope.allow_cross_branch else branch_id,
), ),
"consultants": list_consultants(db, tenant_id=tenant_id, branch_id=branch_id, include_inactive=False),
"review_partners": repository.list_partners_for_scope( "review_partners": repository.list_partners_for_scope(
db, db,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -572,6 +581,8 @@ def client_new_page(request: Request):
"acceptance_status": "pending_review", "acceptance_status": "pending_review",
"acceptance_required": True, "acceptance_required": True,
"engagement_letter_required": True, "engagement_letter_required": True,
"referral_status": "active",
"communication_routing_mode": "client_and_consultant",
} }
return _render( return _render(
@@ -685,6 +696,7 @@ def client_detail(request: Request, client_id: int):
can_activate=has("clients.activate"), can_activate=has("clients.activate"),
can_archive=has("clients.archive"), can_archive=has("clients.archive"),
can_restore=has("clients.restore"), can_restore=has("clients.restore"),
consultant_summary=get_client_consultant_summary(db, tenant_id=int(row["tenant_id"]), client_id=client_id),
can_manage_acceptance=has("clients.acceptance.manage"), can_manage_acceptance=has("clients.acceptance.manage"),
can_approve_acceptance=has("clients.acceptance.approve"), can_approve_acceptance=has("clients.acceptance.approve"),
) )
@@ -725,7 +737,7 @@ def client_edit_page(request: Request, client_id: int):
user, user,
title=f"Edit Client • {row.client_name}", title=f"Edit Client • {row.client_name}",
row=row, row=row,
form_data=row, form_data={**row.__dict__, "primary_consultant_id": getattr(get_primary_client_consultant_link(db, tenant_id=row.tenant_id, client_id=row.id), "consultant_id", None)},
form_errors=[], form_errors=[],
scope=scope, scope=scope,
form_options=_form_options(db, scope, form_mode), form_options=_form_options(db, scope, form_mode),
+13
View File
@@ -162,6 +162,19 @@ class ClientConsultantLink(CommonBase):
can_view_services: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) can_view_services: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_view_due_dates: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) can_view_due_dates: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_view_communications: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) can_view_communications: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_view_engagements: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_view_task_status: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_view_assignee: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_view_document_requests: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_upload_documents: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_reply_to_clarifications: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_view_filing_details: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_view_final_documents: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_view_permanent_documents: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
can_receive_notifications: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
can_act_for_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
effective_from: Mapped[date | None] = mapped_column(Date, nullable=True)
effective_to: Mapped[date | None] = mapped_column(Date, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
remarks: Mapped[str | None] = mapped_column(Text, nullable=True) remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
+72
View File
@@ -1388,3 +1388,75 @@ def reject_managed_client_conversion(
managed_client.conversion_firm_notes = normalise_text(firm_notes) managed_client.conversion_firm_notes = normalise_text(firm_notes)
managed_client.updated_by_user_id = user_id managed_client.updated_by_user_id = user_id
return managed_client 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,
}