Add client groups and family tracking
This commit is contained in:
@@ -33,6 +33,7 @@ from app.modules.core.audit import models as audit_models # noqa: F401
|
||||
|
||||
# Business module models
|
||||
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.services import models as service_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")
|
||||
@@ -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)
|
||||
@@ -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 %}
|
||||
@@ -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()
|
||||
@@ -6,6 +6,7 @@ class ClientListFilters:
|
||||
q: str = ""
|
||||
status: str = ""
|
||||
client_type: str = ""
|
||||
client_group_id: int | None = None
|
||||
partner_id: int | None = None
|
||||
include_archived: bool = False
|
||||
page: int = 1
|
||||
@@ -20,6 +21,9 @@ class ClientListFilters:
|
||||
partner_id = None
|
||||
elif not isinstance(partner_id, int):
|
||||
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)
|
||||
if isinstance(include_archived, str):
|
||||
include_archived = include_archived.lower() in ("1", "true", "yes", "on")
|
||||
@@ -27,6 +31,7 @@ class ClientListFilters:
|
||||
q=kwargs.get("q", "") or "",
|
||||
status=kwargs.get("status", "") or "",
|
||||
client_type=kwargs.get("client_type", "") or "",
|
||||
client_group_id=client_group_id,
|
||||
partner_id=partner_id,
|
||||
include_archived=include_archived,
|
||||
page=max(int(kwargs.get("page", 1) or 1), 1),
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.models import Client
|
||||
from app.modules.clients.schemas import ClientCreate
|
||||
@@ -31,6 +32,11 @@ TEMPLATE_COLUMNS = [
|
||||
"referral_status",
|
||||
"communication_routing_mode",
|
||||
"branch_id",
|
||||
"client_group_code",
|
||||
"client_group_name",
|
||||
"group_type",
|
||||
"group_relationship",
|
||||
"is_group_head",
|
||||
"client_code",
|
||||
"client_name",
|
||||
"client_type",
|
||||
@@ -75,6 +81,7 @@ TEMPLATE_COLUMNS = [
|
||||
]
|
||||
|
||||
BOOL_FIELDS = {
|
||||
"is_group_head",
|
||||
"gst_applicable",
|
||||
"income_tax_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.",
|
||||
"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.",
|
||||
"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_reference": "Optional referral or source reference.",
|
||||
"referral_status": "active, inactive, ended, or pending.",
|
||||
@@ -407,6 +419,9 @@ def build_preview(
|
||||
"tenant_id": firm_tenant_id,
|
||||
"branch_id": branch_id,
|
||||
"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,
|
||||
"primary_consultant_id": primary_id,
|
||||
"referral_date": cleaned.get("referral_date"),
|
||||
@@ -470,6 +485,9 @@ def build_preview(
|
||||
"tenant_id": firm_tenant_id,
|
||||
"branch_id": branch_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,
|
||||
"portal_password": password,
|
||||
"portal_password_confirm": password_confirm,
|
||||
@@ -530,6 +548,16 @@ def commit_import(
|
||||
if existing_user:
|
||||
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)
|
||||
row = create_client_service(
|
||||
db,
|
||||
|
||||
@@ -20,6 +20,9 @@ class Client(CommonBase):
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.core.security.passwords import hash_password
|
||||
from app.modules.clients.association_models import ClientAssociation
|
||||
from app.modules.clients.constants import CLIENT_SORT_FIELDS
|
||||
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.rbac.models import Role, UserRole
|
||||
from app.modules.core.tenancy.models import Branch, Tenant
|
||||
@@ -30,16 +31,18 @@ def build_clients_query(
|
||||
q: str = "",
|
||||
status: str = "",
|
||||
client_type: str = "",
|
||||
client_group_id: int | None = None,
|
||||
include_archived: bool = False,
|
||||
):
|
||||
assoc = ClientAssociation
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Client,
|
||||
User.full_name.label("partner_name"),
|
||||
Branch.name.label("branch_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.firm_tenant_id.label("assoc_firm_tenant_id"),
|
||||
assoc.consultant_id.label("assoc_consultant_id"),
|
||||
@@ -47,189 +50,85 @@ def build_clients_query(
|
||||
assoc.created_source.label("assoc_created_source"),
|
||||
)
|
||||
.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(Branch, Branch.id == Client.branch_id, isouter=True)
|
||||
.join(Tenant, Tenant.id == Client.tenant_id, isouter=True)
|
||||
)
|
||||
|
||||
if not allow_all_clients:
|
||||
stmt = stmt.where(Client.tenant_id == tenant_id)
|
||||
|
||||
if not include_archived:
|
||||
stmt = stmt.where(Client.is_archived.is_(False))
|
||||
|
||||
if branch_id and not allow_all_clients and not allow_cross_branch:
|
||||
stmt = stmt.where(Client.branch_id == branch_id)
|
||||
|
||||
if partner_id:
|
||||
stmt = stmt.where(
|
||||
(Client.partner_id == partner_id) | (assoc.partner_user_id == partner_id)
|
||||
)
|
||||
|
||||
stmt = stmt.where((Client.partner_id == partner_id) | (assoc.partner_user_id == partner_id))
|
||||
if status:
|
||||
stmt = stmt.where(Client.status == status)
|
||||
|
||||
if 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:
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Client.client_code.ilike(like),
|
||||
Client.client_name.ilike(like),
|
||||
Client.trade_name.ilike(like),
|
||||
Client.pan.ilike(like),
|
||||
Client.gstin.ilike(like),
|
||||
Client.mobile.ilike(like),
|
||||
Client.email.ilike(like),
|
||||
)
|
||||
)
|
||||
|
||||
stmt = stmt.where(or_(
|
||||
Client.client_code.ilike(like), Client.client_name.ilike(like), Client.trade_name.ilike(like),
|
||||
Client.pan.ilike(like), Client.gstin.ilike(like), Client.mobile.ilike(like), Client.email.ilike(like),
|
||||
ClientGroup.group_name.ilike(like), ClientGroup.group_code.ilike(like),
|
||||
))
|
||||
return stmt
|
||||
|
||||
|
||||
def list_clients(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: int,
|
||||
branch_id: int | None = None,
|
||||
allow_cross_branch: bool = False,
|
||||
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",
|
||||
db: Session, *, tenant_id: int, branch_id: int | None = None,
|
||||
allow_cross_branch: bool = False, allow_all_clients: bool = False,
|
||||
partner_id: int | None = None, q: str = "", status: str = "", client_type: str = "",
|
||||
client_group_id: int | None = None, include_archived: bool = False,
|
||||
page: int = 1, per_page: int = 10, sort_by: str = "client_name", sort_order: str = "asc",
|
||||
) -> dict:
|
||||
stmt = build_clients_query(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
allow_cross_branch=allow_cross_branch,
|
||||
allow_all_clients=allow_all_clients,
|
||||
partner_id=partner_id,
|
||||
q=q,
|
||||
status=status,
|
||||
client_type=client_type,
|
||||
include_archived=include_archived,
|
||||
tenant_id=tenant_id, branch_id=branch_id, allow_cross_branch=allow_cross_branch,
|
||||
allow_all_clients=allow_all_clients, partner_id=partner_id, q=q, status=status,
|
||||
client_type=client_type, client_group_id=client_group_id, include_archived=include_archived,
|
||||
)
|
||||
|
||||
total = db.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
|
||||
result = db.execute(
|
||||
stmt.order_by(_safe_sort(sort_by, sort_order))
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
).all()
|
||||
|
||||
result = db.execute(stmt.order_by(_safe_sort(sort_by, sort_order)).offset((page - 1) * per_page).limit(per_page)).all()
|
||||
rows=[]
|
||||
for (
|
||||
client,
|
||||
partner_name,
|
||||
branch_name,
|
||||
tenant_name,
|
||||
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)),
|
||||
)
|
||||
|
||||
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:
|
||||
row={**client.__dict__}; row.pop("_sa_instance_state",None)
|
||||
row.update({
|
||||
"partner_name":partner_name,"branch_name":branch_name,"tenant_name":tenant_name,
|
||||
"client_group_name":client_group_name,"client_group_code":client_group_code,
|
||||
"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:
|
||||
stats_stmt=stats_stmt.where(Client.tenant_id==tenant_id)
|
||||
if branch_id and not allow_cross_branch:
|
||||
stats_stmt = stats_stmt.where(Client.branch_id == branch_id)
|
||||
|
||||
if partner_id:
|
||||
stats_stmt = stats_stmt.where(Client.partner_id == partner_id)
|
||||
|
||||
if branch_id and not allow_cross_branch: 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()
|
||||
|
||||
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),
|
||||
},
|
||||
}
|
||||
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):
|
||||
assoc=ClientAssociation
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Client,
|
||||
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"),
|
||||
stmt=(select(
|
||||
Client, ClientGroup.group_name.label("client_group_name"), ClientGroup.group_code.label("client_group_code"),
|
||||
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"),
|
||||
)
|
||||
.outerjoin(assoc, assoc.client_id == Client.id)
|
||||
.where(Client.id == client_id)
|
||||
)
|
||||
|
||||
).outerjoin(assoc,assoc.client_id==Client.id).outerjoin(ClientGroup,ClientGroup.id==Client.client_group_id).where(Client.id==client_id))
|
||||
result=db.execute(stmt).one_or_none()
|
||||
if not result:
|
||||
return None
|
||||
|
||||
(
|
||||
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"),
|
||||
}
|
||||
)
|
||||
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)
|
||||
row.update({"client_group_name":client_group_name,"client_group_code":client_group_code,"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
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ class ClientBase(BaseModel):
|
||||
tenant_id: int
|
||||
branch_id: int
|
||||
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
|
||||
referred_by_consultant_id: Optional[int] = None
|
||||
primary_consultant_id: Optional[int] = None
|
||||
@@ -88,7 +91,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", "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",
|
||||
)
|
||||
@classmethod
|
||||
@@ -213,6 +216,9 @@ class ClientUpdate(BaseModel):
|
||||
tenant_id: Optional[int] = None
|
||||
branch_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
|
||||
referred_by_consultant_id: Optional[int] = None
|
||||
primary_consultant_id: Optional[int] = None
|
||||
@@ -272,7 +278,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", "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",
|
||||
)
|
||||
@classmethod
|
||||
|
||||
@@ -30,6 +30,8 @@ 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
|
||||
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:
|
||||
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)
|
||||
_enforce_client_acceptance_controls(payload)
|
||||
_validate_client_group_assignment(db, payload=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)
|
||||
@@ -301,6 +324,7 @@ def update_client_service(db, *, row, data, actor_user_id: int, scope, current_u
|
||||
|
||||
payload = _payload_from_schema(data)
|
||||
_enforce_client_acceptance_controls(payload, existing_row=row)
|
||||
_validate_client_group_assignment(db, payload=payload, existing_row=row)
|
||||
|
||||
if payload.get("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",
|
||||
"gstin",
|
||||
"partner",
|
||||
"client_group_code",
|
||||
"client_group_name",
|
||||
"group_relationship",
|
||||
"is_group_head",
|
||||
"association_type",
|
||||
"association_source",
|
||||
]
|
||||
@@ -455,6 +483,10 @@ def export_clients_csv(payload: dict) -> str:
|
||||
row.get("pan"),
|
||||
row.get("gstin"),
|
||||
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("assoc_created_source"),
|
||||
]
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<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">
|
||||
<h3 class="text-base font-semibold text-slate-900">Profile</h3>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<p class="text-sm text-slate-500">Association-aware list view.</p>
|
||||
</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 %}
|
||||
<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">
|
||||
|
||||
@@ -162,6 +162,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<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>
|
||||
<div class="mt-4 space-y-4">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.services.execution import list_client_visible_task_comments
|
||||
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 (
|
||||
build_client_portal_summary,
|
||||
create_client_reply,
|
||||
@@ -173,6 +174,9 @@ def _build_form_payload(request: Request, user, scope, *, include_client_code: b
|
||||
"tenant_id": tenant_id,
|
||||
"branch_id": branch_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,
|
||||
"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,
|
||||
@@ -260,6 +264,7 @@ def _form_options(db, scope, form_mode: str):
|
||||
"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 [],
|
||||
"client_groups": [item["group"] for item in list_groups(db, tenant_id=tenant_id)] if tenant_id else [],
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -271,6 +276,7 @@ def _form_options(db, scope, form_mode: str):
|
||||
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),
|
||||
"client_groups": [item["group"] for item in list_groups(db, tenant_id=tenant_id)],
|
||||
"review_partners": repository.list_partners_for_scope(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
@@ -287,6 +293,7 @@ def clients_list(
|
||||
q: str = "",
|
||||
status: str = "",
|
||||
client_type: str = "",
|
||||
client_group_id: int | None = None,
|
||||
partner_id: int | None = None,
|
||||
include_archived: bool = False,
|
||||
page: int = 1,
|
||||
@@ -313,6 +320,7 @@ def clients_list(
|
||||
status=status,
|
||||
client_type=client_type,
|
||||
partner_id=partner_id,
|
||||
client_group_id=client_group_id,
|
||||
include_archived=include_archived,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
@@ -332,6 +340,7 @@ def clients_list(
|
||||
q=filters.q,
|
||||
status=filters.status,
|
||||
client_type=filters.client_type,
|
||||
client_group_id=filters.client_group_id,
|
||||
include_archived=filters.include_archived,
|
||||
page=filters.page,
|
||||
per_page=filters.per_page,
|
||||
@@ -354,6 +363,7 @@ def clients_list(
|
||||
status=filters.status,
|
||||
client_type=filters.client_type,
|
||||
partner_id=filters.partner_id,
|
||||
client_group_id=filters.client_group_id,
|
||||
include_archived=filters.include_archived,
|
||||
page=filters.page,
|
||||
per_page=filters.per_page,
|
||||
@@ -697,6 +707,7 @@ def client_detail(request: Request, client_id: int):
|
||||
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),
|
||||
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_approve_acceptance=has("clients.acceptance.approve"),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import FastAPI
|
||||
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.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.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
|
||||
@@ -67,6 +68,7 @@ def mount_ui(app: FastAPI) -> None:
|
||||
app.include_router(system_admin_dashboard_router)
|
||||
app.include_router(work_detail_ui_router)
|
||||
app.include_router(clients_ui_router)
|
||||
app.include_router(client_groups_ui_router)
|
||||
app.include_router(registrations_ui_router)
|
||||
app.include_router(credential_vault_ui_router)
|
||||
app.include_router(client_identity_ui_router)
|
||||
|
||||
Reference in New Issue
Block a user