diff --git a/alembic/versions/20260805_scope_aware_client_services.py b/alembic/versions/20260805_scope_aware_client_services.py new file mode 100644 index 0000000..6060e94 --- /dev/null +++ b/alembic/versions/20260805_scope_aware_client_services.py @@ -0,0 +1,139 @@ +"""Scope-aware client services using the existing registrations module.""" + +from alembic import op +import sqlalchemy as sa + +revision = "20260805_scope_aware_services" +down_revision = "20260805_client_service_plan_master" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "client_business_units", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("business_code", sa.String(50), nullable=False), + sa.Column("business_name", sa.String(200), nullable=False), + sa.Column("trade_name", sa.String(200), nullable=True), + sa.Column("nature_of_business", sa.String(200), nullable=True), + sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id", "client_id", "business_code", name="uq_cbu_tenant_client_code"), + ) + op.create_index("ix_client_business_units_tenant_id", "client_business_units", ["tenant_id"]) + op.create_index("ix_client_business_units_client_id", "client_business_units", ["client_id"]) + + op.create_table( + "client_branches", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("business_unit_id", sa.Integer(), sa.ForeignKey("client_business_units.id", ondelete="CASCADE"), nullable=False), + sa.Column("branch_code", sa.String(50), nullable=False), + sa.Column("branch_name", sa.String(200), nullable=False), + sa.Column("branch_type", sa.String(40), nullable=False, server_default="branch"), + sa.Column("address_line_1", sa.String(255), nullable=True), + sa.Column("address_line_2", sa.String(255), nullable=True), + sa.Column("city", sa.String(100), nullable=True), + sa.Column("state", sa.String(100), nullable=True), + sa.Column("pincode", sa.String(20), nullable=True), + sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id", "business_unit_id", "branch_code", name="uq_cbranch_tenant_business_code"), + ) + op.create_index("ix_client_branches_tenant_id", "client_branches", ["tenant_id"]) + op.create_index("ix_client_branches_client_id", "client_branches", ["client_id"]) + op.create_index("ix_client_branches_business_unit_id", "client_branches", ["business_unit_id"]) + + # Reuse and extend the production client_registrations table introduced in Phase 4. + with op.batch_alter_table("client_registrations") as batch: + batch.add_column(sa.Column("business_unit_id", sa.Integer(), nullable=True)) + batch.add_column(sa.Column("client_branch_id", sa.Integer(), nullable=True)) + batch.add_column(sa.Column("legal_name", sa.String(200), nullable=True)) + batch.add_column(sa.Column("trade_name", sa.String(200), nullable=True)) + batch.add_column(sa.Column("state", sa.String(100), nullable=True)) + batch.create_foreign_key( + "fk_client_registrations_business_unit", + "client_business_units", ["business_unit_id"], ["id"], ondelete="SET NULL" + ) + batch.create_foreign_key( + "fk_client_registrations_client_branch", + "client_branches", ["client_branch_id"], ["id"], ondelete="SET NULL" + ) + batch.create_index("ix_client_registrations_business_unit_id", ["business_unit_id"]) + batch.create_index("ix_client_registrations_client_branch_id", ["client_branch_id"]) + + with op.batch_alter_table("service_catalogues") as batch: + batch.add_column(sa.Column("service_scope_type", sa.String(30), nullable=False, server_default="client")) + batch.add_column(sa.Column("required_registration_type", sa.String(40), nullable=True)) + batch.create_index("ix_service_catalogues_service_scope_type", ["service_scope_type"]) + batch.create_index("ix_service_catalogues_required_registration_type", ["required_registration_type"]) + + for table in ("client_service_plans", "client_service_subscriptions"): + with op.batch_alter_table(table) as batch: + batch.add_column(sa.Column("scope_type", sa.String(30), nullable=False, server_default="client")) + batch.add_column(sa.Column("scope_key", sa.String(80), nullable=False, server_default="")) + batch.add_column(sa.Column("business_unit_id", sa.Integer(), nullable=True)) + batch.add_column(sa.Column("client_branch_id", sa.Integer(), nullable=True)) + batch.add_column(sa.Column("registration_id", sa.Integer(), nullable=True)) + batch.create_foreign_key(f"fk_{table}_business_unit", "client_business_units", ["business_unit_id"], ["id"], ondelete="SET NULL") + batch.create_foreign_key(f"fk_{table}_client_branch", "client_branches", ["client_branch_id"], ["id"], ondelete="SET NULL") + batch.create_foreign_key(f"fk_{table}_registration", "client_registrations", ["registration_id"], ["id"], ondelete="SET NULL") + batch.create_index(f"ix_{table}_scope_type", ["scope_type"]) + batch.create_index(f"ix_{table}_scope_key", ["scope_key"]) + batch.create_index(f"ix_{table}_business_unit_id", ["business_unit_id"]) + batch.create_index(f"ix_{table}_client_branch_id", ["client_branch_id"]) + batch.create_index(f"ix_{table}_registration_id", ["registration_id"]) + + op.execute("UPDATE client_service_plans SET scope_key = 'CLIENT:' || client_id WHERE scope_key = ''") + op.execute("UPDATE client_service_subscriptions SET scope_key = 'CLIENT:' || client_id WHERE scope_key = ''") + + with op.batch_alter_table("client_service_plans") as batch: + batch.drop_constraint("uq_csp_tenant_client_service", type_="unique") + batch.create_unique_constraint("uq_csp_tenant_service_scope", ["tenant_id", "service_catalogue_id", "scope_key"]) + + with op.batch_alter_table("client_service_subscriptions") as batch: + batch.drop_constraint("uq_css_tenant_client_service_fy_period", type_="unique") + batch.create_unique_constraint( + "uq_css_tenant_service_scope_fy_period", + ["tenant_id", "service_catalogue_id", "scope_key", "financial_year", "period_label"], + ) + + +def downgrade(): + with op.batch_alter_table("client_service_subscriptions") as batch: + batch.drop_constraint("uq_css_tenant_service_scope_fy_period", type_="unique") + batch.create_unique_constraint( + "uq_css_tenant_client_service_fy_period", + ["tenant_id", "client_id", "service_catalogue_id", "financial_year", "period_label"], + ) + for name in ("registration_id", "client_branch_id", "business_unit_id", "scope_key", "scope_type"): + batch.drop_column(name) + + with op.batch_alter_table("client_service_plans") as batch: + batch.drop_constraint("uq_csp_tenant_service_scope", type_="unique") + batch.create_unique_constraint("uq_csp_tenant_client_service", ["tenant_id", "client_id", "service_catalogue_id"]) + for name in ("registration_id", "client_branch_id", "business_unit_id", "scope_key", "scope_type"): + batch.drop_column(name) + + with op.batch_alter_table("service_catalogues") as batch: + batch.drop_column("required_registration_type") + batch.drop_column("service_scope_type") + + with op.batch_alter_table("client_registrations") as batch: + for name in ("state", "trade_name", "legal_name", "client_branch_id", "business_unit_id"): + batch.drop_column(name) + + op.drop_table("client_branches") + op.drop_table("client_business_units") diff --git a/app/modules/clients/models.py b/app/modules/clients/models.py index 411112d..bcc88d1 100644 --- a/app/modules/clients/models.py +++ b/app/modules/clients/models.py @@ -203,3 +203,50 @@ class ClientEngagementLetter(CommonBase): 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) + + +class ClientBusinessUnit(CommonBase): + __tablename__ = "client_business_units" + __table_args__ = ( + UniqueConstraint("tenant_id", "client_id", "business_code", name="uq_cbu_tenant_client_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) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + business_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + business_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + nature_of_business: Mapped[str | None] = mapped_column(String(200), nullable=True) + is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=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"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), 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) + + +class ClientBranch(CommonBase): + __tablename__ = "client_branches" + __table_args__ = ( + UniqueConstraint("tenant_id", "business_unit_id", "branch_code", name="uq_cbranch_tenant_business_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) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + business_unit_id: Mapped[int] = mapped_column(ForeignKey("client_business_units.id", ondelete="CASCADE"), nullable=False, index=True) + branch_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + branch_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + branch_type: Mapped[str] = mapped_column(String(40), nullable=False, default="branch", index=True) + address_line_1: Mapped[str | None] = mapped_column(String(255), nullable=True) + address_line_2: Mapped[str | None] = mapped_column(String(255), nullable=True) + city: Mapped[str | None] = mapped_column(String(100), nullable=True) + state: Mapped[str | None] = mapped_column(String(100), nullable=True) + pincode: Mapped[str | None] = mapped_column(String(20), nullable=True) + is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=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"), nullable=True) + updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), 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) diff --git a/app/modules/clients/scope_ui.py b/app/modules/clients/scope_ui.py new file mode 100644 index 0000000..5becb4c --- /dev/null +++ b/app/modules/clients/scope_ui.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse +from sqlalchemy import select + +from app.core.db.common import CommonSessionLocal +from app.core.security.csrf import get_or_create_csrf_token, validate_csrf +from app.core.security.session_auth import get_current_user +from app.core.templating import templates +from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch +from app.modules.registrations.models import ClientRegistration, RegistrationType +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from app.modules.core.rbac.permission_guard import require_permission + +router = APIRouter(prefix="/clients", tags=["client-business-structure"]) + + +def _tenant_id(request, user): + return int(request.session.get("active_tenant_id") or request.session.get("tenant_id") or user.tenant_id) + + +def _load_client(db, tenant_id: int, client_id: int): + return db.execute(select(Client).where(Client.id == client_id, Client.tenant_id == tenant_id)).scalar_one_or_none() + + +def _context(request, db, user, **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 + + +@router.get("/{client_id}/business-structure") +def business_structure_page(request: Request, client_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") + client = _load_client(db, _tenant_id(request, user), client_id) + if not client: + return RedirectResponse("/clients", 303) + businesses = db.execute(select(ClientBusinessUnit).where( + ClientBusinessUnit.client_id == client.id + ).order_by(ClientBusinessUnit.is_active.desc(), ClientBusinessUnit.is_primary.desc(), ClientBusinessUnit.business_name)).scalars().all() + branches = db.execute(select(ClientBranch).where( + ClientBranch.client_id == client.id + ).order_by(ClientBranch.is_active.desc(), ClientBranch.is_primary.desc(), ClientBranch.branch_name)).scalars().all() + registration_rows = db.execute( + select(ClientRegistration, RegistrationType).join( + RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id + ).where(ClientRegistration.client_id == client.id).order_by( + ClientRegistration.status, RegistrationType.code, ClientRegistration.registration_number + ) + ).all() + registrations = [row[0] for row in registration_rows] + registration_type_codes = {row[0].id: row[1].code for row in registration_rows} + registration_types = db.execute( + select(RegistrationType).where(RegistrationType.is_active.is_(True)).order_by( + RegistrationType.sort_order, RegistrationType.name + ) + ).scalars().all() + return templates.TemplateResponse( + "modules/clients/templates/clients/business_structure.html", + _context(request, db, user, title="Client Business Structure", client=client, + businesses=businesses, branches=branches, registrations=registrations, + registration_type_codes=registration_type_codes, registration_types=registration_types, + can_edit="clients.edit" in set(get_user_permissions(db, user.id))), + ) + finally: + db.close() + + +@router.post("/{client_id}/business-units") +def add_business_unit(request: Request, client_id: int, business_code: str = Form(...), + business_name: str = Form(...), trade_name: str = Form(""), + nature_of_business: str = Form(""), is_primary: str | None = Form(None), + csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db); require_permission(db, user, "clients.edit") + tenant_id = _tenant_id(request, user); client = _load_client(db, tenant_id, client_id) + if not client: return RedirectResponse("/clients", 303) + if is_primary: + db.query(ClientBusinessUnit).filter_by(client_id=client.id).update({"is_primary": False}) + db.add(ClientBusinessUnit( + tenant_id=tenant_id, client_id=client.id, business_code=business_code.strip().upper(), + business_name=business_name.strip(), trade_name=trade_name.strip() or None, + nature_of_business=nature_of_business.strip() or None, is_primary=bool(is_primary), + is_active=True, created_by_user_id=user.id, updated_by_user_id=user.id, + )) + db.commit() + return RedirectResponse(f"/clients/{client_id}/business-structure", 303) + finally: + db.close() + + +@router.post("/{client_id}/branches") +def add_client_branch(request: Request, client_id: int, business_unit_id: int = Form(...), + branch_code: str = Form(...), branch_name: str = Form(...), + branch_type: str = Form("branch"), city: str = Form(""), + state: str = Form(""), pincode: str = Form(""), + is_primary: str | None = Form(None), csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db); require_permission(db, user, "clients.edit") + tenant_id = _tenant_id(request, user); client = _load_client(db, tenant_id, client_id) + business = db.get(ClientBusinessUnit, business_unit_id) + if not client or not business or business.client_id != client.id or business.tenant_id != tenant_id: + return RedirectResponse("/clients", 303) + if is_primary: + db.query(ClientBranch).filter_by(business_unit_id=business.id).update({"is_primary": False}) + db.add(ClientBranch( + tenant_id=tenant_id, client_id=client.id, business_unit_id=business.id, + branch_code=branch_code.strip().upper(), branch_name=branch_name.strip(), + branch_type=branch_type.strip() or "branch", city=city.strip() or None, + state=state.strip() or None, pincode=pincode.strip() or None, + is_primary=bool(is_primary), is_active=True, + created_by_user_id=user.id, updated_by_user_id=user.id, + )) + db.commit() + return RedirectResponse(f"/clients/{client_id}/business-structure", 303) + finally: + db.close() + + +@router.post("/{client_id}/registrations") +def add_registration(request: Request, client_id: int, business_unit_id: str = Form(""), + client_branch_id: str = Form(""), registration_type_id: int = Form(...), + registration_number: str = Form(...), legal_name: str = Form(""), + trade_name: str = Form(""), state: str = Form(""), + csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db); require_permission(db, user, "clients.edit") + tenant_id = _tenant_id(request, user); client = _load_client(db, tenant_id, client_id) + if not client: return RedirectResponse("/clients", 303) + bu_id = int(business_unit_id) if business_unit_id else None + br_id = int(client_branch_id) if client_branch_id else None + if bu_id: + business = db.get(ClientBusinessUnit, bu_id) + if not business or business.client_id != client.id: return RedirectResponse(f"/clients/{client_id}/business-structure", 303) + if br_id: + branch = db.get(ClientBranch, br_id) + if not branch or branch.client_id != client.id: return RedirectResponse(f"/clients/{client_id}/business-structure", 303) + bu_id = branch.business_unit_id + registration_type = db.get(RegistrationType, registration_type_id) + if not registration_type or not registration_type.is_active: + return RedirectResponse(f"/clients/{client_id}/business-structure", 303) + db.add(ClientRegistration( + tenant_id=tenant_id, + branch_id=getattr(client, "branch_id", None), + client_id=client.id, + business_unit_id=bu_id, + client_branch_id=br_id, + registration_type_id=registration_type.id, + registration_number=registration_number.strip().upper(), + legal_name=legal_name.strip() or client.client_name, + trade_name=trade_name.strip() or None, + state=state.strip() or None, + jurisdiction=state.strip() or None, + status="active", + primary_registration=False, + responsible_party="firm", + auto_create_task=True, + created_by_user_id=user.id, + )) + db.commit() + return RedirectResponse(f"/clients/{client_id}/business-structure", 303) + finally: + db.close() + + +@router.post("/{client_id}/business-structure/{entity}/{entity_id}/toggle") +def toggle_scope_record(request: Request, client_id: int, entity: str, entity_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db); require_permission(db, user, "clients.edit") + tenant_id = _tenant_id(request, user); client = _load_client(db, tenant_id, client_id) + model = {"business": ClientBusinessUnit, "branch": ClientBranch, "registration": ClientRegistration}.get(entity) + row = db.get(model, entity_id) if model else None + if not client or not row or row.client_id != client.id or row.tenant_id != tenant_id: + return RedirectResponse("/clients", 303) + if entity == "registration": + row.status = "inactive" if row.status in {"active", "valid", "registered"} else "active" + else: + row.is_active = not bool(row.is_active) + row.updated_by_user_id = user.id + db.commit() + return RedirectResponse(f"/clients/{client_id}/business-structure", 303) + finally: + db.close() diff --git a/app/modules/clients/templates/clients/business_structure.html b/app/modules/clients/templates/clients/business_structure.html new file mode 100644 index 0000000..37b4c64 --- /dev/null +++ b/app/modules/clients/templates/clients/business_structure.html @@ -0,0 +1,77 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

