Add phase 1 client consultant referral and linkage
This commit is contained in:
@@ -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")
|
||||
@@ -12,11 +12,18 @@ from sqlalchemy.orm import Session
|
||||
from app.modules.clients import repository
|
||||
from app.modules.clients.schemas import ClientCreate
|
||||
from app.modules.clients.service import create_client_service
|
||||
from app.modules.consultants.service import get_consultant
|
||||
|
||||
TEMPLATE_COLUMNS = [
|
||||
"uploader_user_id",
|
||||
"firm_tenant_id",
|
||||
"partner_user_id",
|
||||
"referred_by_consultant_id",
|
||||
"primary_consultant_id",
|
||||
"referral_date",
|
||||
"referral_reference",
|
||||
"referral_status",
|
||||
"communication_routing_mode",
|
||||
"branch_id",
|
||||
"client_code",
|
||||
"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(['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(['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(['portal_password', 'Minimum 8 characters.'])
|
||||
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:
|
||||
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 = {
|
||||
'tenant_id': firm_tenant_id,
|
||||
'branch_id': branch_id,
|
||||
'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',
|
||||
'client_code': cleaned.get('client_code') or '',
|
||||
'client_name': cleaned.get('client_name') or '',
|
||||
|
||||
@@ -21,6 +21,11 @@ class Client(CommonBase):
|
||||
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)
|
||||
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)
|
||||
client_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
client_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
|
||||
|
||||
@@ -22,6 +22,12 @@ class ClientBase(BaseModel):
|
||||
branch_id: int
|
||||
partner_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"
|
||||
client_code: str
|
||||
client_name: str
|
||||
@@ -82,7 +88,7 @@ class ClientBase(BaseModel):
|
||||
|
||||
@field_validator(
|
||||
"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",
|
||||
)
|
||||
@classmethod
|
||||
@@ -94,6 +100,25 @@ class ClientBase(BaseModel):
|
||||
def uppercase_codes(cls, 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")
|
||||
@classmethod
|
||||
def clean_engagement_mode(cls, value):
|
||||
@@ -189,6 +214,12 @@ class ClientUpdate(BaseModel):
|
||||
branch_id: Optional[int] = None
|
||||
partner_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
|
||||
client_name: Optional[str] = None
|
||||
trade_name: Optional[str] = None
|
||||
@@ -241,7 +272,7 @@ class ClientUpdate(BaseModel):
|
||||
|
||||
@field_validator(
|
||||
"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",
|
||||
)
|
||||
@classmethod
|
||||
@@ -253,6 +284,26 @@ class ClientUpdate(BaseModel):
|
||||
def uppercase_codes(cls, 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")
|
||||
@classmethod
|
||||
def clean_engagement_mode(cls, value):
|
||||
|
||||
@@ -29,11 +29,15 @@ from app.modules.alerts.service import create_alert
|
||||
from app.modules.core.iam.models import User
|
||||
from app.modules.core.rbac.models import Role, UserRole
|
||||
from app.modules.documents.models import PermanentClientDocument
|
||||
from app.modules.consultants.service import sync_primary_client_consultant_link
|
||||
|
||||
|
||||
|
||||
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)
|
||||
row = repository.create_client(db, payload)
|
||||
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)
|
||||
|
||||
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 = _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)
|
||||
|
||||
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">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">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>
|
||||
</section>
|
||||
</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>
|
||||
<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">
|
||||
|
||||
@@ -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.email_integration.services import send_auth_otp_email
|
||||
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 (
|
||||
get_permanent_version,
|
||||
get_version,
|
||||
@@ -173,6 +174,12 @@ def _build_form_payload(request: Request, user, scope, *, include_client_code: b
|
||||
"branch_id": branch_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,
|
||||
"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",
|
||||
"client_name": form.get("client_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),
|
||||
"active_tenant_id": tenant_id,
|
||||
"active_branch_id": branch_id,
|
||||
"consultants": list_consultants(db, tenant_id=tenant_id, include_inactive=False) if tenant_id else [],
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -262,6 +270,7 @@ def _form_options(db, scope, form_mode: str):
|
||||
tenant_id=tenant_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(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
@@ -572,6 +581,8 @@ def client_new_page(request: Request):
|
||||
"acceptance_status": "pending_review",
|
||||
"acceptance_required": True,
|
||||
"engagement_letter_required": True,
|
||||
"referral_status": "active",
|
||||
"communication_routing_mode": "client_and_consultant",
|
||||
}
|
||||
|
||||
return _render(
|
||||
@@ -685,6 +696,7 @@ def client_detail(request: Request, client_id: int):
|
||||
can_activate=has("clients.activate"),
|
||||
can_archive=has("clients.archive"),
|
||||
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_approve_acceptance=has("clients.acceptance.approve"),
|
||||
)
|
||||
@@ -725,7 +737,7 @@ def client_edit_page(request: Request, client_id: int):
|
||||
user,
|
||||
title=f"Edit Client • {row.client_name}",
|
||||
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=[],
|
||||
scope=scope,
|
||||
form_options=_form_options(db, scope, form_mode),
|
||||
|
||||
@@ -162,6 +162,19 @@ class ClientConsultantLink(CommonBase):
|
||||
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_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)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
@@ -1388,3 +1388,75 @@ def reject_managed_client_conversion(
|
||||
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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user