From 8f51d16c7d0a767acc131d3862af74554f94d763 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Wed, 22 Jul 2026 23:45:05 +0530 Subject: [PATCH] Add phase 1 client consultant referral and linkage --- ...260722_phase1_client_consultant_linkage.py | 53 ++++++++++++++ app/modules/clients/import_service.py | 27 +++++++ app/modules/clients/models.py | 5 ++ app/modules/clients/schemas.py | 55 +++++++++++++- app/modules/clients/service.py | 8 ++- .../clients/templates/clients/detail.html | 5 +- .../templates/clients/partials/form.html | 43 +++++++++++ app/modules/clients/ui.py | 14 +++- app/modules/consultants/models.py | 13 ++++ app/modules/consultants/service.py | 72 +++++++++++++++++++ 10 files changed, 290 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/20260722_phase1_client_consultant_linkage.py diff --git a/alembic/versions/20260722_phase1_client_consultant_linkage.py b/alembic/versions/20260722_phase1_client_consultant_linkage.py new file mode 100644 index 0000000..952443c --- /dev/null +++ b/alembic/versions/20260722_phase1_client_consultant_linkage.py @@ -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") diff --git a/app/modules/clients/import_service.py b/app/modules/clients/import_service.py index 216b9d9..f5481b3 100644 --- a/app/modules/clients/import_service.py +++ b/app/modules/clients/import_service.py @@ -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 '', diff --git a/app/modules/clients/models.py b/app/modules/clients/models.py index cb4e7d0..ea350f3 100644 --- a/app/modules/clients/models.py +++ b/app/modules/clients/models.py @@ -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) diff --git a/app/modules/clients/schemas.py b/app/modules/clients/schemas.py index 86c8834..ea33e73 100644 --- a/app/modules/clients/schemas.py +++ b/app/modules/clients/schemas.py @@ -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): diff --git a/app/modules/clients/service.py b/app/modules/clients/service.py index aaa405e..df8c47b 100644 --- a/app/modules/clients/service.py +++ b/app/modules/clients/service.py @@ -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( diff --git a/app/modules/clients/templates/clients/detail.html b/app/modules/clients/templates/clients/detail.html index d97341d..6d74ad4 100644 --- a/app/modules/clients/templates/clients/detail.html +++ b/app/modules/clients/templates/clients/detail.html @@ -116,7 +116,10 @@
Branch: {{ row.branch_id or '-' }}
Partner: {{ row.assoc_partner_user_id or row.partner_id or '-' }}
Default Review Partner: {{ row.default_review_partner_user_id or '-' }}
-
Consultant: {{ row.assoc_consultant_id or '-' }}
+
Referred by: {{ consultant_summary.referred_by.contact_person if consultant_summary and consultant_summary.referred_by else 'Direct / Not recorded' }}
+
Primary consultant: {{ consultant_summary.primary.contact_person if consultant_summary and consultant_summary.primary else 'Firm managed' }}
+
Communication: {{ (row.communication_routing_mode or 'client_and_consultant')|replace('_', ' ')|title }}
+
Referral status: {{ (row.referral_status or 'active')|title }}
diff --git a/app/modules/clients/templates/clients/partials/form.html b/app/modules/clients/templates/clients/partials/form.html index f0e88f8..5bc4237 100644 --- a/app/modules/clients/templates/clients/partials/form.html +++ b/app/modules/clients/templates/clients/partials/form.html @@ -255,6 +255,49 @@ +
+

Consultant Referral & Communication

+

Referral records who introduced the client. Primary consultant controls the operational client link.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+