{{ client.client_name }} — Business Structure

+

Maintain Business Units, Client Branches and statutory registrations under one PAN/legal client.

+ Back to Client +
+ + {% if can_edit %} +
+
+ +

Add Business Unit

+ + + + + + +
+ +
+ +

Add Client Branch

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

Add Registration

+ + + + + + + + +
+
+ {% endif %} + +
+
Business Units
+ + {% for row in businesses %}{% else %}{% endfor %}
CodeBusiness UnitTrade NameNatureStatus
{{ row.business_code }}{{ row.business_name }}{% if row.is_primary %} Primary{% endif %}{{ row.trade_name or '-' }}{{ row.nature_of_business or '-' }}{{ 'Active' if row.is_active else 'Inactive' }}{% if can_edit %}
{% endif %}
No Business Units added.
+
+ +
+
Client Branches
+ + {% for row in branches %}{% else %}{% endfor %}
CodeClient BranchTypeLocationStatus
{{ row.branch_code }}{{ row.branch_name }}{% if row.is_primary %} Primary{% endif %}{{ row.branch_type|replace('_',' ')|title }}{{ row.city or '' }}{% if row.city and row.state %}, {% endif %}{{ row.state or '-' }}{{ 'Active' if row.is_active else 'Inactive' }}{% if can_edit %}
{% endif %}
No Client Branches added.
+
+ +
+
Registrations
+ + {% for row in registrations %}{% else %}{% endfor %}
TypeNumberTrade / Unit NameStateStatus
{{ registration_type_codes.get(row.id, "-") }}{{ row.registration_number }}{{ row.trade_name or row.legal_name or '-' }}{{ row.state or '-' }}{{ row.status|replace('_',' ')|title }}{% if can_edit %}
{% endif %}
No registrations added.
+
+
+{% endblock %} diff --git a/app/modules/clients/templates/clients/detail.html b/app/modules/clients/templates/clients/detail.html index 91de7f6..4ecaea5 100644 --- a/app/modules/clients/templates/clients/detail.html +++ b/app/modules/clients/templates/clients/detail.html @@ -9,6 +9,7 @@
{% if can_edit %} + Business Structure Portal Identity diff --git a/app/modules/registrations/models.py b/app/modules/registrations/models.py index 49b42bf..3783324 100644 --- a/app/modules/registrations/models.py +++ b/app/modules/registrations/models.py @@ -29,6 +29,8 @@ class ClientRelatedPerson(CommonBase): id: Mapped[int]=mapped_column(Integer, primary_key=True) tenant_id: Mapped[int]=mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) client_id: Mapped[int]=mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + business_unit_id: Mapped[int|None]=mapped_column(ForeignKey("client_business_units.id", ondelete="SET NULL"), index=True) + client_branch_id: Mapped[int|None]=mapped_column(ForeignKey("client_branches.id", ondelete="SET NULL"), index=True) person_type: Mapped[str]=mapped_column(String(60), nullable=False, index=True) full_name: Mapped[str]=mapped_column(String(200), nullable=False) designation: Mapped[str|None]=mapped_column(String(120)) @@ -56,6 +58,9 @@ class ClientRegistration(CommonBase): registration_type_id: Mapped[int]=mapped_column(ForeignKey("registration_types.id"), nullable=False, index=True) related_person_id: Mapped[int|None]=mapped_column(ForeignKey("client_related_persons.id", ondelete="SET NULL"), index=True) registration_number: Mapped[str]=mapped_column(String(120), nullable=False, index=True) + legal_name: Mapped[str|None]=mapped_column(String(200)) + trade_name: Mapped[str|None]=mapped_column(String(200)) + state: Mapped[str|None]=mapped_column(String(100)) jurisdiction: Mapped[str|None]=mapped_column(String(160)) state_code: Mapped[str|None]=mapped_column(String(10)) issue_date: Mapped[date|None]=mapped_column(Date) diff --git a/app/modules/services/models.py b/app/modules/services/models.py index eef4f46..553147e 100644 --- a/app/modules/services/models.py +++ b/app/modules/services/models.py @@ -37,6 +37,8 @@ class ServiceCatalogue(CommonBase): category_id: Mapped[int | None] = mapped_column(ForeignKey("service_categories.id"), nullable=True, index=True) recurrence_type: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True) engagement_type: Mapped[str] = mapped_column(String(20), nullable=False, default="non_assurance", index=True) + service_scope_type: Mapped[str] = mapped_column(String(30), nullable=False, default="client", server_default="client", index=True) + required_registration_type: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100) description: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -377,8 +379,8 @@ class ClientServicePlan(CommonBase): __tablename__ = "client_service_plans" __table_args__ = ( UniqueConstraint( - "tenant_id", "client_id", "service_catalogue_id", - name="uq_csp_tenant_client_service", + "tenant_id", "service_catalogue_id", "scope_key", + name="uq_csp_tenant_service_scope", ), ) @@ -386,6 +388,11 @@ class ClientServicePlan(CommonBase): tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + scope_type: Mapped[str] = mapped_column(String(30), nullable=False, default="client", server_default="client", index=True) + scope_key: Mapped[str] = mapped_column(String(80), nullable=False, default="", server_default="", index=True) + business_unit_id: Mapped[int | None] = mapped_column(ForeignKey("client_business_units.id", ondelete="SET NULL"), nullable=True, index=True) + client_branch_id: Mapped[int | None] = mapped_column(ForeignKey("client_branches.id", ondelete="SET NULL"), nullable=True, index=True) + registration_id: Mapped[int | None] = mapped_column(ForeignKey("client_registrations.id", ondelete="SET NULL"), nullable=True, index=True) service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True) firm_service_selection_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_selections.id", ondelete="SET NULL"), nullable=True, index=True) @@ -426,11 +433,11 @@ class ClientServiceSubscription(CommonBase): __table_args__ = ( UniqueConstraint( "tenant_id", - "client_id", "service_catalogue_id", + "scope_key", "financial_year", "period_label", - name="uq_css_tenant_client_service_fy_period", + name="uq_css_tenant_service_scope_fy_period", ), ) @@ -440,6 +447,11 @@ class ClientServiceSubscription(CommonBase): branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True) service_plan_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_plans.id", ondelete="SET NULL"), nullable=True, index=True) client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + scope_type: Mapped[str] = mapped_column(String(30), nullable=False, default="client", server_default="client", index=True) + scope_key: Mapped[str] = mapped_column(String(80), nullable=False, default="", server_default="", index=True) + business_unit_id: Mapped[int | None] = mapped_column(ForeignKey("client_business_units.id", ondelete="SET NULL"), nullable=True, index=True) + client_branch_id: Mapped[int | None] = mapped_column(ForeignKey("client_branches.id", ondelete="SET NULL"), nullable=True, index=True) + registration_id: Mapped[int | None] = mapped_column(ForeignKey("client_registrations.id", ondelete="SET NULL"), nullable=True, index=True) service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True) firm_service_selection_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_selections.id", ondelete="SET NULL"), nullable=True, index=True) diff --git a/app/modules/services/scope_targets.py b/app/modules/services/scope_targets.py new file mode 100644 index 0000000..abfaf1b --- /dev/null +++ b/app/modules/services/scope_targets.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +from dataclasses import dataclass +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch +from app.modules.registrations.models import ClientRegistration, RegistrationType +from app.modules.services.models import ClientServicePlan, ServiceCatalogue + +VALID_SCOPE_TYPES = {"client", "business_unit", "client_branch", "registration"} +VALID_REGISTRATION_TYPES = {"GST", "TAN", "PF", "ESI", "PT", "IEC", "FSSAI", "UDYAM", "OTHER"} + + +@dataclass +class ScopeTarget: + token: str + scope_type: str + scope_key: str + client_id: int + business_unit_id: int | None + client_branch_id: int | None + registration_id: int | None + client_code: str + client_name: str + pan: str + business_unit: str + client_branch: str + registration_type: str + registration_number: str + trade_name: str + state: str + entity_type: str + partner_id: int | None + existing_plan_status: str = "" + + +def normalize_scope_type(value: str | None) -> str: + value = (value or "client").strip().lower().replace("-", "_").replace(" ", "_") + return value if value in VALID_SCOPE_TYPES else "client" + + +def normalize_registration_type(value: str | None) -> str | None: + value = (value or "").strip().upper() + return value if value in VALID_REGISTRATION_TYPES else None + + +def scope_key(scope_type: str, target_id: int) -> str: + prefixes = { + "client": "CLIENT", + "business_unit": "BUSINESS", + "client_branch": "BRANCH", + "registration": "REGISTRATION", + } + return f"{prefixes[scope_type]}:{int(target_id)}" + + +def _plan_status_map(db: Session, tenant_id: int, service_catalogue_id: int) -> dict[str, str]: + rows = db.execute( + select(ClientServicePlan.scope_key, ClientServicePlan.status).where( + ClientServicePlan.tenant_id == tenant_id, + ClientServicePlan.service_catalogue_id == service_catalogue_id, + ) + ).all() + return {key: status for key, status in rows if key} + + +def list_scope_targets( + db: Session, + *, + tenant_id: int, + clients: list[Client], + catalogue: ServiceCatalogue, +) -> list[ScopeTarget]: + client_map = {int(c.id): c for c in clients} + client_ids = list(client_map) + if not client_ids: + return [] + + selected_scope = normalize_scope_type(catalogue.service_scope_type) + required_registration = normalize_registration_type(catalogue.required_registration_type) + plan_status = _plan_status_map(db, tenant_id, catalogue.id) + result: list[ScopeTarget] = [] + + if selected_scope == "client": + for client in clients: + key = scope_key("client", client.id) + result.append(ScopeTarget( + token=f"client:{client.id}", scope_type="client", scope_key=key, + client_id=client.id, business_unit_id=None, client_branch_id=None, registration_id=None, + client_code=client.client_code or "", client_name=client.client_name or "", + pan=client.pan or "", business_unit="", client_branch="", registration_type="", + registration_number="", trade_name=client.trade_name or "", state=client.state or "", + entity_type=client.client_type or "", partner_id=client.partner_id, + existing_plan_status=plan_status.get(key, ""), + )) + return result + + businesses = db.execute( + select(ClientBusinessUnit).where( + ClientBusinessUnit.tenant_id == tenant_id, + ClientBusinessUnit.client_id.in_(client_ids), + ClientBusinessUnit.is_active.is_(True), + ).order_by(ClientBusinessUnit.client_id, ClientBusinessUnit.is_primary.desc(), ClientBusinessUnit.business_name) + ).scalars().all() + + if selected_scope == "business_unit": + actual_clients = set() + for business in businesses: + client = client_map[business.client_id] + actual_clients.add(client.id) + key = scope_key("business_unit", business.id) + result.append(ScopeTarget( + token=f"business:{business.id}", scope_type="business_unit", scope_key=key, + client_id=client.id, business_unit_id=business.id, client_branch_id=None, registration_id=None, + client_code=client.client_code or "", client_name=client.client_name or "", pan=client.pan or "", + business_unit=business.business_name, client_branch="", registration_type="", registration_number="", + trade_name=business.trade_name or client.trade_name or "", state=client.state or "", + entity_type=client.client_type or "", partner_id=client.partner_id, + existing_plan_status=plan_status.get(key, ""), + )) + for client in clients: + if client.id not in actual_clients: + result.append(ScopeTarget( + token=f"business:auto:{client.id}", scope_type="business_unit", scope_key=f"AUTO_BUSINESS:{client.id}", + client_id=client.id, business_unit_id=None, client_branch_id=None, registration_id=None, + client_code=client.client_code or "", client_name=client.client_name or "", pan=client.pan or "", + business_unit=client.trade_name or client.client_name, client_branch="", registration_type="", + registration_number="", trade_name=client.trade_name or "", state=client.state or "", + entity_type=client.client_type or "", partner_id=client.partner_id, existing_plan_status="", + )) + return result + + business_map = {b.id: b for b in businesses} + branches = db.execute( + select(ClientBranch).where( + ClientBranch.tenant_id == tenant_id, + ClientBranch.client_id.in_(client_ids), + ClientBranch.is_active.is_(True), + ).order_by(ClientBranch.client_id, ClientBranch.is_primary.desc(), ClientBranch.branch_name) + ).scalars().all() + + if selected_scope == "client_branch": + for branch in branches: + client = client_map[branch.client_id] + business = business_map.get(branch.business_unit_id) + key = scope_key("client_branch", branch.id) + result.append(ScopeTarget( + token=f"branch:{branch.id}", scope_type="client_branch", scope_key=key, + client_id=client.id, business_unit_id=branch.business_unit_id, client_branch_id=branch.id, registration_id=None, + client_code=client.client_code or "", client_name=client.client_name or "", pan=client.pan or "", + business_unit=business.business_name if business else "", client_branch=branch.branch_name, + registration_type="", registration_number="", trade_name=(business.trade_name if business else "") or client.trade_name or "", + state=branch.state or client.state or "", entity_type=client.client_type or "", partner_id=client.partner_id, + existing_plan_status=plan_status.get(key, ""), + )) + return result + + branch_map = {b.id: b for b in branches} + registration_rows = db.execute( + select(ClientRegistration, RegistrationType).join( + RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id + ).where( + ClientRegistration.tenant_id == tenant_id, + ClientRegistration.client_id.in_(client_ids), + ClientRegistration.status.in_(("active", "valid", "registered")), + RegistrationType.is_active.is_(True), + ).order_by(ClientRegistration.client_id, RegistrationType.code, ClientRegistration.registration_number) + ).all() + + actual_legacy = set() + for registration, registration_type in registration_rows: + registration_code = (registration_type.code or "").strip().upper() + if required_registration and registration_code != required_registration: + continue + client = client_map[registration.client_id] + business = business_map.get(registration.business_unit_id) + branch = branch_map.get(registration.client_branch_id) + key = scope_key("registration", registration.id) + actual_legacy.add((client.id, registration_code)) + result.append(ScopeTarget( + token=f"registration:{registration.id}", scope_type="registration", scope_key=key, + client_id=client.id, business_unit_id=registration.business_unit_id, + client_branch_id=registration.client_branch_id, registration_id=registration.id, + client_code=client.client_code or "", client_name=client.client_name or "", pan=client.pan or "", + business_unit=business.business_name if business else "", client_branch=branch.branch_name if branch else "", + registration_type=registration_code, registration_number=registration.registration_number, + trade_name=registration.trade_name or (business.trade_name if business else "") or client.trade_name or "", + state=registration.state or (branch.state if branch else "") or client.state or "", + entity_type=client.client_type or "", partner_id=client.partner_id, + existing_plan_status=plan_status.get(key, ""), + )) + + # Existing legacy GSTIN/TAN values remain usable immediately after migration. + for client in clients: + legacy_pairs = [] + if required_registration in (None, "GST") and client.gstin: + legacy_pairs.append(("GST", client.gstin)) + if required_registration in (None, "TAN") and client.tan: + legacy_pairs.append(("TAN", client.tan)) + for reg_type, number in legacy_pairs: + if (client.id, reg_type) in actual_legacy: + continue + result.append(ScopeTarget( + token=f"registration:legacy:{reg_type}:{client.id}", scope_type="registration", + scope_key=f"LEGACY_{reg_type}:{client.id}", client_id=client.id, + business_unit_id=None, client_branch_id=None, registration_id=None, + client_code=client.client_code or "", client_name=client.client_name or "", pan=client.pan or "", + business_unit=client.trade_name or client.client_name, client_branch="Primary Branch", + registration_type=reg_type, registration_number=number, trade_name=client.trade_name or "", + state=client.state or "", entity_type=client.client_type or "", partner_id=client.partner_id, + existing_plan_status="", + )) + return result + + +def _next_code(prefix: str, value: int) -> str: + return f"{prefix}{int(value):05d}" + + +def resolve_scope_target( + db: Session, + *, + tenant_id: int, + clients_by_id: dict[int, Client], + token: str, + actor_user_id: int, +): + parts = (token or "").split(":") + if not parts: + raise ValueError("Invalid subscription scope.") + + kind = parts[0] + if kind == "client" and len(parts) == 2: + client = clients_by_id.get(int(parts[1])) + if not client: + raise ValueError("Client is outside the permitted scope.") + return client, "client", scope_key("client", client.id), None, None, None + + if kind == "business": + if len(parts) == 2: + business = db.get(ClientBusinessUnit, int(parts[1])) + if not business or business.tenant_id != tenant_id or business.client_id not in clients_by_id: + raise ValueError("Business Unit is outside the permitted scope.") + elif len(parts) == 3 and parts[1] == "auto": + client = clients_by_id.get(int(parts[2])) + if not client: + raise ValueError("Client is outside the permitted scope.") + business = ClientBusinessUnit( + tenant_id=tenant_id, client_id=client.id, + business_code=_next_code("BU", client.id), + business_name=client.trade_name or client.client_name, + trade_name=client.trade_name, is_primary=True, is_active=True, + created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id, + ) + db.add(business); db.flush() + else: + raise ValueError("Invalid Business Unit.") + client = clients_by_id[business.client_id] + return client, "business_unit", scope_key("business_unit", business.id), business.id, None, None + + if kind == "branch" and len(parts) == 2: + branch = db.get(ClientBranch, int(parts[1])) + if not branch or branch.tenant_id != tenant_id or branch.client_id not in clients_by_id: + raise ValueError("Client Branch is outside the permitted scope.") + client = clients_by_id[branch.client_id] + return client, "client_branch", scope_key("client_branch", branch.id), branch.business_unit_id, branch.id, None + + if kind == "registration": + if len(parts) == 2: + reg = db.get(ClientRegistration, int(parts[1])) + if not reg or reg.tenant_id != tenant_id or reg.client_id not in clients_by_id: + raise ValueError("Registration is outside the permitted scope.") + elif len(parts) == 4 and parts[1] == "legacy": + reg_type = parts[2].upper() + client = clients_by_id.get(int(parts[3])) + if not client: + raise ValueError("Client is outside the permitted scope.") + number = client.gstin if reg_type == "GST" else client.tan if reg_type == "TAN" else None + if not number: + raise ValueError("Legacy registration is no longer available.") + business = db.execute( + select(ClientBusinessUnit).where( + ClientBusinessUnit.tenant_id == tenant_id, + ClientBusinessUnit.client_id == client.id, + ClientBusinessUnit.is_active.is_(True), + ).order_by(ClientBusinessUnit.is_primary.desc(), ClientBusinessUnit.id) + ).scalars().first() + if not business: + business = ClientBusinessUnit( + tenant_id=tenant_id, client_id=client.id, + business_code=_next_code("BU", client.id), + business_name=client.trade_name or client.client_name, + trade_name=client.trade_name, is_primary=True, is_active=True, + created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id, + ) + db.add(business); db.flush() + branch = db.execute( + select(ClientBranch).where( + ClientBranch.tenant_id == tenant_id, + ClientBranch.business_unit_id == business.id, + ClientBranch.is_active.is_(True), + ).order_by(ClientBranch.is_primary.desc(), ClientBranch.id) + ).scalars().first() + if not branch: + branch = ClientBranch( + tenant_id=tenant_id, client_id=client.id, business_unit_id=business.id, + branch_code=_next_code("BR", client.id), branch_name="Primary Branch", + branch_type="head_office", state=client.state, is_primary=True, is_active=True, + created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id, + ) + db.add(branch); db.flush() + registration_type = db.execute( + select(RegistrationType).where(RegistrationType.code == reg_type) + ).scalar_one_or_none() + if not registration_type: + registration_type = RegistrationType( + code=reg_type, + name=reg_type, + category="registration", + identifier_label=f"{reg_type} Number", + supports_expiry=False, + supports_related_person=False, + is_system=True, + is_active=True, + sort_order=0, + ) + db.add(registration_type) + db.flush() + reg = ClientRegistration( + tenant_id=tenant_id, + branch_id=getattr(client, "branch_id", None), + client_id=client.id, + business_unit_id=business.id, + client_branch_id=branch.id, + registration_type_id=registration_type.id, + registration_number=number.strip().upper(), + legal_name=client.client_name, + trade_name=client.trade_name, + state=client.state, + jurisdiction=client.state, + status="active", + primary_registration=True, + responsible_party="firm", + auto_create_task=True, + created_by_user_id=actor_user_id, + ) + db.add(reg); db.flush() + else: + raise ValueError("Invalid registration.") + client = clients_by_id[reg.client_id] + return client, "registration", scope_key("registration", reg.id), reg.business_unit_id, reg.client_branch_id, reg.id + + raise ValueError("Invalid subscription scope.") diff --git a/app/modules/services/subscriptions_ui.py b/app/modules/services/subscriptions_ui.py index 8366824..30fbd9d 100644 --- a/app/modules/services/subscriptions_ui.py +++ b/app/modules/services/subscriptions_ui.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import date, datetime, timezone from fastapi import APIRouter, Form, Request -from fastapi.responses import RedirectResponse +from fastapi.responses import JSONResponse, RedirectResponse from sqlalchemy import func, or_, select from sqlalchemy.orm import selectinload @@ -36,6 +36,7 @@ from app.modules.services.client_services import ( ) from app.modules.clients.models import Client from app.modules.services.due_dates import apply_due_date_rule_to_subscription +from app.modules.services.scope_targets import list_scope_targets, resolve_scope_target from app.modules.services.models import ( ClientServicePlan, ClientServiceSubscription, @@ -240,27 +241,13 @@ def subscription_bulk_page( tenant_id = _tenant_id(request, user) branch_id = _branch_id(request, user) - clients = list_clients_for_assignment( - db, - tenant_id=tenant_id, - branch_id=branch_id, - partner_id=_partner_scope_id(db, user), - ) enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id) - partners = list_assignable_users( - db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Partner",) - ) - managers = list_assignable_users( - db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Branch Manager",) - ) - staff_users = list_assignable_users( - db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Staff",) - ) - review_partners = list_review_partners( - db, tenant_id=tenant_id, branch_id=branch_id - ) + partners = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Partner",)) + managers = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Branch Manager",)) + staff_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Staff",)) + review_partners = list_review_partners(db, tenant_id=tenant_id, branch_id=branch_id) error_messages = { - "clients": "Select at least one permitted client.", + "targets": "Select at least one permitted Client, Business Unit, Client Branch or Registration.", "service": "Select a valid enabled firm service.", "partner": "Select a valid Engagement Partner.", "performing_partner": "Select a valid Performing Partner.", @@ -276,20 +263,9 @@ def subscription_bulk_page( return templates.TemplateResponse( "modules/services/templates/services/subscriptions/bulk.html", _ctx( - request, - db, - user, - title="Bulk Client Subscriptions", - clients=clients, - enabled_services=enabled_services, - partners=partners, - managers=managers, - staff_users=staff_users, - review_partners=review_partners, - client_partner_names={ - row.id: (row.full_name or row.email) - for row in partners - }, + request, db, user, title="Bulk Client Subscriptions", + enabled_services=enabled_services, partners=partners, managers=managers, + staff_users=staff_users, review_partners=review_partners, financial_year=_active_financial_year(request), error_message=error_messages.get(error, ""), subscriptions_created=subscriptions_created, @@ -302,10 +278,59 @@ def subscription_bulk_page( db.close() +@router.get("/bulk/targets") +def subscription_bulk_targets(request: Request, service_catalogue_id: int): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return JSONResponse({"error": "Authentication required."}, status_code=401) + try: + require_permission(db, user, "clients.edit") + except Exception: + return JSONResponse({"error": "Access denied."}, status_code=403) + + tenant_id = _tenant_id(request, user) + branch_id = _branch_id(request, user) + firm_selection = get_enabled_firm_service(db, tenant_id=tenant_id, service_catalogue_id=service_catalogue_id) + if not firm_selection: + return JSONResponse({"error": "Enabled firm service not found."}, status_code=404) + + clients = list_clients_for_assignment( + db, tenant_id=tenant_id, branch_id=branch_id, partner_id=_partner_scope_id(db, user) + ) + targets = list_scope_targets(db, tenant_id=tenant_id, clients=clients, catalogue=firm_selection.catalogue) + scope_type = getattr(firm_selection.catalogue, "service_scope_type", "client") or "client" + registration_type = getattr(firm_selection.catalogue, "required_registration_type", None) + return { + "scope_type": scope_type, + "registration_type": registration_type, + "targets": [ + { + "token": row.token, + "client_code": row.client_code, + "client_name": row.client_name, + "pan": row.pan, + "business_unit": row.business_unit, + "client_branch": row.client_branch, + "registration_type": row.registration_type, + "registration_number": row.registration_number, + "trade_name": row.trade_name, + "state": row.state, + "entity_type": row.entity_type, + "subscription_status": row.existing_plan_status or "not_subscribed", + } + for row in targets + ], + } + finally: + db.close() + + @router.post("/bulk") def subscription_bulk_submit( request: Request, - client_ids: list[int] = Form([]), + scope_targets: list[str] = Form([]), service_catalogue_id: int = Form(...), default_partner_user_id: int = Form(...), default_performing_partner_user_id: str = Form(""), @@ -323,10 +348,7 @@ def subscription_bulk_submit( ): validate_csrf(request, csrf_token) db = CommonSessionLocal() - subscriptions_created = 0 - subscriptions_reused = 0 - engagements_created = 0 - engagements_skipped = 0 + subscriptions_created = subscriptions_reused = engagements_created = engagements_skipped = 0 try: user = get_current_user(request, db=db) if not user: @@ -338,70 +360,41 @@ def subscription_bulk_submit( tenant_id = _tenant_id(request, user) branch_id = _branch_id(request, user) - permitted_clients = list_clients_for_assignment( - db, - tenant_id=tenant_id, - branch_id=branch_id, - partner_id=_partner_scope_id(db, user), - ) - permitted_client_map = {row.id: row for row in permitted_clients} - selected_client_ids = list(dict.fromkeys(int(value) for value in client_ids)) - if not selected_client_ids or any(value not in permitted_client_map for value in selected_client_ids): - return RedirectResponse("/services/subscriptions/bulk?error=clients", 303) - - firm_selection = get_enabled_firm_service( - db, - tenant_id=tenant_id, - service_catalogue_id=service_catalogue_id, - ) + firm_selection = get_enabled_firm_service(db, tenant_id=tenant_id, service_catalogue_id=service_catalogue_id) if not firm_selection: return RedirectResponse("/services/subscriptions/bulk?error=service", 303) - plan_branch_id = ( - branch_id - or getattr(firm_selection, "default_branch_id", None) - or getattr(user, "branch_id", None) - ) - partners = list_assignable_users( - db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Partner",) - ) - managers = list_assignable_users( - db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Branch Manager",) - ) - staff_users = list_assignable_users( - db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Staff",) - ) - review_partners = list_review_partners( - db, tenant_id=tenant_id, branch_id=plan_branch_id + permitted_clients = list_clients_for_assignment( + db, tenant_id=tenant_id, branch_id=branch_id, partner_id=_partner_scope_id(db, user) ) + clients_by_id = {int(row.id): row for row in permitted_clients} + selected_tokens = list(dict.fromkeys(value for value in scope_targets if value)) + if not selected_tokens: + return RedirectResponse("/services/subscriptions/bulk?error=targets", 303) - partner_ids = {row.id for row in partners} - manager_ids = {row.id for row in managers} - staff_ids = {row.id for row in staff_users} - review_partner_ids = {row.id for row in review_partners} + plan_branch_id = branch_id or getattr(firm_selection, "default_branch_id", None) or getattr(user, "branch_id", None) + partners = list_assignable_users(db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Partner",)) + managers = list_assignable_users(db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Branch Manager",)) + staff_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Staff",)) + review_partners = list_review_partners(db, tenant_id=tenant_id, branch_id=plan_branch_id) - if default_partner_user_id not in partner_ids: + if default_partner_user_id not in {row.id for row in partners}: return RedirectResponse("/services/subscriptions/bulk?error=partner", 303) performing_partner_id = _optional_int(default_performing_partner_user_id) or default_partner_user_id - if performing_partner_id not in partner_ids: + if performing_partner_id not in {row.id for row in partners}: return RedirectResponse("/services/subscriptions/bulk?error=performing_partner", 303) manager_id = _optional_int(default_manager_user_id) - if manager_id is not None and manager_id not in manager_ids: + if manager_id is not None and manager_id not in {row.id for row in managers}: return RedirectResponse("/services/subscriptions/bulk?error=manager", 303) staff_id = _optional_int(default_staff_user_id) - if staff_id is not None and staff_id not in staff_ids: + if staff_id is not None and staff_id not in {row.id for row in staff_users}: return RedirectResponse("/services/subscriptions/bulk?error=staff", 303) review_partner_id = _optional_int(default_review_partner_user_id) - if review_partner_id is not None and review_partner_id not in review_partner_ids: + if review_partner_id is not None and review_partner_id not in {row.id for row in review_partners}: return RedirectResponse("/services/subscriptions/bulk?error=review_partner", 303) - engagement_type = ( - getattr(firm_selection.catalogue, "engagement_type", None) - or "non_assurance" - ) - review_required = review_partner_required_for_engagement( - db, tenant_id=tenant_id, engagement_type=engagement_type - ) + engagement_type = getattr(firm_selection.catalogue, "engagement_type", None) or "non_assurance" + review_required = review_partner_required_for_engagement(db, tenant_id=tenant_id, engagement_type=engagement_type) if review_required and review_partner_id is None: return RedirectResponse("/services/subscriptions/bulk?error=review_partner_required", 303) if review_required and review_partner_id in {default_partner_user_id, performing_partner_id}: @@ -409,39 +402,26 @@ def subscription_bulk_submit( if generation_mode not in {"subscription_only", "current_period", "all_periods"}: return RedirectResponse("/services/subscriptions/bulk?error=generation", 303) - - selected_financial_year = normalize_financial_year( - financial_year or _active_financial_year(request) - ) + selected_financial_year = normalize_financial_year(financial_year or _active_financial_year(request)) if generation_mode != "subscription_only": - locked_response = redirect_if_financial_year_locked( - db, - tenant_id=tenant_id, - year_code=selected_financial_year, + locked = redirect_if_financial_year_locked( + db, tenant_id=tenant_id, year_code=selected_financial_year, redirect_url="/services/subscriptions/bulk?error=financial_year", ) - if locked_response: - return locked_response + if locked: + return locked recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None) if generation_mode == "subscription_only": - requested_periods: list[str] = [] + requested_periods = [] elif recurrence_requires_period(recurrence_type): if generation_mode == "all_periods": - requested_periods = [ - code for code, _label in period_choices_for_service( - selected_financial_year, recurrence_type - ) - ] + requested_periods = [code for code, _ in period_choices_for_service(selected_financial_year, recurrence_type)] else: try: - requested_periods = [ - normalize_period_label( - period_label, - financial_year=selected_financial_year, - recurrence_type=recurrence_type, - ) - ] + requested_periods = [normalize_period_label( + period_label, financial_year=selected_financial_year, recurrence_type=recurrence_type + )] except ValueError: return RedirectResponse("/services/subscriptions/bulk?error=period", 303) else: @@ -452,54 +432,51 @@ def subscription_bulk_submit( if plan_effective_from and plan_effective_to and plan_effective_to < plan_effective_from: return RedirectResponse("/services/subscriptions/bulk?error=period", 303) - for client_id in selected_client_ids: - client = permitted_client_map[client_id] - existing_plan = db.execute( - select(ClientServicePlan).where( - ClientServicePlan.tenant_id == tenant_id, - ClientServicePlan.client_id == client.id, - ClientServicePlan.service_catalogue_id == service_catalogue_id, + for token in selected_tokens: + try: + client, scope_type, scope_key, business_unit_id, client_branch_id, registration_id = resolve_scope_target( + db, tenant_id=tenant_id, clients_by_id=clients_by_id, token=token, actor_user_id=user.id ) - ).scalar_one_or_none() + except ValueError: + db.rollback() + return RedirectResponse("/services/subscriptions/bulk?error=targets", 303) - plan = get_or_create_client_service_plan( - db, - tenant_id=tenant_id, - client=client, - catalogue=firm_selection.catalogue, - firm_selection=firm_selection, - branch_id=plan_branch_id or getattr(client, "branch_id", None), - partner_user_id=default_partner_user_id, - performing_partner_user_id=performing_partner_id, - manager_user_id=manager_id, - staff_user_id=staff_id, - review_partner_user_id=review_partner_id, - actor_user_id=user.id, - remarks=remarks.strip() or None, - ) - plan.effective_from = plan_effective_from - plan.effective_to = plan_effective_to - plan.auto_generate_periods = auto_generate_periods is not None - plan.status = "active" - plan.is_active = True - plan.updated_by_user_id = user.id - if remarks.strip(): - plan.remarks = remarks.strip() + plan = db.execute(select(ClientServicePlan).where( + ClientServicePlan.tenant_id == tenant_id, + ClientServicePlan.service_catalogue_id == service_catalogue_id, + ClientServicePlan.scope_key == scope_key, + )).scalar_one_or_none() - if existing_plan is None: - subscriptions_created += 1 - else: + if plan: subscriptions_reused += 1 + else: + plan = ClientServicePlan( + tenant_id=tenant_id, branch_id=plan_branch_id or client.branch_id, + client_id=client.id, scope_type=scope_type, scope_key=scope_key, + business_unit_id=business_unit_id, client_branch_id=client_branch_id, + registration_id=registration_id, service_catalogue_id=service_catalogue_id, + firm_service_selection_id=firm_selection.id, + default_partner_user_id=default_partner_user_id, + default_performing_partner_user_id=performing_partner_id, + default_manager_user_id=manager_id, default_staff_user_id=staff_id, + default_review_partner_user_id=review_partner_id, + recurrence_type=(recurrence_type or "one_time"), + effective_from=plan_effective_from, effective_to=plan_effective_to, + auto_generate_periods=auto_generate_periods is not None, + status="active", is_active=True, remarks=remarks.strip() or None, + created_by_user_id=user.id, updated_by_user_id=user.id, + ) + db.add(plan); db.flush() + subscriptions_created += 1 for requested_period in requested_periods: - existing = get_existing_subscription( - db, - tenant_id=tenant_id, - client_id=client.id, - service_catalogue_id=service_catalogue_id, - financial_year=selected_financial_year, - period_label=requested_period, - ) + existing = db.execute(select(ClientServiceSubscription).where( + ClientServiceSubscription.tenant_id == tenant_id, + ClientServiceSubscription.service_catalogue_id == service_catalogue_id, + ClientServiceSubscription.scope_key == scope_key, + ClientServiceSubscription.financial_year == selected_financial_year, + ClientServiceSubscription.period_label == requested_period, + )).scalar_one_or_none() if existing: if existing.service_plan_id is None: existing.service_plan_id = plan.id @@ -507,57 +484,37 @@ def subscription_bulk_submit( continue engagement = ClientServiceSubscription( - tenant_id=tenant_id, - branch_id=plan.branch_id or getattr(client, "branch_id", None), - service_plan_id=plan.id, - client_id=client.id, - service_catalogue_id=service_catalogue_id, + tenant_id=tenant_id, branch_id=plan.branch_id, service_plan_id=plan.id, + client_id=client.id, scope_type=scope_type, scope_key=scope_key, + business_unit_id=business_unit_id, client_branch_id=client_branch_id, + registration_id=registration_id, service_catalogue_id=service_catalogue_id, firm_service_selection_id=firm_selection.id, assigned_partner_user_id=default_partner_user_id, performing_partner_user_id=performing_partner_id, - assigned_manager_user_id=manager_id, - assigned_staff_user_id=staff_id, + assigned_manager_user_id=manager_id, assigned_staff_user_id=staff_id, review_partner_user_id=review_partner_id if review_required else None, - financial_year=selected_financial_year, - period_label=requested_period, + financial_year=selected_financial_year, period_label=requested_period, assessment_year=assessment_year_from_financial_year(selected_financial_year), - engagement_type=engagement_type, - start_date=plan_effective_from, - end_date=plan_effective_to, - status="active", - remarks=remarks.strip() or None, - is_active=True, - created_by_user_id=user.id, - updated_by_user_id=user.id, + engagement_type=engagement_type, start_date=plan_effective_from, + end_date=plan_effective_to, status="active", remarks=remarks.strip() or None, + is_active=True, created_by_user_id=user.id, updated_by_user_id=user.id, ) - db.add(engagement) - db.flush() + db.add(engagement); db.flush() attach_engagement_to_plan( - db, - engagement=engagement, - client=client, - catalogue=firm_selection.catalogue, - firm_selection=firm_selection, - actor_user_id=user.id, + db, engagement=engagement, client=client, catalogue=firm_selection.catalogue, + firm_selection=firm_selection, actor_user_id=user.id, ) apply_due_date_rule_to_subscription(db, engagement, force=True) - ensure_engagement_quality_workflow( - db, - subscription=engagement, - actor_user_id=user.id, - create_declarations=False, - ) + ensure_engagement_quality_workflow(db, subscription=engagement, actor_user_id=user.id, create_declarations=False) enforce_quality_gate_on_subscription(engagement) engagements_created += 1 db.commit() return RedirectResponse( "/services/subscriptions/bulk" - f"?subscriptions_created={subscriptions_created}" - f"&subscriptions_reused={subscriptions_reused}" - f"&engagements_created={engagements_created}" - f"&engagements_skipped={engagements_skipped}", - status_code=303, + f"?subscriptions_created={subscriptions_created}&subscriptions_reused={subscriptions_reused}" + f"&engagements_created={engagements_created}&engagements_skipped={engagements_skipped}", + 303, ) except Exception: db.rollback() diff --git a/app/modules/services/templates/services/catalogue_form.html b/app/modules/services/templates/services/catalogue_form.html index a699d6d..1015ea9 100644 --- a/app/modules/services/templates/services/catalogue_form.html +++ b/app/modules/services/templates/services/catalogue_form.html @@ -9,6 +9,22 @@

