Add client groups and family tracking

This commit is contained in:
A R R R Associates
2026-07-25 22:32:35 +05:30
parent 5e6584702c
commit 2dc2a712e8
21 changed files with 444 additions and 161 deletions
+1
View File
@@ -33,6 +33,7 @@ from app.modules.core.audit import models as audit_models # noqa: F401
# Business module models # Business module models
from app.modules.clients import models as client_models # noqa: F401 from app.modules.clients import models as client_models # noqa: F401
from app.modules.client_groups import models as client_group_models # noqa: F401
from app.modules.clients import association_models as client_association_models # noqa: F401 from app.modules.clients import association_models as client_association_models # noqa: F401
from app.modules.services import models as service_models # noqa: F401 from app.modules.services import models as service_models # noqa: F401
from app.modules.consultants import models as consultant_models # noqa: F401 from app.modules.consultants import models as consultant_models # noqa: F401
@@ -0,0 +1,46 @@
"""Client groups and family tracking.
Revision ID: 20260725_client_groups_family_tracking
Revises: 20260723_phase6_pan_login_client_identity
"""
from alembic import op
import sqlalchemy as sa
revision = "20260725_client_groups_family_tracking"
down_revision = "20260723_phase6_pan_login_client_identity"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"client_groups",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("group_code", sa.String(50), nullable=False),
sa.Column("group_name", sa.String(200), nullable=False),
sa.Column("group_type", sa.String(50), nullable=False, server_default="Family"),
sa.Column("primary_contact_name", sa.String(200)), sa.Column("primary_contact_mobile", sa.String(20)), sa.Column("primary_contact_email", sa.String(255)),
sa.Column("assigned_partner_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("primary_consultant_id", sa.Integer(), sa.ForeignKey("consultant_profiles.id", ondelete="SET NULL")),
sa.Column("notes", sa.Text()), sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")), sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL")),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("tenant_id", "group_code", name="uq_client_groups_tenant_code"),
)
for c in ("tenant_id","group_code","group_name","group_type","assigned_partner_user_id","primary_consultant_id","is_active"):
op.create_index(f"ix_client_groups_{c}", "client_groups", [c])
op.add_column("clients", sa.Column("client_group_id", sa.Integer(), nullable=True))
op.add_column("clients", sa.Column("group_relationship", sa.String(100), nullable=True))
op.add_column("clients", sa.Column("is_group_head", sa.Boolean(), nullable=False, server_default=sa.false()))
op.create_foreign_key("fk_clients_client_group_id", "clients", "client_groups", ["client_group_id"], ["id"], ondelete="SET NULL")
op.create_index("ix_clients_client_group_id", "clients", ["client_group_id"])
op.create_index("ix_clients_is_group_head", "clients", ["is_group_head"])
def downgrade():
op.drop_index("ix_clients_is_group_head", table_name="clients")
op.drop_index("ix_clients_client_group_id", table_name="clients")
op.drop_constraint("fk_clients_client_group_id", "clients", type_="foreignkey")
op.drop_column("clients", "is_group_head"); op.drop_column("clients", "group_relationship"); op.drop_column("clients", "client_group_id")
op.drop_table("client_groups")
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
class ClientGroup(CommonBase):
__tablename__ = "client_groups"
__table_args__ = (
UniqueConstraint("tenant_id", "group_code", name="uq_client_groups_tenant_code"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
group_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
group_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
group_type: Mapped[str] = mapped_column(String(50), nullable=False, default="Family", index=True)
primary_contact_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
primary_contact_mobile: Mapped[str | None] = mapped_column(String(20), nullable=True)
primary_contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
assigned_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
primary_consultant_id: Mapped[int | None] = mapped_column(ForeignKey("consultant_profiles.id", ondelete="SET NULL"), nullable=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.modules.client_groups.models import ClientGroup
from app.modules.clients.models import Client
GROUP_TYPES = ("Family", "Business Group", "Promoter Group", "Trust Group", "Common Management", "Other")
def normalise_group_code(value: str | None) -> str:
return (value or "").strip().upper()
def list_groups(db: Session, *, tenant_id: int, include_inactive: bool = False):
stmt = (
select(ClientGroup, func.count(Client.id).label("client_count"))
.outerjoin(Client, Client.client_group_id == ClientGroup.id)
.where(ClientGroup.tenant_id == tenant_id)
.group_by(ClientGroup.id)
.order_by(ClientGroup.group_name.asc())
)
if not include_inactive:
stmt = stmt.where(ClientGroup.is_active.is_(True))
return [{"group": group, "client_count": int(count or 0)} for group, count in db.execute(stmt).all()]
def get_group(db: Session, *, tenant_id: int, group_id: int):
return db.execute(select(ClientGroup).where(ClientGroup.id == group_id, ClientGroup.tenant_id == tenant_id)).scalar_one_or_none()
def get_group_by_code(db: Session, *, tenant_id: int, group_code: str):
code = normalise_group_code(group_code)
if not code:
return None
return db.execute(select(ClientGroup).where(ClientGroup.tenant_id == tenant_id, ClientGroup.group_code == code)).scalar_one_or_none()
def list_group_clients(db: Session, *, tenant_id: int, group_id: int):
return db.execute(select(Client).where(Client.tenant_id == tenant_id, Client.client_group_id == group_id, Client.is_archived.is_(False)).order_by(Client.is_group_head.desc(), Client.client_name.asc())).scalars().all()
def create_group(db: Session, *, tenant_id: int, actor_user_id: int, payload: dict):
code = normalise_group_code(payload.get("group_code"))
name = (payload.get("group_name") or "").strip()
if not code or not name:
raise ValueError("Group code and group name are required.")
if get_group_by_code(db, tenant_id=tenant_id, group_code=code):
raise ValueError("Client group code already exists in this firm.")
group_type = (payload.get("group_type") or "Family").strip()
if group_type not in GROUP_TYPES:
raise ValueError("Invalid group type.")
row = ClientGroup(
tenant_id=tenant_id, group_code=code, group_name=name, group_type=group_type,
primary_contact_name=(payload.get("primary_contact_name") or "").strip() or None,
primary_contact_mobile=(payload.get("primary_contact_mobile") or "").strip() or None,
primary_contact_email=(payload.get("primary_contact_email") or "").strip().lower() or None,
assigned_partner_user_id=payload.get("assigned_partner_user_id") or None,
primary_consultant_id=payload.get("primary_consultant_id") or None,
notes=(payload.get("notes") or "").strip() or None,
is_active=bool(payload.get("is_active", True)), created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id,
)
db.add(row); db.commit(); db.refresh(row); return row
def update_group(db: Session, *, row: ClientGroup, actor_user_id: int, payload: dict):
code = normalise_group_code(payload.get("group_code"))
name = (payload.get("group_name") or "").strip()
if not code or not name:
raise ValueError("Group code and group name are required.")
duplicate = get_group_by_code(db, tenant_id=row.tenant_id, group_code=code)
if duplicate and duplicate.id != row.id:
raise ValueError("Client group code already exists in this firm.")
group_type = (payload.get("group_type") or "Family").strip()
if group_type not in GROUP_TYPES:
raise ValueError("Invalid group type.")
for key, value in {
"group_code": code, "group_name": name, "group_type": group_type,
"primary_contact_name": (payload.get("primary_contact_name") or "").strip() or None,
"primary_contact_mobile": (payload.get("primary_contact_mobile") or "").strip() or None,
"primary_contact_email": (payload.get("primary_contact_email") or "").strip().lower() or None,
"assigned_partner_user_id": payload.get("assigned_partner_user_id") or None,
"primary_consultant_id": payload.get("primary_consultant_id") or None,
"notes": (payload.get("notes") or "").strip() or None,
"is_active": bool(payload.get("is_active", False)), "updated_by_user_id": actor_user_id,
}.items(): setattr(row, key, value)
db.add(row); db.commit(); db.refresh(row); return row
def resolve_or_create_group(db: Session, *, tenant_id: int, actor_user_id: int, group_code: str | None, group_name: str | None, group_type: str | None = None):
code = normalise_group_code(group_code)
name = (group_name or "").strip()
if not code and not name:
return None
if not code:
raise ValueError("client_group_code is required when client_group_name is supplied.")
existing = get_group_by_code(db, tenant_id=tenant_id, group_code=code)
if existing:
if name and existing.group_name.strip().lower() != name.lower():
raise ValueError(f"Client group code {code} already exists with name {existing.group_name}.")
return existing
if not name:
raise ValueError(f"Client group {code} does not exist; provide client_group_name to create it.")
return create_group(db, tenant_id=tenant_id, actor_user_id=actor_user_id, payload={"group_code": code, "group_name": name, "group_type": group_type or "Family", "is_active": True})
@@ -0,0 +1 @@
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="space-y-6"><div class="flex items-center justify-between"><div><div class="text-sm text-slate-500">{{ row.group_code }} · {{ row.group_type }}</div><h2 class="text-2xl font-semibold">{{ row.group_name }}</h2></div><a href="/client-groups/{{ row.id }}/edit" class="rounded-xl border px-4 py-2 text-sm">Edit Group</a></div><div class="grid gap-4 md:grid-cols-3"><div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs uppercase text-slate-500">Primary contact</div><div class="mt-2 font-medium">{{ row.primary_contact_name or '-' }}</div><div class="text-sm text-slate-500">{{ row.primary_contact_mobile or '' }} {{ row.primary_contact_email or '' }}</div></div><div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs uppercase text-slate-500">Members</div><div class="mt-2 text-3xl font-semibold">{{ clients|length }}</div></div><div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs uppercase text-slate-500">Status</div><div class="mt-2 font-medium">{{ 'Active' if row.is_active else 'Inactive' }}</div></div></div><div class="overflow-hidden rounded-2xl bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs uppercase text-slate-500">Client</th><th class="px-4 py-3 text-left text-xs uppercase text-slate-500">Relationship</th><th class="px-4 py-3 text-left text-xs uppercase text-slate-500">PAN</th><th></th></tr></thead><tbody class="divide-y divide-slate-100">{% for client in clients %}<tr><td class="px-4 py-3 text-sm"><div class="font-medium">{{ client.client_name }}</div><div class="text-xs text-slate-500">{{ client.client_code }}{% if client.is_group_head %} · Group Head{% endif %}</div></td><td class="px-4 py-3 text-sm">{{ client.group_relationship or '-' }}</td><td class="px-4 py-3 text-sm">{{ client.pan or '-' }}</td><td class="px-4 py-3 text-right"><a href="/clients/{{ client.id }}" class="text-brand-700">Open</a></td></tr>{% else %}<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">No clients linked.</td></tr>{% endfor %}</tbody></table></div></div>{% endblock %}
@@ -0,0 +1 @@
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="mx-auto max-w-5xl space-y-6"><div><h2 class="text-2xl font-semibold">{{ title }}</h2></div>{% for error in errors %}<div class="rounded-xl bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ error }}</div>{% endfor %}<form method="post" action="{% if row and row.id %}/client-groups/{{ row.id }}/edit{% else %}/client-groups{% endif %}" class="space-y-6 rounded-2xl bg-white p-6 shadow-soft"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><div class="grid gap-5 md:grid-cols-2"><label class="text-sm font-medium">Group Code<input name="group_code" required value="{{ row.group_code if row else '' }}" class="mt-1 w-full rounded-xl border px-4 py-2"></label><label class="text-sm font-medium">Group Name<input name="group_name" required value="{{ row.group_name if row else '' }}" class="mt-1 w-full rounded-xl border px-4 py-2"></label><label class="text-sm font-medium">Group Type<select name="group_type" class="mt-1 w-full rounded-xl border px-4 py-2">{% for value in group_types %}<option {% if row and row.group_type == value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select></label><label class="text-sm font-medium">Primary Contact<input name="primary_contact_name" value="{{ row.primary_contact_name if row else '' }}" class="mt-1 w-full rounded-xl border px-4 py-2"></label><label class="text-sm font-medium">Mobile<input name="primary_contact_mobile" value="{{ row.primary_contact_mobile if row else '' }}" class="mt-1 w-full rounded-xl border px-4 py-2"></label><label class="text-sm font-medium">Email<input type="email" name="primary_contact_email" value="{{ row.primary_contact_email if row else '' }}" class="mt-1 w-full rounded-xl border px-4 py-2"></label><label class="text-sm font-medium">Assigned Partner<select name="assigned_partner_user_id" class="mt-1 w-full rounded-xl border px-4 py-2"><option value="">-- None --</option>{% for p in partners %}<option value="{{ p.id }}" {% if row and row.assigned_partner_user_id == p.id %}selected{% endif %}>{{ p.full_name or p.email }}</option>{% endfor %}</select></label><label class="text-sm font-medium">Primary Consultant<select name="primary_consultant_id" class="mt-1 w-full rounded-xl border px-4 py-2"><option value="">-- None --</option>{% for c in consultants %}<option value="{{ c.id }}" {% if row and row.primary_consultant_id == c.id %}selected{% endif %}>{{ c.contact_person }}{% if c.firm_name %} — {{ c.firm_name }}{% endif %}</option>{% endfor %}</select></label></div><label class="block text-sm font-medium">Notes<textarea name="notes" rows="4" class="mt-1 w-full rounded-xl border px-4 py-2">{{ row.notes if row else '' }}</textarea></label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not row or row.is_active %}checked{% endif %}> Active</label><div><button class="rounded-xl bg-brand-600 px-5 py-2 text-white">Save Group</button></div></form></div>{% endblock %}
@@ -0,0 +1 @@
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="space-y-6"><div class="flex items-center justify-between"><div><h2 class="text-2xl font-semibold text-slate-900">Client Groups</h2><p class="text-sm text-slate-500">Track family, promoter and related business clients together.</p></div><a href="/client-groups/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white">Add Group</a></div><div class="overflow-hidden rounded-2xl bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Code</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Group</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Type</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Clients</th><th></th></tr></thead><tbody class="divide-y divide-slate-100">{% for item in groups %}<tr><td class="px-4 py-3 text-sm font-medium">{{ item.group.group_code }}</td><td class="px-4 py-3 text-sm"><div class="font-medium">{{ item.group.group_name }}</div><div class="text-xs text-slate-500">{{ item.group.primary_contact_name or '-' }}</div></td><td class="px-4 py-3 text-sm">{{ item.group.group_type }}</td><td class="px-4 py-3 text-sm">{{ item.client_count }}</td><td class="px-4 py-3 text-right"><a class="text-brand-700" href="/client-groups/{{ item.group.id }}">Open</a></td></tr>{% else %}<tr><td colspan="5" class="px-4 py-8 text-center text-sm text-slate-500">No client groups created.</td></tr>{% endfor %}</tbody></table></div></div>{% endblock %}
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from app.core.db import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.deps import get_current_user
from app.core.templating import templates
from app.modules.core.rbac.service import get_user_permissions, get_user_roles, require_permission
from app.modules.client_groups.service import GROUP_TYPES, create_group, get_group, list_group_clients, list_groups, update_group
from app.modules.clients.access import build_scope
from app.modules.clients import repository
from app.modules.consultants.service import list_consultants
router = APIRouter(prefix="/client-groups", tags=["client-groups-ui"])
def _ctx(request, user, db, **extra):
data={"request":request,"current_user":user,"current_user_roles":get_user_roles(db,user.id),"current_user_permissions":get_user_permissions(db,user.id),"csrf_token":get_or_create_csrf_token(request)}; data.update(extra); return data
def _payload(form):
def opt_int(name):
value=form.get(name); return int(value) if value not in (None,"","None") else None
return {"group_code":form.get("group_code"),"group_name":form.get("group_name"),"group_type":form.get("group_type") or "Family","primary_contact_name":form.get("primary_contact_name"),"primary_contact_mobile":form.get("primary_contact_mobile"),"primary_contact_email":form.get("primary_contact_email"),"assigned_partner_user_id":opt_int("assigned_partner_user_id"),"primary_consultant_id":opt_int("primary_consultant_id"),"notes":form.get("notes"),"is_active":form.get("is_active") in ("1","on","true","yes")}
@router.get("")
def groups_page(request: Request):
db=CommonSessionLocal()
try:
user=get_current_user(request,db=db)
if not user: return RedirectResponse('/login',303)
require_permission(db,user,'clients.view'); scope=build_scope(request,user,lambda code: True)
return templates.TemplateResponse('modules/client_groups/templates/client_groups/list.html',_ctx(request,user,db,title='Client Groups',groups=list_groups(db,tenant_id=scope.tenant_id,include_inactive=True)))
finally: db.close()
@router.get("/new")
def group_new(request: Request):
db=CommonSessionLocal()
try:
user=get_current_user(request,db=db)
if not user: return RedirectResponse('/login',303)
require_permission(db,user,'clients.create'); scope=build_scope(request,user,lambda code: True)
return templates.TemplateResponse('modules/client_groups/templates/client_groups/form.html',_ctx(request,user,db,title='Add Client Group',row=None,group_types=GROUP_TYPES,partners=repository.list_partners_for_scope(db,tenant_id=scope.tenant_id,branch_id=None),consultants=list_consultants(db,tenant_id=scope.tenant_id,include_inactive=False),errors=[]))
finally: db.close()
@router.post("")
async def group_create(request: Request):
db=CommonSessionLocal()
try:
user=get_current_user(request,db=db); form=await request.form(); validate_csrf(request,form.get('csrf_token'))
if not user: return RedirectResponse('/login',303)
require_permission(db,user,'clients.create'); scope=build_scope(request,user,lambda code: True)
try: row=create_group(db,tenant_id=scope.tenant_id,actor_user_id=user.id,payload=_payload(form)); return RedirectResponse(f'/client-groups/{row.id}',303)
except Exception as exc: return templates.TemplateResponse('modules/client_groups/templates/client_groups/form.html',_ctx(request,user,db,title='Add Client Group',row=_payload(form),group_types=GROUP_TYPES,partners=repository.list_partners_for_scope(db,tenant_id=scope.tenant_id,branch_id=None),consultants=list_consultants(db,tenant_id=scope.tenant_id,include_inactive=False),errors=[str(exc)]),status_code=400)
finally: db.close()
@router.get("/{group_id}")
def group_detail(request: Request, group_id:int):
db=CommonSessionLocal()
try:
user=get_current_user(request,db=db)
if not user: return RedirectResponse('/login',303)
require_permission(db,user,'clients.view'); scope=build_scope(request,user,lambda code: True); row=get_group(db,tenant_id=scope.tenant_id,group_id=group_id)
if not row: return RedirectResponse('/client-groups',303)
return templates.TemplateResponse('modules/client_groups/templates/client_groups/detail.html',_ctx(request,user,db,title=row.group_name,row=row,clients=list_group_clients(db,tenant_id=scope.tenant_id,group_id=row.id)))
finally: db.close()
@router.get("/{group_id}/edit")
def group_edit(request: Request, group_id:int):
db=CommonSessionLocal()
try:
user=get_current_user(request,db=db)
if not user: return RedirectResponse('/login',303)
require_permission(db,user,'clients.edit'); scope=build_scope(request,user,lambda code: True); row=get_group(db,tenant_id=scope.tenant_id,group_id=group_id)
if not row: return RedirectResponse('/client-groups',303)
return templates.TemplateResponse('modules/client_groups/templates/client_groups/form.html',_ctx(request,user,db,title='Edit Client Group',row=row,group_types=GROUP_TYPES,partners=repository.list_partners_for_scope(db,tenant_id=scope.tenant_id,branch_id=None),consultants=list_consultants(db,tenant_id=scope.tenant_id,include_inactive=False),errors=[]))
finally: db.close()
@router.post("/{group_id}/edit")
async def group_update(request: Request, group_id:int):
db=CommonSessionLocal()
try:
user=get_current_user(request,db=db); form=await request.form(); validate_csrf(request,form.get('csrf_token'))
if not user: return RedirectResponse('/login',303)
require_permission(db,user,'clients.edit'); scope=build_scope(request,user,lambda code: True); row=get_group(db,tenant_id=scope.tenant_id,group_id=group_id)
if not row: return RedirectResponse('/client-groups',303)
try: update_group(db,row=row,actor_user_id=user.id,payload=_payload(form)); return RedirectResponse(f'/client-groups/{row.id}',303)
except Exception as exc: return templates.TemplateResponse('modules/client_groups/templates/client_groups/form.html',_ctx(request,user,db,title='Edit Client Group',row=row,group_types=GROUP_TYPES,partners=repository.list_partners_for_scope(db,tenant_id=scope.tenant_id,branch_id=None),consultants=list_consultants(db,tenant_id=scope.tenant_id,include_inactive=False),errors=[str(exc)]),status_code=400)
finally: db.close()
+5
View File
@@ -6,6 +6,7 @@ class ClientListFilters:
q: str = "" q: str = ""
status: str = "" status: str = ""
client_type: str = "" client_type: str = ""
client_group_id: int | None = None
partner_id: int | None = None partner_id: int | None = None
include_archived: bool = False include_archived: bool = False
page: int = 1 page: int = 1
@@ -20,6 +21,9 @@ class ClientListFilters:
partner_id = None partner_id = None
elif not isinstance(partner_id, int): elif not isinstance(partner_id, int):
partner_id = int(partner_id) partner_id = int(partner_id)
client_group_id = kwargs.get("client_group_id")
if client_group_id in ("", None): client_group_id = None
elif not isinstance(client_group_id, int): client_group_id = int(client_group_id)
include_archived = kwargs.get("include_archived", False) include_archived = kwargs.get("include_archived", False)
if isinstance(include_archived, str): if isinstance(include_archived, str):
include_archived = include_archived.lower() in ("1", "true", "yes", "on") include_archived = include_archived.lower() in ("1", "true", "yes", "on")
@@ -27,6 +31,7 @@ class ClientListFilters:
q=kwargs.get("q", "") or "", q=kwargs.get("q", "") or "",
status=kwargs.get("status", "") or "", status=kwargs.get("status", "") or "",
client_type=kwargs.get("client_type", "") or "", client_type=kwargs.get("client_type", "") or "",
client_group_id=client_group_id,
partner_id=partner_id, partner_id=partner_id,
include_archived=include_archived, include_archived=include_archived,
page=max(int(kwargs.get("page", 1) or 1), 1), page=max(int(kwargs.get("page", 1) or 1), 1),
+28
View File
@@ -11,6 +11,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.modules.client_identity.service import ensure_identity, normalize_pan, placeholder_email from app.modules.client_identity.service import ensure_identity, normalize_pan, placeholder_email
from app.modules.client_groups.service import get_group_by_code, resolve_or_create_group
from app.modules.clients import repository from app.modules.clients import repository
from app.modules.clients.models import Client from app.modules.clients.models import Client
from app.modules.clients.schemas import ClientCreate from app.modules.clients.schemas import ClientCreate
@@ -31,6 +32,11 @@ TEMPLATE_COLUMNS = [
"referral_status", "referral_status",
"communication_routing_mode", "communication_routing_mode",
"branch_id", "branch_id",
"client_group_code",
"client_group_name",
"group_type",
"group_relationship",
"is_group_head",
"client_code", "client_code",
"client_name", "client_name",
"client_type", "client_type",
@@ -75,6 +81,7 @@ TEMPLATE_COLUMNS = [
] ]
BOOL_FIELDS = { BOOL_FIELDS = {
"is_group_head",
"gst_applicable", "gst_applicable",
"income_tax_applicable", "income_tax_applicable",
"tds_applicable", "tds_applicable",
@@ -239,6 +246,11 @@ def build_client_import_template_bytes(*, current_user, tenant_id: int, partner_
"partner_user_id": "Must be an active Partner user in the same firm.", "partner_user_id": "Must be an active Partner user in the same firm.",
"referred_by_consultant_id": "Optional active consultant profile id who introduced the client.", "referred_by_consultant_id": "Optional active consultant profile id who introduced the client.",
"primary_consultant_id": "Optional active consultant profile id for the operational client link.", "primary_consultant_id": "Optional active consultant profile id for the operational client link.",
"client_group_code": "Optional tenant-unique group code. Existing group is matched by this code.",
"client_group_name": "Required only when creating a new group during import.",
"group_type": "Family, Business Group, Promoter Group, Trust Group, Common Management, or Other.",
"group_relationship": "Optional relationship such as Spouse, HUF, Company, Trust, or Related Concern.",
"is_group_head": "yes/no. Only one group head is recommended per group.",
"referral_date": "Optional referral date supported by the existing client schema.", "referral_date": "Optional referral date supported by the existing client schema.",
"referral_reference": "Optional referral or source reference.", "referral_reference": "Optional referral or source reference.",
"referral_status": "active, inactive, ended, or pending.", "referral_status": "active, inactive, ended, or pending.",
@@ -407,6 +419,9 @@ def build_preview(
"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,
"client_group_id": None,
"group_relationship": cleaned.get("group_relationship"),
"is_group_head": bool(cleaned.get("is_group_head")),
"referred_by_consultant_id": referred_id, "referred_by_consultant_id": referred_id,
"primary_consultant_id": primary_id, "primary_consultant_id": primary_id,
"referral_date": cleaned.get("referral_date"), "referral_date": cleaned.get("referral_date"),
@@ -470,6 +485,9 @@ def build_preview(
"tenant_id": firm_tenant_id, "tenant_id": firm_tenant_id,
"branch_id": branch_id, "branch_id": branch_id,
"partner_id": partner_user_id, "partner_id": partner_user_id,
"client_group_code": cleaned.get("client_group_code"),
"client_group_name": cleaned.get("client_group_name"),
"group_type": cleaned.get("group_type") or "Family",
"client_payload": payload, "client_payload": payload,
"portal_password": password, "portal_password": password,
"portal_password_confirm": password_confirm, "portal_password_confirm": password_confirm,
@@ -530,6 +548,16 @@ def commit_import(
if existing_user: if existing_user:
raise ValueError("Email is already used by another ERP login.") raise ValueError("Email is already used by another ERP login.")
group = resolve_or_create_group(
db, tenant_id=int(payload["tenant_id"]), actor_user_id=current_user.id,
group_code=item.get("client_group_code"), group_name=item.get("client_group_name"), group_type=item.get("group_type"),
)
payload["client_group_id"] = group.id if group else None
if group and payload.get("is_group_head"):
existing_head = db.execute(select(Client).where(Client.tenant_id == int(payload["tenant_id"]), Client.client_group_id == group.id, Client.is_group_head.is_(True))).scalar_one_or_none()
if existing_head:
raise ValueError(f"Group {group.group_code} already has group head {existing_head.client_code} - {existing_head.client_name}.")
data = ClientCreate(**payload) data = ClientCreate(**payload)
row = create_client_service( row = create_client_service(
db, db,
+3
View File
@@ -20,6 +20,9 @@ class Client(CommonBase):
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True) tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), nullable=False, index=True)
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)
client_group_id: Mapped[int | None] = mapped_column(ForeignKey("client_groups.id", ondelete="SET NULL"), nullable=True, index=True)
group_relationship: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_group_head: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, 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) 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_date: Mapped[date | None] = mapped_column(Date, nullable=True)
+53 -154
View File
@@ -9,6 +9,7 @@ from app.core.security.passwords import hash_password
from app.modules.clients.association_models import ClientAssociation from app.modules.clients.association_models import ClientAssociation
from app.modules.clients.constants import CLIENT_SORT_FIELDS from app.modules.clients.constants import CLIENT_SORT_FIELDS
from app.modules.clients.models import Client, ClientAuditLog from app.modules.clients.models import Client, ClientAuditLog
from app.modules.client_groups.models import ClientGroup
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.core.tenancy.models import Branch, Tenant from app.modules.core.tenancy.models import Branch, Tenant
@@ -30,16 +31,18 @@ def build_clients_query(
q: str = "", q: str = "",
status: str = "", status: str = "",
client_type: str = "", client_type: str = "",
client_group_id: int | None = None,
include_archived: bool = False, include_archived: bool = False,
): ):
assoc = ClientAssociation assoc = ClientAssociation
stmt = ( stmt = (
select( select(
Client, Client,
User.full_name.label("partner_name"), User.full_name.label("partner_name"),
Branch.name.label("branch_name"), Branch.name.label("branch_name"),
Tenant.name.label("tenant_name"), Tenant.name.label("tenant_name"),
ClientGroup.group_name.label("client_group_name"),
ClientGroup.group_code.label("client_group_code"),
assoc.association_type.label("association_type"), assoc.association_type.label("association_type"),
assoc.firm_tenant_id.label("assoc_firm_tenant_id"), assoc.firm_tenant_id.label("assoc_firm_tenant_id"),
assoc.consultant_id.label("assoc_consultant_id"), assoc.consultant_id.label("assoc_consultant_id"),
@@ -47,189 +50,85 @@ def build_clients_query(
assoc.created_source.label("assoc_created_source"), assoc.created_source.label("assoc_created_source"),
) )
.outerjoin(assoc, assoc.client_id == Client.id) .outerjoin(assoc, assoc.client_id == Client.id)
.outerjoin(ClientGroup, ClientGroup.id == Client.client_group_id)
.join(User, User.id == Client.partner_id, isouter=True) .join(User, User.id == Client.partner_id, isouter=True)
.join(Branch, Branch.id == Client.branch_id, isouter=True) .join(Branch, Branch.id == Client.branch_id, isouter=True)
.join(Tenant, Tenant.id == Client.tenant_id, isouter=True) .join(Tenant, Tenant.id == Client.tenant_id, isouter=True)
) )
if not allow_all_clients: if not allow_all_clients:
stmt = stmt.where(Client.tenant_id == tenant_id) stmt = stmt.where(Client.tenant_id == tenant_id)
if not include_archived: if not include_archived:
stmt = stmt.where(Client.is_archived.is_(False)) stmt = stmt.where(Client.is_archived.is_(False))
if branch_id and not allow_all_clients and not allow_cross_branch: if branch_id and not allow_all_clients and not allow_cross_branch:
stmt = stmt.where(Client.branch_id == branch_id) stmt = stmt.where(Client.branch_id == branch_id)
if partner_id: if partner_id:
stmt = stmt.where( stmt = stmt.where((Client.partner_id == partner_id) | (assoc.partner_user_id == partner_id))
(Client.partner_id == partner_id) | (assoc.partner_user_id == partner_id)
)
if status: if status:
stmt = stmt.where(Client.status == status) stmt = stmt.where(Client.status == status)
if client_type: if client_type:
stmt = stmt.where(Client.client_type == client_type) stmt = stmt.where(Client.client_type == client_type)
if client_group_id:
stmt = stmt.where(Client.client_group_id == client_group_id)
if q: if q:
like = f"%{q.strip()}%" like = f"%{q.strip()}%"
stmt = stmt.where( stmt = stmt.where(or_(
or_( Client.client_code.ilike(like), Client.client_name.ilike(like), Client.trade_name.ilike(like),
Client.client_code.ilike(like), Client.pan.ilike(like), Client.gstin.ilike(like), Client.mobile.ilike(like), Client.email.ilike(like),
Client.client_name.ilike(like), ClientGroup.group_name.ilike(like), ClientGroup.group_code.ilike(like),
Client.trade_name.ilike(like), ))
Client.pan.ilike(like),
Client.gstin.ilike(like),
Client.mobile.ilike(like),
Client.email.ilike(like),
)
)
return stmt return stmt
def list_clients( def list_clients(
db: Session, db: Session, *, tenant_id: int, branch_id: int | None = None,
*, allow_cross_branch: bool = False, allow_all_clients: bool = False,
tenant_id: int, partner_id: int | None = None, q: str = "", status: str = "", client_type: str = "",
branch_id: int | None = None, client_group_id: int | None = None, include_archived: bool = False,
allow_cross_branch: bool = False, page: int = 1, per_page: int = 10, sort_by: str = "client_name", sort_order: str = "asc",
allow_all_clients: bool = False,
partner_id: int | None = None,
q: str = "",
status: str = "",
client_type: str = "",
include_archived: bool = False,
page: int = 1,
per_page: int = 10,
sort_by: str = "client_name",
sort_order: str = "asc",
) -> dict: ) -> dict:
stmt = build_clients_query( stmt = build_clients_query(
tenant_id=tenant_id, tenant_id=tenant_id, branch_id=branch_id, allow_cross_branch=allow_cross_branch,
branch_id=branch_id, allow_all_clients=allow_all_clients, partner_id=partner_id, q=q, status=status,
allow_cross_branch=allow_cross_branch, client_type=client_type, client_group_id=client_group_id, include_archived=include_archived,
allow_all_clients=allow_all_clients,
partner_id=partner_id,
q=q,
status=status,
client_type=client_type,
include_archived=include_archived,
) )
total = db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one() total = db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
result = db.execute( result = db.execute(stmt.order_by(_safe_sort(sort_by, sort_order)).offset((page - 1) * per_page).limit(per_page)).all()
stmt.order_by(_safe_sort(sort_by, sort_order)) rows=[]
.offset((page - 1) * per_page) for client, partner_name, branch_name, tenant_name, client_group_name, client_group_code, association_type, assoc_firm_tenant_id, assoc_consultant_id, assoc_partner_user_id, assoc_created_source in result:
.limit(per_page) row={**client.__dict__}; row.pop("_sa_instance_state",None)
).all() row.update({
"partner_name":partner_name,"branch_name":branch_name,"tenant_name":tenant_name,
rows = [] "client_group_name":client_group_name,"client_group_code":client_group_code,
for ( "association_type":association_type,"assoc_firm_tenant_id":assoc_firm_tenant_id,
client, "assoc_consultant_id":assoc_consultant_id,"assoc_partner_user_id":assoc_partner_user_id,
partner_name, "assoc_created_source":assoc_created_source,"effective_partner_id":assoc_partner_user_id or row.get("partner_id"),
branch_name, }); rows.append(row)
tenant_name, stats_stmt=select(func.count(Client.id),func.sum(case((Client.status=="active",1),else_=0)),func.sum(case((Client.status=="inactive",1),else_=0)),func.sum(case((Client.status=="archived",1),else_=0)))
association_type,
assoc_firm_tenant_id,
assoc_consultant_id,
assoc_partner_user_id,
assoc_created_source,
) in result:
row = {**client.__dict__}
row.pop("_sa_instance_state", None)
row.update(
{
"partner_name": partner_name,
"branch_name": branch_name,
"tenant_name": tenant_name,
"association_type": association_type,
"assoc_firm_tenant_id": assoc_firm_tenant_id,
"assoc_consultant_id": assoc_consultant_id,
"assoc_partner_user_id": assoc_partner_user_id,
"assoc_created_source": assoc_created_source,
"effective_partner_id": assoc_partner_user_id or row.get("partner_id"),
}
)
rows.append(row)
stats_stmt = select(
func.count(Client.id),
func.sum(case((Client.status == "active", 1), else_=0)),
func.sum(case((Client.status == "inactive", 1), else_=0)),
func.sum(case((Client.status == "archived", 1), else_=0)),
)
if not allow_all_clients: if not allow_all_clients:
stats_stmt = stats_stmt.where(Client.tenant_id == tenant_id) stats_stmt=stats_stmt.where(Client.tenant_id==tenant_id)
if branch_id and not allow_cross_branch: if branch_id and not allow_cross_branch: stats_stmt=stats_stmt.where(Client.branch_id==branch_id)
stats_stmt = stats_stmt.where(Client.branch_id == branch_id) if partner_id: stats_stmt=stats_stmt.where(Client.partner_id==partner_id)
total_all,active,inactive,archived=db.execute(stats_stmt).one()
if partner_id: pages=ceil(total/per_page) if per_page else 1
stats_stmt = stats_stmt.where(Client.partner_id == partner_id) return {"rows":rows,"meta":{"total":total,"page":page,"per_page":per_page,"pages":max(pages,1)},"stats":{"total":int(total_all or 0),"active":int(active or 0),"inactive":int(inactive or 0),"archived":int(archived or 0)}}
total_all, active, inactive, archived = db.execute(stats_stmt).one()
pages = ceil(total / per_page) if per_page else 1
return {
"rows": rows,
"meta": {
"total": total,
"page": page,
"per_page": per_page,
"pages": max(pages, 1),
},
"stats": {
"total": int(total_all or 0),
"active": int(active or 0),
"inactive": int(inactive or 0),
"archived": int(archived or 0),
},
}
def get_client_detail_payload(db: Session, client_id: int): def get_client_detail_payload(db: Session, client_id: int):
assoc = ClientAssociation assoc=ClientAssociation
stmt=(select(
stmt = ( Client, ClientGroup.group_name.label("client_group_name"), ClientGroup.group_code.label("client_group_code"),
select( assoc.association_type.label("association_type"), assoc.firm_tenant_id.label("assoc_firm_tenant_id"),
Client, assoc.consultant_id.label("assoc_consultant_id"), assoc.partner_user_id.label("assoc_partner_user_id"),
assoc.association_type.label("association_type"),
assoc.firm_tenant_id.label("assoc_firm_tenant_id"),
assoc.consultant_id.label("assoc_consultant_id"),
assoc.partner_user_id.label("assoc_partner_user_id"),
assoc.created_source.label("assoc_created_source"), assoc.created_source.label("assoc_created_source"),
) ).outerjoin(assoc,assoc.client_id==Client.id).outerjoin(ClientGroup,ClientGroup.id==Client.client_group_id).where(Client.id==client_id))
.outerjoin(assoc, assoc.client_id == Client.id) result=db.execute(stmt).one_or_none()
.where(Client.id == client_id) if not result: return None
) client,client_group_name,client_group_code,association_type,assoc_firm_tenant_id,assoc_consultant_id,assoc_partner_user_id,assoc_created_source=result
row={**client.__dict__}; row.pop("_sa_instance_state",None)
result = db.execute(stmt).one_or_none() row.update({"client_group_name":client_group_name,"client_group_code":client_group_code,"association_type":association_type,
if not result: "assoc_firm_tenant_id":assoc_firm_tenant_id,"assoc_consultant_id":assoc_consultant_id,
return None "assoc_partner_user_id":assoc_partner_user_id,"assoc_created_source":assoc_created_source,
"effective_partner_id":assoc_partner_user_id or row.get("partner_id")})
(
client,
association_type,
assoc_firm_tenant_id,
assoc_consultant_id,
assoc_partner_user_id,
assoc_created_source,
) = result
row = {**client.__dict__}
row.pop("_sa_instance_state", None)
row.update(
{
"association_type": association_type,
"assoc_firm_tenant_id": assoc_firm_tenant_id,
"assoc_consultant_id": assoc_consultant_id,
"assoc_partner_user_id": assoc_partner_user_id,
"assoc_created_source": assoc_created_source,
"effective_partner_id": assoc_partner_user_id or row.get("partner_id"),
}
)
return row return row
+8 -2
View File
@@ -21,6 +21,9 @@ class ClientBase(BaseModel):
tenant_id: int tenant_id: int
branch_id: int branch_id: int
partner_id: Optional[int] = None partner_id: Optional[int] = None
client_group_id: Optional[int] = None
group_relationship: Optional[str] = None
is_group_head: bool = False
default_review_partner_user_id: Optional[int] = None default_review_partner_user_id: Optional[int] = None
referred_by_consultant_id: Optional[int] = None referred_by_consultant_id: Optional[int] = None
primary_consultant_id: Optional[int] = None primary_consultant_id: Optional[int] = None
@@ -88,7 +91,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", "referral_reference", "acceptance_review_notes", "acceptance_rejection_reason", "notes", "address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "referral_reference", "group_relationship", "acceptance_review_notes", "acceptance_rejection_reason", "notes",
mode="before", mode="before",
) )
@classmethod @classmethod
@@ -213,6 +216,9 @@ class ClientUpdate(BaseModel):
tenant_id: Optional[int] = None tenant_id: Optional[int] = None
branch_id: Optional[int] = None branch_id: Optional[int] = None
partner_id: Optional[int] = None partner_id: Optional[int] = None
client_group_id: Optional[int] = None
group_relationship: Optional[str] = None
is_group_head: Optional[bool] = None
default_review_partner_user_id: Optional[int] = None default_review_partner_user_id: Optional[int] = None
referred_by_consultant_id: Optional[int] = None referred_by_consultant_id: Optional[int] = None
primary_consultant_id: Optional[int] = None primary_consultant_id: Optional[int] = None
@@ -272,7 +278,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", "referral_reference", "acceptance_review_notes", "acceptance_rejection_reason", "notes", "address_line_1", "address_line_2", "city", "state", "country", "client_category", "risk_category", "referral_reference", "group_relationship", "acceptance_review_notes", "acceptance_rejection_reason", "notes",
mode="before", mode="before",
) )
@classmethod @classmethod
+32
View File
@@ -30,6 +30,8 @@ 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 from app.modules.consultants.service import sync_primary_client_consultant_link
from app.modules.client_groups.service import get_group
from app.modules.clients.models import Client
@@ -43,6 +45,26 @@ def _payload_from_schema(data):
def _validate_client_group_assignment(db, *, payload: dict, existing_row=None):
group_id = payload.get("client_group_id")
tenant_id = payload.get("tenant_id") or getattr(existing_row, "tenant_id", None)
if group_id:
group = get_group(db, tenant_id=int(tenant_id), group_id=int(group_id))
if not group or not group.is_active:
raise HTTPException(status_code=400, detail="Selected client group is invalid or inactive for this firm.")
if payload.get("is_group_head") and group_id:
stmt = select(Client).where(Client.tenant_id == int(tenant_id), Client.client_group_id == int(group_id), Client.is_group_head.is_(True))
if existing_row is not None:
stmt = stmt.where(Client.id != existing_row.id)
existing_head = db.execute(stmt).scalar_one_or_none()
if existing_head:
raise HTTPException(status_code=400, detail=f"This group already has group head {existing_head.client_code} - {existing_head.client_name}.")
if not group_id:
payload["group_relationship"] = None
payload["is_group_head"] = False
def _is_high_risk(risk_category: str | None) -> bool: def _is_high_risk(risk_category: str | None) -> bool:
return (risk_category or "").strip().lower() in CLIENT_ACCEPTANCE_APPROVAL_REQUIRED_RISKS return (risk_category or "").strip().lower() in CLIENT_ACCEPTANCE_APPROVAL_REQUIRED_RISKS
@@ -271,6 +293,7 @@ def create_client_service(db, *, data, actor_user_id: int, scope, current_user_r
payload = _payload_from_schema(data) payload = _payload_from_schema(data)
_enforce_client_acceptance_controls(payload) _enforce_client_acceptance_controls(payload)
_validate_client_group_assignment(db, payload=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) sync_primary_client_consultant_link(db, client=row, consultant_id=getattr(data, "primary_consultant_id", None), actor_user_id=actor_user_id)
@@ -301,6 +324,7 @@ def update_client_service(db, *, row, data, actor_user_id: int, scope, current_u
payload = _payload_from_schema(data) payload = _payload_from_schema(data)
_enforce_client_acceptance_controls(payload, existing_row=row) _enforce_client_acceptance_controls(payload, existing_row=row)
_validate_client_group_assignment(db, payload=payload, existing_row=row)
if payload.get("pan"): if payload.get("pan"):
existing_pan = repository.get_client_by_pan(db, tenant_id=payload["tenant_id"], pan=payload["pan"]) existing_pan = repository.get_client_by_pan(db, tenant_id=payload["tenant_id"], pan=payload["pan"])
@@ -441,6 +465,10 @@ def export_clients_csv(payload: dict) -> str:
"pan", "pan",
"gstin", "gstin",
"partner", "partner",
"client_group_code",
"client_group_name",
"group_relationship",
"is_group_head",
"association_type", "association_type",
"association_source", "association_source",
] ]
@@ -455,6 +483,10 @@ def export_clients_csv(payload: dict) -> str:
row.get("pan"), row.get("pan"),
row.get("gstin"), row.get("gstin"),
row.get("partner_name") or row.get("effective_partner_id"), row.get("partner_name") or row.get("effective_partner_id"),
row.get("client_group_code"),
row.get("client_group_name"),
row.get("group_relationship"),
row.get("is_group_head"),
row.get("association_type"), row.get("association_type"),
row.get("assoc_created_source"), row.get("assoc_created_source"),
] ]
@@ -60,7 +60,8 @@
</div> </div>
{% endif %} {% endif %}
<div class="grid gap-6 xl:grid-cols-3"> <div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Client Group</div>{% if client_group %}<div class="mt-2 text-lg font-semibold text-slate-900"><a class="text-brand-700 hover:underline" href="/client-groups/{{ client_group.id }}">{{ client_group.group_name }}</a></div><div class="mt-1 text-sm text-slate-500">{{ client_group.group_code }} · {{ row.group_relationship or client_group.group_type }}{% if row.is_group_head %} · Group Head{% endif %}</div>{% else %}<div class="mt-2 text-sm text-slate-500">Not linked to a group.</div>{% endif %}</div>
<div class="grid gap-6 xl:grid-cols-3">
<section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2"> <section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2">
<h3 class="text-base font-semibold text-slate-900">Profile</h3> <h3 class="text-base font-semibold text-slate-900">Profile</h3>
<div class="mt-4 grid gap-4 md:grid-cols-2"> <div class="mt-4 grid gap-4 md:grid-cols-2">
@@ -7,7 +7,7 @@
<p class="text-sm text-slate-500">Association-aware list view.</p> <p class="text-sm text-slate-500">Association-aware list view.</p>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3"><a href="/client-groups" class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Client Groups</a>
{% if can_export %} {% if can_export %}
<a href="/clients/export?q={{ q }}&status={{ status }}&client_type={{ client_type }}&include_archived={{ include_archived }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}" <a href="/clients/export?q={{ q }}&status={{ status }}&client_type={{ client_type }}&include_archived={{ include_archived }}&sort_by={{ sort_by }}&sort_order={{ sort_order }}"
class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"> class="inline-flex rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
@@ -162,7 +162,25 @@
</div> </div>
</section> </section>
<section class="rounded-2xl bg-white p-6 shadow-soft">
<section class="rounded-2xl border border-slate-200 p-5 md:col-span-2">
<h3 class="text-sm font-semibold text-slate-900">Client Group / Family</h3>
<p class="mt-1 text-xs text-slate-500">Link this client to an existing family, promoter or related business group. Consultant access remains client-specific.</p>
<div class="mt-4 grid gap-4 md:grid-cols-3">
<label class="text-sm font-medium text-slate-700">Client Group
<select name="client_group_id" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
<option value="">-- No group --</option>
{% for g in form_options.client_groups or [] %}<option value="{{ g.id }}" {% if (form_data.client_group_id or (row.client_group_id if is_edit else None)) == g.id %}selected{% endif %}>{{ g.group_code }} — {{ g.group_name }}</option>{% endfor %}
</select>
</label>
<label class="text-sm font-medium text-slate-700">Relationship within group
<input name="group_relationship" value="{{ form_data.group_relationship or (row.group_relationship if is_edit else '') }}" placeholder="Spouse, HUF, Company, Trust..." class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</label>
<label class="mt-7 inline-flex items-center gap-2 text-sm font-medium text-slate-700"><input type="checkbox" name="is_group_head" {% if form_data.is_group_head or (is_edit and row.is_group_head) %}checked{% endif %}> Group head</label>
</div>
<div class="mt-3"><a href="/client-groups/new" class="text-sm font-medium text-brand-700 hover:underline">Create a new client group</a></div>
</section>
<section class="rounded-2xl bg-white p-6 shadow-soft">
<h3 class="text-base font-semibold text-slate-900">Assignment & Scope</h3> <h3 class="text-base font-semibold text-slate-900">Assignment & Scope</h3>
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
<div> <div>
@@ -1,2 +1,2 @@
<div class="overflow-hidden rounded-2xl bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Association</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Partner</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Branch</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th><th class="px-4 py-3"></th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr><td class="px-4 py-3 text-sm font-medium text-slate-900">{{ row.client_code }}</td><td class="px-4 py-3 text-sm text-slate-700"><div class="font-medium">{{ row.client_name }}</div><div class="text-xs text-slate-500">{{ row.pan or row.gstin or '-' }}</div></td><td class="px-4 py-3 text-sm text-slate-700"><div>{{ row.association_type or 'legacy_firm' }}</div><div class="text-xs text-slate-500">{{ row.assoc_created_source or 'legacy' }}</div></td><td class="px-4 py-3 text-sm text-slate-700">{{ row.partner_name or row.effective_partner_id or '-' }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ row.branch_name or row.assoc_firm_branch_id or row.branch_id or '-' }}</td><td class="px-4 py-3 text-sm">{% if row.status == 'active' %}<span class="rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">Active</span>{% elif row.status == 'archived' %}<span class="rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800">Archived</span>{% else %}<span class="rounded-full bg-slate-200 px-2 py-1 text-xs font-medium text-slate-700">Inactive</span>{% endif %}</td><td class="px-4 py-3 text-right"><a href="/clients/{{ row.id }}" class="text-sm font-medium text-brand-700 hover:underline">Open</a></td></tr>{% else %}<tr><td colspan="7" class="px-4 py-8 text-center text-sm text-slate-500">No clients found.</td></tr>{% endfor %}</tbody></table></div> <div class="overflow-hidden rounded-2xl bg-white shadow-soft"><table class="min-w-full divide-y divide-slate-200"><thead class="bg-slate-50"><tr><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Code</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Group</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Association</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Partner</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Branch</th><th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Status</th><th class="px-4 py-3"></th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in rows %}<tr><td class="px-4 py-3 text-sm font-medium text-slate-900">{{ row.client_code }}</td><td class="px-4 py-3 text-sm text-slate-700"><div class="font-medium">{{ row.client_name }}</div><div class="text-xs text-slate-500">{{ row.pan or row.gstin or '-' }}</div></td><td class="px-4 py-3 text-sm text-slate-700">{% if row.client_group_name %}<a href="/client-groups/{{ row.client_group_id }}" class="font-medium text-brand-700">{{ row.client_group_name }}</a><div class="text-xs text-slate-500">{{ row.group_relationship or row.client_group_code }}</div>{% else %}-{% endif %}</td><td class="px-4 py-3 text-sm text-slate-700"><div>{{ row.association_type or 'legacy_firm' }}</div><div class="text-xs text-slate-500">{{ row.assoc_created_source or 'legacy' }}</div></td><td class="px-4 py-3 text-sm text-slate-700">{{ row.partner_name or row.effective_partner_id or '-' }}</td><td class="px-4 py-3 text-sm text-slate-700">{{ row.branch_name or row.assoc_firm_branch_id or row.branch_id or '-' }}</td><td class="px-4 py-3 text-sm">{% if row.status == 'active' %}<span class="rounded-full bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700">Active</span>{% elif row.status == 'archived' %}<span class="rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800">Archived</span>{% else %}<span class="rounded-full bg-slate-200 px-2 py-1 text-xs font-medium text-slate-700">Inactive</span>{% endif %}</td><td class="px-4 py-3 text-right"><a href="/clients/{{ row.id }}" class="text-sm font-medium text-brand-700 hover:underline">Open</a></td></tr>{% else %}<tr><td colspan="8" class="px-4 py-8 text-center text-sm text-slate-500">No clients found.</td></tr>{% endfor %}</tbody></table></div>
+11
View File
@@ -55,6 +55,7 @@ from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission from app.modules.core.rbac.permission_guard import require_permission
from app.modules.services.execution import list_client_visible_task_comments from app.modules.services.execution import list_client_visible_task_comments
from app.modules.clients.auditor_service import build_client_auditor_card from app.modules.clients.auditor_service import build_client_auditor_card
from app.modules.client_groups.service import get_group, list_groups
from app.modules.clients.portal_service import ( from app.modules.clients.portal_service import (
build_client_portal_summary, build_client_portal_summary,
create_client_reply, create_client_reply,
@@ -173,6 +174,9 @@ def _build_form_payload(request: Request, user, scope, *, include_client_code: b
"tenant_id": tenant_id, "tenant_id": tenant_id,
"branch_id": branch_id, "branch_id": branch_id,
"partner_id": partner_id, "partner_id": partner_id,
"client_group_id": int(form.get("client_group_id")) if form.get("client_group_id") not in (None, "", "None") else None,
"group_relationship": form.get("group_relationship"),
"is_group_head": _form_bool(form.get("is_group_head")),
"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, "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, "primary_consultant_id": int(form.get("primary_consultant_id")) if form.get("primary_consultant_id") not in (None, "", "None") else None,
@@ -260,6 +264,7 @@ def _form_options(db, scope, form_mode: str):
"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 [], "consultants": list_consultants(db, tenant_id=tenant_id, include_inactive=False) if tenant_id else [],
"client_groups": [item["group"] for item in list_groups(db, tenant_id=tenant_id)] if tenant_id else [],
} }
return { return {
@@ -271,6 +276,7 @@ def _form_options(db, scope, form_mode: str):
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), "consultants": list_consultants(db, tenant_id=tenant_id, branch_id=branch_id, include_inactive=False),
"client_groups": [item["group"] for item in list_groups(db, tenant_id=tenant_id)],
"review_partners": repository.list_partners_for_scope( "review_partners": repository.list_partners_for_scope(
db, db,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -287,6 +293,7 @@ def clients_list(
q: str = "", q: str = "",
status: str = "", status: str = "",
client_type: str = "", client_type: str = "",
client_group_id: int | None = None,
partner_id: int | None = None, partner_id: int | None = None,
include_archived: bool = False, include_archived: bool = False,
page: int = 1, page: int = 1,
@@ -313,6 +320,7 @@ def clients_list(
status=status, status=status,
client_type=client_type, client_type=client_type,
partner_id=partner_id, partner_id=partner_id,
client_group_id=client_group_id,
include_archived=include_archived, include_archived=include_archived,
page=page, page=page,
per_page=per_page, per_page=per_page,
@@ -332,6 +340,7 @@ def clients_list(
q=filters.q, q=filters.q,
status=filters.status, status=filters.status,
client_type=filters.client_type, client_type=filters.client_type,
client_group_id=filters.client_group_id,
include_archived=filters.include_archived, include_archived=filters.include_archived,
page=filters.page, page=filters.page,
per_page=filters.per_page, per_page=filters.per_page,
@@ -354,6 +363,7 @@ def clients_list(
status=filters.status, status=filters.status,
client_type=filters.client_type, client_type=filters.client_type,
partner_id=filters.partner_id, partner_id=filters.partner_id,
client_group_id=filters.client_group_id,
include_archived=filters.include_archived, include_archived=filters.include_archived,
page=filters.page, page=filters.page,
per_page=filters.per_page, per_page=filters.per_page,
@@ -697,6 +707,7 @@ def client_detail(request: Request, client_id: int):
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), consultant_summary=get_client_consultant_summary(db, tenant_id=int(row["tenant_id"]), client_id=client_id),
client_group=get_group(db, tenant_id=int(row["tenant_id"]), group_id=int(row["client_group_id"])) if row.get("client_group_id") else None,
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"),
) )
+2
View File
@@ -2,6 +2,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router
from app.modules.client_groups.ui import router as client_groups_ui_router
from app.modules.consultants.ui import router as consultants_ui_router, portal_router as consultant_portal_router from app.modules.consultants.ui import router as consultants_ui_router, portal_router as consultant_portal_router
from app.modules.employees.ui import router as employees_ui_router, portal_router as employee_portal_router from app.modules.employees.ui import router as employees_ui_router, portal_router as employee_portal_router
from app.modules.manager_dashboard.ui import router as manager_dashboard_router from app.modules.manager_dashboard.ui import router as manager_dashboard_router
@@ -67,6 +68,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(system_admin_dashboard_router) app.include_router(system_admin_dashboard_router)
app.include_router(work_detail_ui_router) app.include_router(work_detail_ui_router)
app.include_router(clients_ui_router) app.include_router(clients_ui_router)
app.include_router(client_groups_ui_router)
app.include_router(registrations_ui_router) app.include_router(registrations_ui_router)
app.include_router(credential_vault_ui_router) app.include_router(credential_vault_ui_router)
app.include_router(client_identity_ui_router) app.include_router(client_identity_ui_router)