Add phase 1 client consultant referral and linkage
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user