This value will be copied to client engagements when the service is assigned.

+
+ +
+
+ +

Used only for Registration-level services.

+
@@ -27,4 +43,12 @@
Cancel
+ {% endblock %} diff --git a/app/modules/services/templates/services/subscriptions/bulk.html b/app/modules/services/templates/services/subscriptions/bulk.html index cd6ec06..82bcc8a 100644 --- a/app/modules/services/templates/services/subscriptions/bulk.html +++ b/app/modules/services/templates/services/subscriptions/bulk.html @@ -2,493 +2,161 @@ {% block content %}
{% set _uiux_partner_role_text = (current_user_roles or [])|join('|')|lower %} - {% if 'partner' in _uiux_partner_role_text %} - {% include "ui/templates/components/partner_navigation_v2.html" %} - {% endif %} + {% if 'partner' in _uiux_partner_role_text %}{% include "ui/templates/components/partner_navigation_v2.html" %}{% endif %}
-
-

Bulk Subscription Setup

-

- Choose one enabled firm service, create the subscription for multiple clients, and optionally generate period-wise engagements. -

-
- - Back - +

Bulk Subscription Setup

+

The selected service determines whether you select Clients, Business Units, Client Branches or registrations.

+ Back
- {% if error_message %} -
- {{ error_message }} -
- {% endif %} - + {% if error_message %}
{{ error_message }}
{% endif %} {% if subscriptions_created or subscriptions_reused or engagements_created or engagements_skipped %}
-
-
Subscriptions Created
-
{{ subscriptions_created }}
-
-
-
Existing Reused
-
{{ subscriptions_reused }}
-
-
-
Engagements Created
-
{{ engagements_created }}
-
-
-
Duplicates Skipped
-
{{ engagements_skipped }}
-
-
- {% endif %} + {% for label,value in [('Subscriptions Created',subscriptions_created),('Existing Reused',subscriptions_reused),('Engagements Created',engagements_created),('Duplicates Skipped',engagements_skipped)] %} +
{{ label }}
{{ value }}
+ {% endfor %} +
{% endif %}
-
-
- - -
- -
- -
+
+ -

- Due-date rules, recurrence, assurance type, and task workflow are taken from the selected service setup. -

-
- -
- - -

- Required only when generating one monthly or quarterly engagement. -

-
- -
- - -

- The subscription branch is derived from the selected Partner and active branch scope. -

-
- -
- - -

- Defaults to the Engagement Partner when left blank. -

-
- -
- - -
- -
- - -
- -
- - -

- Optional for non-assurance services. The selected Review Partner becomes the subscription default. -

-
- -
- - -
- -
- - -
- -
- - +

Select a service to load the applicable subscription scope.

+
+
+
+
+
+
+
+
+
-

Engagement Generation

+

Engagement Generation

- - - - - + {% for value,title,help in [ + ('subscription_only','Subscriptions only','Create or reuse the service master without generating engagements.'), + ('current_period','One engagement','Generate one selected period for each chosen scope.'), + ('all_periods','All FY engagements','Generate all monthly or quarterly periods, or one annual engagement.') + ] %} + + {% endfor %}
- - +
-
+
-
- - -
- -
-
- 0 clients selected +
+
+
+
0 items selected
- -
- - - - - - - - - - - - - {% for client in clients %} - {% set search_text = ((client.client_code or '') ~ ' ' ~ (client.client_name or '') ~ ' ' ~ (client.trade_name or '') ~ ' ' ~ (client.pan or '') ~ ' ' ~ (client.gstin or '') ~ ' ' ~ (client.client_type or ''))|lower %} - - - - - - - - - {% else %} - - - - {% endfor %} - +
Select an enabled firm service to load Clients, Business Units, Client Branches or registrations.
+
- - CodeClientPAN / GSTINTypeCurrent Partner
- - {{ client.client_code }} -
{{ client.client_name }}
- {% if client.trade_name %} -
{{ client.trade_name }}
- {% endif %} -
-
PAN: {{ client.pan or '-' }}
-
GSTIN: {{ client.gstin or '-' }}
-
{{ client.client_type or '-' }} - {{ client_partner_names.get(client.partner_id, '-') if client.partner_id else '-' }} -
- No clients are available for subscription. -
+ +
-
-

- Existing client-service subscriptions are reused. Existing engagements for the same client, service, financial year and period are skipped automatically. -

-
- - Cancel - - -
+
+ Cancel +
{% endblock %} diff --git a/app/modules/services/ui.py b/app/modules/services/ui.py index b12a2fe..f76b94a 100644 --- a/app/modules/services/ui.py +++ b/app/modules/services/ui.py @@ -484,7 +484,7 @@ def catalogue_create_page(request: Request): @router.post('/catalogue/new') -def catalogue_create_submit(request: Request, service_code: str = Form(...), service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: str | None = Form(None), csrf_token: str = Form(...)): +def catalogue_create_submit(request: Request, service_code: str = Form(...), service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), service_scope_type: str = Form('client'), required_registration_type: str = Form(''), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: str | None = Form(None), csrf_token: str = Form(...)): validate_csrf(request, csrf_token) db = CommonSessionLocal() try: @@ -502,6 +502,8 @@ def catalogue_create_submit(request: Request, service_code: str = Form(...), ser category=selected_category.name if selected_category else None, recurrence_type=recurrence_type.strip() or None, engagement_type=normalize_engagement_type(engagement_type), + service_scope_type=(service_scope_type or 'client').strip().lower(), + required_registration_type=(required_registration_type or '').strip().upper() or None, sort_order=sort_order, description=description.strip() or None, applicable_individual=applicable_individual is not None, @@ -812,7 +814,7 @@ def catalogue_edit_page(request: Request, catalogue_id: int): @router.post('/catalogue/{catalogue_id}/edit') -def catalogue_edit_submit(request: Request, catalogue_id: int, service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: str | None = Form(None), csrf_token: str = Form(...)): +def catalogue_edit_submit(request: Request, catalogue_id: int, service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), service_scope_type: str = Form('client'), required_registration_type: str = Form(''), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: str | None = Form(None), csrf_token: str = Form(...)): validate_csrf(request, csrf_token) db = CommonSessionLocal() try: @@ -831,6 +833,8 @@ def catalogue_edit_submit(request: Request, catalogue_id: int, service_name: str row.category = selected_category.name if selected_category else None row.recurrence_type = recurrence_type.strip() or None row.engagement_type = normalize_engagement_type(engagement_type) + row.service_scope_type = (service_scope_type or 'client').strip().lower() + row.required_registration_type = (required_registration_type or '').strip().upper() or None row.sort_order = sort_order row.applicable_individual = applicable_individual is not None row.applicable_proprietorship = applicable_proprietorship is not None diff --git a/app/ui/app.py b/app/ui/app.py index a7c36ba..569bba9 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -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.clients.scope_ui import router as client_scope_ui_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 @@ -69,6 +70,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_scope_ui_router) app.include_router(client_groups_ui_router) app.include_router(registrations_ui_router) app.include_router(credential_vault_ui_router)