Add scope-aware subscriptions using existing registrations
This commit is contained in:
@@ -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")
|
||||||
@@ -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)
|
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)
|
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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "ui/templates/base/layout.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div><h2 class="text-xl font-semibold text-slate-900">{{ client.client_name }} — Business Structure</h2>
|
||||||
|
<p class="text-sm text-slate-500">Maintain Business Units, Client Branches and statutory registrations under one PAN/legal client.</p></div>
|
||||||
|
<a href="/clients/{{ client.id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm">Back to Client</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_edit %}
|
||||||
|
<div class="grid gap-5 xl:grid-cols-3">
|
||||||
|
<form method="post" action="/clients/{{ client.id }}/business-units" class="rounded-2xl bg-white p-5 shadow-soft space-y-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<h3 class="font-semibold text-slate-900">Add Business Unit</h3>
|
||||||
|
<input name="business_code" required placeholder="Business code" class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<input name="business_name" required placeholder="Business unit name" class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<input name="trade_name" placeholder="Trade name" class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<input name="nature_of_business" placeholder="Nature of business" class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<label class="flex gap-2 text-sm"><input type="checkbox" name="is_primary"> Primary Business Unit</label>
|
||||||
|
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm text-white">Add Business Unit</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/clients/{{ client.id }}/branches" class="rounded-2xl bg-white p-5 shadow-soft space-y-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<h3 class="font-semibold text-slate-900">Add Client Branch</h3>
|
||||||
|
<select name="business_unit_id" required class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<option value="">Select Business Unit</option>
|
||||||
|
{% for row in businesses if row.is_active %}<option value="{{ row.id }}">{{ row.business_name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input name="branch_code" required placeholder="Branch code" class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<input name="branch_name" required placeholder="Client Branch name" class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<select name="branch_type" class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<option value="head_office">Head Office</option><option value="branch">Branch</option>
|
||||||
|
<option value="warehouse">Warehouse</option><option value="unit">Unit</option>
|
||||||
|
</select>
|
||||||
|
<div class="grid grid-cols-3 gap-2"><input name="city" placeholder="City" class="rounded-xl border px-3 py-2 text-sm"><input name="state" placeholder="State" class="rounded-xl border px-3 py-2 text-sm"><input name="pincode" placeholder="Pincode" class="rounded-xl border px-3 py-2 text-sm"></div>
|
||||||
|
<label class="flex gap-2 text-sm"><input type="checkbox" name="is_primary"> Primary Branch</label>
|
||||||
|
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm text-white">Add Client Branch</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/clients/{{ client.id }}/registrations" class="rounded-2xl bg-white p-5 shadow-soft space-y-3">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<h3 class="font-semibold text-slate-900">Add Registration</h3>
|
||||||
|
<select name="business_unit_id" class="w-full rounded-xl border px-3 py-2 text-sm"><option value="">Client level / derive from Branch</option>{% for row in businesses if row.is_active %}<option value="{{ row.id }}">{{ row.business_name }}</option>{% endfor %}</select>
|
||||||
|
<select name="client_branch_id" class="w-full rounded-xl border px-3 py-2 text-sm"><option value="">No Client Branch</option>{% for row in branches if row.is_active %}<option value="{{ row.id }}">{{ row.branch_name }}</option>{% endfor %}</select>
|
||||||
|
<select name="registration_type_id" required class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<option value="">Select Registration Type</option>
|
||||||
|
{% for row in registration_types %}<option value="{{ row.id }}">{{ row.name }} ({{ row.code }})</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input name="registration_number" required placeholder="Registration number" class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<input name="legal_name" placeholder="Legal name" class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<input name="trade_name" placeholder="Trade / deductor unit name" class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<input name="state" placeholder="State" class="w-full rounded-xl border px-3 py-2 text-sm">
|
||||||
|
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm text-white">Add Registration</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white shadow-soft overflow-hidden">
|
||||||
|
<div class="border-b p-4 font-semibold">Business Units</div>
|
||||||
|
<table class="min-w-full text-sm"><thead class="bg-slate-50"><tr><th class="p-3 text-left">Code</th><th class="p-3 text-left">Business Unit</th><th class="p-3 text-left">Trade Name</th><th class="p-3 text-left">Nature</th><th class="p-3">Status</th><th></th></tr></thead>
|
||||||
|
<tbody>{% for row in businesses %}<tr class="border-t"><td class="p-3">{{ row.business_code }}</td><td class="p-3 font-medium">{{ row.business_name }}{% if row.is_primary %} <span class="text-xs text-brand-700">Primary</span>{% endif %}</td><td class="p-3">{{ row.trade_name or '-' }}</td><td class="p-3">{{ row.nature_of_business or '-' }}</td><td class="p-3 text-center">{{ 'Active' if row.is_active else 'Inactive' }}</td><td class="p-3">{% if can_edit %}<form method="post" action="/clients/{{ client.id }}/business-structure/business/{{ row.id }}/toggle"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="text-xs text-brand-700">{{ 'Deactivate' if row.is_active else 'Activate' }}</button></form>{% endif %}</td></tr>{% else %}<tr><td colspan="6" class="p-6 text-center text-slate-500">No Business Units added.</td></tr>{% endfor %}</tbody></table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white shadow-soft overflow-hidden">
|
||||||
|
<div class="border-b p-4 font-semibold">Client Branches</div>
|
||||||
|
<table class="min-w-full text-sm"><thead class="bg-slate-50"><tr><th class="p-3 text-left">Code</th><th class="p-3 text-left">Client Branch</th><th class="p-3 text-left">Type</th><th class="p-3 text-left">Location</th><th class="p-3">Status</th><th></th></tr></thead>
|
||||||
|
<tbody>{% for row in branches %}<tr class="border-t"><td class="p-3">{{ row.branch_code }}</td><td class="p-3 font-medium">{{ row.branch_name }}{% if row.is_primary %} <span class="text-xs text-brand-700">Primary</span>{% endif %}</td><td class="p-3">{{ row.branch_type|replace('_',' ')|title }}</td><td class="p-3">{{ row.city or '' }}{% if row.city and row.state %}, {% endif %}{{ row.state or '-' }}</td><td class="p-3 text-center">{{ 'Active' if row.is_active else 'Inactive' }}</td><td class="p-3">{% if can_edit %}<form method="post" action="/clients/{{ client.id }}/business-structure/branch/{{ row.id }}/toggle"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="text-xs text-brand-700">{{ 'Deactivate' if row.is_active else 'Activate' }}</button></form>{% endif %}</td></tr>{% else %}<tr><td colspan="6" class="p-6 text-center text-slate-500">No Client Branches added.</td></tr>{% endfor %}</tbody></table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl bg-white shadow-soft overflow-hidden">
|
||||||
|
<div class="border-b p-4 font-semibold">Registrations</div>
|
||||||
|
<table class="min-w-full text-sm"><thead class="bg-slate-50"><tr><th class="p-3 text-left">Type</th><th class="p-3 text-left">Number</th><th class="p-3 text-left">Trade / Unit Name</th><th class="p-3 text-left">State</th><th class="p-3">Status</th><th></th></tr></thead>
|
||||||
|
<tbody>{% for row in registrations %}<tr class="border-t"><td class="p-3">{{ registration_type_codes.get(row.id, "-") }}</td><td class="p-3 font-medium">{{ row.registration_number }}</td><td class="p-3">{{ row.trade_name or row.legal_name or '-' }}</td><td class="p-3">{{ row.state or '-' }}</td><td class="p-3 text-center">{{ row.status|replace('_',' ')|title }}</td><td class="p-3">{% if can_edit %}<form method="post" action="/clients/{{ client.id }}/business-structure/registration/{{ row.id }}/toggle"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="text-xs text-brand-700">{{ 'Deactivate' if row.status in ['active','valid','registered'] else 'Activate' }}</button></form>{% endif %}</td></tr>{% else %}<tr><td colspan="6" class="p-6 text-center text-slate-500">No registrations added.</td></tr>{% endfor %}</tbody></table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
|
<a href="/clients/{{ row.id }}/business-structure" class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">Business Structure</a>
|
||||||
<a href="/client-identity/clients/{{ row.id }}" class="rounded-xl border border-indigo-300 px-4 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50">Portal Identity</a>
|
<a href="/client-identity/clients/{{ row.id }}" class="rounded-xl border border-indigo-300 px-4 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50">Portal Identity</a>
|
||||||
<a href="/clients/{{ row.id }}/edit"
|
<a href="/clients/{{ row.id }}/edit"
|
||||||
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class ClientRelatedPerson(CommonBase):
|
|||||||
id: Mapped[int]=mapped_column(Integer, primary_key=True)
|
id: Mapped[int]=mapped_column(Integer, primary_key=True)
|
||||||
tenant_id: Mapped[int]=mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=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)
|
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)
|
person_type: Mapped[str]=mapped_column(String(60), nullable=False, index=True)
|
||||||
full_name: Mapped[str]=mapped_column(String(200), nullable=False)
|
full_name: Mapped[str]=mapped_column(String(200), nullable=False)
|
||||||
designation: Mapped[str|None]=mapped_column(String(120))
|
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)
|
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)
|
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)
|
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))
|
jurisdiction: Mapped[str|None]=mapped_column(String(160))
|
||||||
state_code: Mapped[str|None]=mapped_column(String(10))
|
state_code: Mapped[str|None]=mapped_column(String(10))
|
||||||
issue_date: Mapped[date|None]=mapped_column(Date)
|
issue_date: Mapped[date|None]=mapped_column(Date)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class ServiceCatalogue(CommonBase):
|
|||||||
category_id: Mapped[int | None] = mapped_column(ForeignKey("service_categories.id"), nullable=True, index=True)
|
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)
|
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)
|
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)
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
@@ -377,8 +379,8 @@ class ClientServicePlan(CommonBase):
|
|||||||
__tablename__ = "client_service_plans"
|
__tablename__ = "client_service_plans"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint(
|
UniqueConstraint(
|
||||||
"tenant_id", "client_id", "service_catalogue_id",
|
"tenant_id", "service_catalogue_id", "scope_key",
|
||||||
name="uq_csp_tenant_client_service",
|
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)
|
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)
|
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)
|
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)
|
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)
|
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__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint(
|
UniqueConstraint(
|
||||||
"tenant_id",
|
"tenant_id",
|
||||||
"client_id",
|
|
||||||
"service_catalogue_id",
|
"service_catalogue_id",
|
||||||
|
"scope_key",
|
||||||
"financial_year",
|
"financial_year",
|
||||||
"period_label",
|
"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)
|
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)
|
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)
|
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)
|
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)
|
firm_service_selection_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_selections.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Request
|
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 import func, or_, select
|
||||||
from sqlalchemy.orm import selectinload
|
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.clients.models import Client
|
||||||
from app.modules.services.due_dates import apply_due_date_rule_to_subscription
|
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 (
|
from app.modules.services.models import (
|
||||||
ClientServicePlan,
|
ClientServicePlan,
|
||||||
ClientServiceSubscription,
|
ClientServiceSubscription,
|
||||||
@@ -240,27 +241,13 @@ def subscription_bulk_page(
|
|||||||
|
|
||||||
tenant_id = _tenant_id(request, user)
|
tenant_id = _tenant_id(request, user)
|
||||||
branch_id = _branch_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)
|
enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id)
|
||||||
partners = list_assignable_users(
|
partners = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Partner",))
|
||||||
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",))
|
||||||
managers = list_assignable_users(
|
review_partners = list_review_partners(db, tenant_id=tenant_id, branch_id=branch_id)
|
||||||
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 = {
|
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.",
|
"service": "Select a valid enabled firm service.",
|
||||||
"partner": "Select a valid Engagement Partner.",
|
"partner": "Select a valid Engagement Partner.",
|
||||||
"performing_partner": "Select a valid Performing Partner.",
|
"performing_partner": "Select a valid Performing Partner.",
|
||||||
@@ -276,20 +263,9 @@ def subscription_bulk_page(
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"modules/services/templates/services/subscriptions/bulk.html",
|
"modules/services/templates/services/subscriptions/bulk.html",
|
||||||
_ctx(
|
_ctx(
|
||||||
request,
|
request, db, user, title="Bulk Client Subscriptions",
|
||||||
db,
|
enabled_services=enabled_services, partners=partners, managers=managers,
|
||||||
user,
|
staff_users=staff_users, review_partners=review_partners,
|
||||||
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
|
|
||||||
},
|
|
||||||
financial_year=_active_financial_year(request),
|
financial_year=_active_financial_year(request),
|
||||||
error_message=error_messages.get(error, ""),
|
error_message=error_messages.get(error, ""),
|
||||||
subscriptions_created=subscriptions_created,
|
subscriptions_created=subscriptions_created,
|
||||||
@@ -302,10 +278,59 @@ def subscription_bulk_page(
|
|||||||
db.close()
|
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")
|
@router.post("/bulk")
|
||||||
def subscription_bulk_submit(
|
def subscription_bulk_submit(
|
||||||
request: Request,
|
request: Request,
|
||||||
client_ids: list[int] = Form([]),
|
scope_targets: list[str] = Form([]),
|
||||||
service_catalogue_id: int = Form(...),
|
service_catalogue_id: int = Form(...),
|
||||||
default_partner_user_id: int = Form(...),
|
default_partner_user_id: int = Form(...),
|
||||||
default_performing_partner_user_id: str = Form(""),
|
default_performing_partner_user_id: str = Form(""),
|
||||||
@@ -323,10 +348,7 @@ def subscription_bulk_submit(
|
|||||||
):
|
):
|
||||||
validate_csrf(request, csrf_token)
|
validate_csrf(request, csrf_token)
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
subscriptions_created = 0
|
subscriptions_created = subscriptions_reused = engagements_created = engagements_skipped = 0
|
||||||
subscriptions_reused = 0
|
|
||||||
engagements_created = 0
|
|
||||||
engagements_skipped = 0
|
|
||||||
try:
|
try:
|
||||||
user = get_current_user(request, db=db)
|
user = get_current_user(request, db=db)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -338,70 +360,41 @@ def subscription_bulk_submit(
|
|||||||
|
|
||||||
tenant_id = _tenant_id(request, user)
|
tenant_id = _tenant_id(request, user)
|
||||||
branch_id = _branch_id(request, user)
|
branch_id = _branch_id(request, user)
|
||||||
permitted_clients = list_clients_for_assignment(
|
firm_selection = get_enabled_firm_service(db, tenant_id=tenant_id, service_catalogue_id=service_catalogue_id)
|
||||||
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,
|
|
||||||
)
|
|
||||||
if not firm_selection:
|
if not firm_selection:
|
||||||
return RedirectResponse("/services/subscriptions/bulk?error=service", 303)
|
return RedirectResponse("/services/subscriptions/bulk?error=service", 303)
|
||||||
|
|
||||||
plan_branch_id = (
|
permitted_clients = list_clients_for_assignment(
|
||||||
branch_id
|
db, tenant_id=tenant_id, branch_id=branch_id, partner_id=_partner_scope_id(db, user)
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
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}
|
plan_branch_id = branch_id or getattr(firm_selection, "default_branch_id", None) or getattr(user, "branch_id", None)
|
||||||
manager_ids = {row.id for row in managers}
|
partners = list_assignable_users(db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Partner",))
|
||||||
staff_ids = {row.id for row in staff_users}
|
managers = list_assignable_users(db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Branch Manager",))
|
||||||
review_partner_ids = {row.id for row in review_partners}
|
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)
|
return RedirectResponse("/services/subscriptions/bulk?error=partner", 303)
|
||||||
performing_partner_id = _optional_int(default_performing_partner_user_id) or default_partner_user_id
|
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)
|
return RedirectResponse("/services/subscriptions/bulk?error=performing_partner", 303)
|
||||||
manager_id = _optional_int(default_manager_user_id)
|
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)
|
return RedirectResponse("/services/subscriptions/bulk?error=manager", 303)
|
||||||
staff_id = _optional_int(default_staff_user_id)
|
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)
|
return RedirectResponse("/services/subscriptions/bulk?error=staff", 303)
|
||||||
review_partner_id = _optional_int(default_review_partner_user_id)
|
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)
|
return RedirectResponse("/services/subscriptions/bulk?error=review_partner", 303)
|
||||||
|
|
||||||
engagement_type = (
|
engagement_type = getattr(firm_selection.catalogue, "engagement_type", None) or "non_assurance"
|
||||||
getattr(firm_selection.catalogue, "engagement_type", None)
|
review_required = review_partner_required_for_engagement(db, tenant_id=tenant_id, engagement_type=engagement_type)
|
||||||
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:
|
if review_required and review_partner_id is None:
|
||||||
return RedirectResponse("/services/subscriptions/bulk?error=review_partner_required", 303)
|
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}:
|
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"}:
|
if generation_mode not in {"subscription_only", "current_period", "all_periods"}:
|
||||||
return RedirectResponse("/services/subscriptions/bulk?error=generation", 303)
|
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":
|
if generation_mode != "subscription_only":
|
||||||
locked_response = redirect_if_financial_year_locked(
|
locked = redirect_if_financial_year_locked(
|
||||||
db,
|
db, tenant_id=tenant_id, year_code=selected_financial_year,
|
||||||
tenant_id=tenant_id,
|
|
||||||
year_code=selected_financial_year,
|
|
||||||
redirect_url="/services/subscriptions/bulk?error=financial_year",
|
redirect_url="/services/subscriptions/bulk?error=financial_year",
|
||||||
)
|
)
|
||||||
if locked_response:
|
if locked:
|
||||||
return locked_response
|
return locked
|
||||||
|
|
||||||
recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None)
|
recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None)
|
||||||
if generation_mode == "subscription_only":
|
if generation_mode == "subscription_only":
|
||||||
requested_periods: list[str] = []
|
requested_periods = []
|
||||||
elif recurrence_requires_period(recurrence_type):
|
elif recurrence_requires_period(recurrence_type):
|
||||||
if generation_mode == "all_periods":
|
if generation_mode == "all_periods":
|
||||||
requested_periods = [
|
requested_periods = [code for code, _ in period_choices_for_service(selected_financial_year, recurrence_type)]
|
||||||
code for code, _label in period_choices_for_service(
|
|
||||||
selected_financial_year, recurrence_type
|
|
||||||
)
|
|
||||||
]
|
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
requested_periods = [
|
requested_periods = [normalize_period_label(
|
||||||
normalize_period_label(
|
period_label, financial_year=selected_financial_year, recurrence_type=recurrence_type
|
||||||
period_label,
|
)]
|
||||||
financial_year=selected_financial_year,
|
|
||||||
recurrence_type=recurrence_type,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return RedirectResponse("/services/subscriptions/bulk?error=period", 303)
|
return RedirectResponse("/services/subscriptions/bulk?error=period", 303)
|
||||||
else:
|
else:
|
||||||
@@ -452,54 +432,51 @@ def subscription_bulk_submit(
|
|||||||
if plan_effective_from and plan_effective_to and plan_effective_to < plan_effective_from:
|
if plan_effective_from and plan_effective_to and plan_effective_to < plan_effective_from:
|
||||||
return RedirectResponse("/services/subscriptions/bulk?error=period", 303)
|
return RedirectResponse("/services/subscriptions/bulk?error=period", 303)
|
||||||
|
|
||||||
for client_id in selected_client_ids:
|
for token in selected_tokens:
|
||||||
client = permitted_client_map[client_id]
|
try:
|
||||||
existing_plan = db.execute(
|
client, scope_type, scope_key, business_unit_id, client_branch_id, registration_id = resolve_scope_target(
|
||||||
select(ClientServicePlan).where(
|
db, tenant_id=tenant_id, clients_by_id=clients_by_id, token=token, actor_user_id=user.id
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
db.rollback()
|
||||||
|
return RedirectResponse("/services/subscriptions/bulk?error=targets", 303)
|
||||||
|
|
||||||
|
plan = db.execute(select(ClientServicePlan).where(
|
||||||
ClientServicePlan.tenant_id == tenant_id,
|
ClientServicePlan.tenant_id == tenant_id,
|
||||||
ClientServicePlan.client_id == client.id,
|
|
||||||
ClientServicePlan.service_catalogue_id == service_catalogue_id,
|
ClientServicePlan.service_catalogue_id == service_catalogue_id,
|
||||||
)
|
ClientServicePlan.scope_key == scope_key,
|
||||||
).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
plan = get_or_create_client_service_plan(
|
if 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()
|
|
||||||
|
|
||||||
if existing_plan is None:
|
|
||||||
subscriptions_created += 1
|
|
||||||
else:
|
|
||||||
subscriptions_reused += 1
|
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:
|
for requested_period in requested_periods:
|
||||||
existing = get_existing_subscription(
|
existing = db.execute(select(ClientServiceSubscription).where(
|
||||||
db,
|
ClientServiceSubscription.tenant_id == tenant_id,
|
||||||
tenant_id=tenant_id,
|
ClientServiceSubscription.service_catalogue_id == service_catalogue_id,
|
||||||
client_id=client.id,
|
ClientServiceSubscription.scope_key == scope_key,
|
||||||
service_catalogue_id=service_catalogue_id,
|
ClientServiceSubscription.financial_year == selected_financial_year,
|
||||||
financial_year=selected_financial_year,
|
ClientServiceSubscription.period_label == requested_period,
|
||||||
period_label=requested_period,
|
)).scalar_one_or_none()
|
||||||
)
|
|
||||||
if existing:
|
if existing:
|
||||||
if existing.service_plan_id is None:
|
if existing.service_plan_id is None:
|
||||||
existing.service_plan_id = plan.id
|
existing.service_plan_id = plan.id
|
||||||
@@ -507,57 +484,37 @@ def subscription_bulk_submit(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
engagement = ClientServiceSubscription(
|
engagement = ClientServiceSubscription(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id, branch_id=plan.branch_id, service_plan_id=plan.id,
|
||||||
branch_id=plan.branch_id or getattr(client, "branch_id", None),
|
client_id=client.id, scope_type=scope_type, scope_key=scope_key,
|
||||||
service_plan_id=plan.id,
|
business_unit_id=business_unit_id, client_branch_id=client_branch_id,
|
||||||
client_id=client.id,
|
registration_id=registration_id, service_catalogue_id=service_catalogue_id,
|
||||||
service_catalogue_id=service_catalogue_id,
|
|
||||||
firm_service_selection_id=firm_selection.id,
|
firm_service_selection_id=firm_selection.id,
|
||||||
assigned_partner_user_id=default_partner_user_id,
|
assigned_partner_user_id=default_partner_user_id,
|
||||||
performing_partner_user_id=performing_partner_id,
|
performing_partner_user_id=performing_partner_id,
|
||||||
assigned_manager_user_id=manager_id,
|
assigned_manager_user_id=manager_id, assigned_staff_user_id=staff_id,
|
||||||
assigned_staff_user_id=staff_id,
|
|
||||||
review_partner_user_id=review_partner_id if review_required else None,
|
review_partner_user_id=review_partner_id if review_required else None,
|
||||||
financial_year=selected_financial_year,
|
financial_year=selected_financial_year, period_label=requested_period,
|
||||||
period_label=requested_period,
|
|
||||||
assessment_year=assessment_year_from_financial_year(selected_financial_year),
|
assessment_year=assessment_year_from_financial_year(selected_financial_year),
|
||||||
engagement_type=engagement_type,
|
engagement_type=engagement_type, start_date=plan_effective_from,
|
||||||
start_date=plan_effective_from,
|
end_date=plan_effective_to, status="active", remarks=remarks.strip() or None,
|
||||||
end_date=plan_effective_to,
|
is_active=True, created_by_user_id=user.id, updated_by_user_id=user.id,
|
||||||
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.add(engagement); db.flush()
|
||||||
db.flush()
|
|
||||||
attach_engagement_to_plan(
|
attach_engagement_to_plan(
|
||||||
db,
|
db, engagement=engagement, client=client, catalogue=firm_selection.catalogue,
|
||||||
engagement=engagement,
|
firm_selection=firm_selection, actor_user_id=user.id,
|
||||||
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)
|
apply_due_date_rule_to_subscription(db, engagement, force=True)
|
||||||
ensure_engagement_quality_workflow(
|
ensure_engagement_quality_workflow(db, subscription=engagement, actor_user_id=user.id, create_declarations=False)
|
||||||
db,
|
|
||||||
subscription=engagement,
|
|
||||||
actor_user_id=user.id,
|
|
||||||
create_declarations=False,
|
|
||||||
)
|
|
||||||
enforce_quality_gate_on_subscription(engagement)
|
enforce_quality_gate_on_subscription(engagement)
|
||||||
engagements_created += 1
|
engagements_created += 1
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
"/services/subscriptions/bulk"
|
"/services/subscriptions/bulk"
|
||||||
f"?subscriptions_created={subscriptions_created}"
|
f"?subscriptions_created={subscriptions_created}&subscriptions_reused={subscriptions_reused}"
|
||||||
f"&subscriptions_reused={subscriptions_reused}"
|
f"&engagements_created={engagements_created}&engagements_skipped={engagements_skipped}",
|
||||||
f"&engagements_created={engagements_created}"
|
303,
|
||||||
f"&engagements_skipped={engagements_skipped}",
|
|
||||||
status_code=303,
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|||||||
@@ -9,6 +9,22 @@
|
|||||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Category</label><select name="category_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Select category</option>{% for cat in categories %}<option value="{{ cat.id }}" {% if catalogue and catalogue.category_id == cat.id %}selected{% endif %}>{{ cat.name }}</option>{% endfor %}</select></div>
|
<div><label class="mb-2 block text-sm font-medium text-slate-700">Category</label><select name="category_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Select category</option>{% for cat in categories %}<option value="{{ cat.id }}" {% if catalogue and catalogue.category_id == cat.id %}selected{% endif %}>{{ cat.name }}</option>{% endfor %}</select></div>
|
||||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Recurrence Type</label><select name="recurrence_type" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Select recurrence</option>{% for value, label in recurrence_choices %}<option value="{{ value }}" {% if catalogue and catalogue.recurrence_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
|
<div><label class="mb-2 block text-sm font-medium text-slate-700">Recurrence Type</label><select name="recurrence_type" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Select recurrence</option>{% for value, label in recurrence_choices %}<option value="{{ value }}" {% if catalogue and catalogue.recurrence_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
|
||||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Engagement Type</label><select name="engagement_type" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{% for value, label in engagement_type_choices %}<option value="{{ value }}" {% if catalogue and catalogue.engagement_type == value %}selected{% elif not catalogue and value == "non_assurance" %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><p class="mt-1 text-xs text-slate-500">This value will be copied to client engagements when the service is assigned.</p></div>
|
<div><label class="mb-2 block text-sm font-medium text-slate-700">Engagement Type</label><select name="engagement_type" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{% for value, label in engagement_type_choices %}<option value="{{ value }}" {% if catalogue and catalogue.engagement_type == value %}selected{% elif not catalogue and value == "non_assurance" %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><p class="mt-1 text-xs text-slate-500">This value will be copied to client engagements when the service is assigned.</p></div>
|
||||||
|
<div><label class="mb-2 block text-sm font-medium text-slate-700">Service Scope</label>
|
||||||
|
<select name="service_scope_type" id="service-scope-type" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||||
|
{% for value, label in [('client','Client / PAN level'),('business_unit','Business Unit level'),('client_branch','Client Branch level'),('registration','Registration level')] %}
|
||||||
|
<option value="{{ value }}" {% if catalogue and catalogue.service_scope_type == value %}selected{% elif not catalogue and value == 'client' %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="registration-type-field"><label class="mb-2 block text-sm font-medium text-slate-700">Required Registration Type</label>
|
||||||
|
<select name="required_registration_type" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||||
|
<option value="">Not applicable</option>
|
||||||
|
{% for value in ['GST','TAN','PF','ESI','PT','IEC','FSSAI','UDYAM','OTHER'] %}
|
||||||
|
<option value="{{ value }}" {% if catalogue and catalogue.required_registration_type == value %}selected{% endif %}>{{ value }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Used only for Registration-level services.</p>
|
||||||
|
</div>
|
||||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Sort Order</label><input type="number" name="sort_order" value="{{ catalogue.sort_order if catalogue else 100 }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
<div><label class="mb-2 block text-sm font-medium text-slate-700">Sort Order</label><input type="number" name="sort_order" value="{{ catalogue.sort_order if catalogue else 100 }}" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
|
||||||
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not catalogue or catalogue.is_active %}checked{% endif %}> Active</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_client_requestable" {% if catalogue and catalogue.is_client_requestable %}checked{% endif %}> Client Requestable</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_consultant_requestable" {% if catalogue and catalogue.is_consultant_requestable %}checked{% endif %}> Consultant Requestable</label></div>
|
<div class="flex items-center gap-6 pt-7 text-sm text-slate-700"><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_active" {% if not catalogue or catalogue.is_active %}checked{% endif %}> Active</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_client_requestable" {% if catalogue and catalogue.is_client_requestable %}checked{% endif %}> Client Requestable</label><label class="inline-flex items-center gap-2"><input type="checkbox" name="is_consultant_requestable" {% if catalogue and catalogue.is_consultant_requestable %}checked{% endif %}> Consultant Requestable</label></div>
|
||||||
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Description</label><textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ catalogue.description if catalogue else '' }}</textarea></div>
|
<div class="md:col-span-2"><label class="mb-2 block text-sm font-medium text-slate-700">Description</label><textarea name="description" rows="4" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ catalogue.description if catalogue else '' }}</textarea></div>
|
||||||
@@ -27,4 +43,12 @@
|
|||||||
<div class="md:col-span-2 flex items-center justify-end gap-3"><a href="/services/catalogue" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save</button></div>
|
<div class="md:col-span-2 flex items-center justify-end gap-3"><a href="/services/catalogue" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const scope=document.getElementById('service-scope-type');
|
||||||
|
const field=document.getElementById('registration-type-field');
|
||||||
|
function refresh(){ field.classList.toggle('hidden', scope.value !== 'registration'); }
|
||||||
|
scope.addEventListener('change', refresh); refresh();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,493 +2,161 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{% set _uiux_partner_role_text = (current_user_roles or [])|join('|')|lower %}
|
{% set _uiux_partner_role_text = (current_user_roles or [])|join('|')|lower %}
|
||||||
{% if 'partner' in _uiux_partner_role_text %}
|
{% if 'partner' in _uiux_partner_role_text %}{% include "ui/templates/components/partner_navigation_v2.html" %}{% endif %}
|
||||||
{% include "ui/templates/components/partner_navigation_v2.html" %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div><h2 class="text-xl font-semibold text-slate-900">Bulk Subscription Setup</h2>
|
||||||
<h2 class="text-xl font-semibold text-slate-900">Bulk Subscription Setup</h2>
|
<p class="text-sm text-slate-500">The selected service determines whether you select Clients, Business Units, Client Branches or registrations.</p></div>
|
||||||
<p class="text-sm text-slate-500">
|
<a href="/services/subscriptions" class="rounded-xl border border-slate-300 px-4 py-2 text-sm">Back</a>
|
||||||
Choose one enabled firm service, create the subscription for multiple clients, and optionally generate period-wise engagements.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<a href="/services/subscriptions"
|
|
||||||
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
|
||||||
Back
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if error_message %}
|
{% if error_message %}<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{{ error_message }}</div>{% endif %}
|
||||||
<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm font-medium text-rose-700">
|
|
||||||
{{ error_message }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if subscriptions_created or subscriptions_reused or engagements_created or engagements_skipped %}
|
{% if subscriptions_created or subscriptions_reused or engagements_created or engagements_skipped %}
|
||||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
{% for label,value in [('Subscriptions Created',subscriptions_created),('Existing Reused',subscriptions_reused),('Engagements Created',engagements_created),('Duplicates Skipped',engagements_skipped)] %}
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Subscriptions Created</div>
|
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">{{ label }}</div><div class="mt-1 text-2xl font-semibold">{{ value }}</div></div>
|
||||||
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ subscriptions_created }}</div>
|
{% endfor %}
|
||||||
</div>
|
</div>{% endif %}
|
||||||
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Existing Reused</div>
|
|
||||||
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ subscriptions_reused }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Engagements Created</div>
|
|
||||||
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ engagements_created }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-2xl bg-white p-4 shadow-soft">
|
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Duplicates Skipped</div>
|
|
||||||
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ engagements_skipped }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="/services/subscriptions/bulk" id="bulk-subscription-form" class="space-y-5">
|
<form method="post" action="/services/subscriptions/bulk" id="bulk-subscription-form" class="space-y-5">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
<div class="grid gap-4 rounded-2xl bg-white p-5 shadow-soft md:grid-cols-2 xl:grid-cols-3">
|
<div class="grid gap-4 rounded-2xl bg-white p-5 shadow-soft md:grid-cols-2 xl:grid-cols-3">
|
||||||
<div>
|
<div><label class="mb-2 block text-sm font-medium">Financial Year</label><input name="financial_year" id="bulk-financial-year" value="{{ financial_year }}" required class="w-full rounded-xl border px-4 py-2 text-sm"></div>
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Financial Year</label>
|
<div class="xl:col-span-2"><label class="mb-2 block text-sm font-medium">Enabled Firm Service</label>
|
||||||
<input type="text"
|
<select name="service_catalogue_id" id="bulk-service" required class="w-full rounded-xl border px-4 py-2 text-sm">
|
||||||
name="financial_year"
|
|
||||||
id="bulk-financial-year"
|
|
||||||
value="{{ financial_year or '2025-26' }}"
|
|
||||||
required
|
|
||||||
placeholder="2025-26"
|
|
||||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-1 xl:col-span-2">
|
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Enabled Firm Service</label>
|
|
||||||
<select name="service_catalogue_id"
|
|
||||||
id="bulk-service"
|
|
||||||
required
|
|
||||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
|
||||||
<option value="">Select service</option>
|
<option value="">Select service</option>
|
||||||
{% for selection in enabled_services %}
|
{% for selection in enabled_services %}
|
||||||
<option value="{{ selection.catalogue.id }}"
|
<option value="{{ selection.catalogue.id }}" data-type="{{ selection.catalogue.engagement_type or 'non_assurance' }}" data-recurrence="{{ selection.catalogue.recurrence_type or '' }}" data-scope="{{ selection.catalogue.service_scope_type or 'client' }}" data-registration="{{ selection.catalogue.required_registration_type or '' }}">
|
||||||
data-type="{{ selection.catalogue.engagement_type or 'non_assurance' }}"
|
|
||||||
data-recurrence="{{ selection.catalogue.recurrence_type or '' }}">
|
|
||||||
{{ selection.catalogue.service_name }} ({{ selection.catalogue.service_code }})
|
{{ selection.catalogue.service_name }} ({{ selection.catalogue.service_code }})
|
||||||
</option>
|
</option>{% endfor %}
|
||||||
{% endfor %}
|
|
||||||
</select>
|
</select>
|
||||||
<p id="service-derived-info" class="mt-1 text-xs text-slate-500">
|
<p id="service-derived-info" class="mt-1 text-xs text-slate-500">Select a service to load the applicable subscription scope.</p>
|
||||||
Due-date rules, recurrence, assurance type, and task workflow are taken from the selected service setup.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Return / Engagement Period</label>
|
|
||||||
<select name="period_label"
|
|
||||||
id="bulk-period"
|
|
||||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
|
||||||
<option value="">Not applicable</option>
|
|
||||||
</select>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
|
||||||
Required only when generating one monthly or quarterly engagement.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Engagement Partner</label>
|
|
||||||
<select name="default_partner_user_id"
|
|
||||||
id="bulk-partner"
|
|
||||||
required
|
|
||||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
|
||||||
<option value="">Select partner</option>
|
|
||||||
{% for u in partners %}
|
|
||||||
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
|
||||||
The subscription branch is derived from the selected Partner and active branch scope.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Performing Partner</label>
|
|
||||||
<select name="default_performing_partner_user_id"
|
|
||||||
id="bulk-performing-partner"
|
|
||||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
|
||||||
<option value="">Same as Engagement Partner</option>
|
|
||||||
{% for u in partners %}
|
|
||||||
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
|
||||||
Defaults to the Engagement Partner when left blank.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Default Manager</label>
|
|
||||||
<select name="default_manager_user_id"
|
|
||||||
id="bulk-manager"
|
|
||||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
|
||||||
<option value="">Not assigned</option>
|
|
||||||
{% for u in managers %}
|
|
||||||
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Default Staff</label>
|
|
||||||
<select name="default_staff_user_id"
|
|
||||||
id="bulk-staff"
|
|
||||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
|
||||||
<option value="">Not assigned</option>
|
|
||||||
{% for u in staff_users %}
|
|
||||||
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="review-partner-field">
|
|
||||||
<label class="mb-2 flex items-center gap-2 text-sm font-medium text-slate-700">
|
|
||||||
<span>Review Partner</span>
|
|
||||||
<span id="review-partner-requirement"
|
|
||||||
class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600">
|
|
||||||
Optional
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<select name="default_review_partner_user_id"
|
|
||||||
id="bulk-review-partner"
|
|
||||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
|
||||||
<option value="">Not assigned</option>
|
|
||||||
{% for u in review_partners %}
|
|
||||||
<option value="{{ u.id }}" data-branch="{{ u.branch_id }}">{{ u.full_name or u.email }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<p id="review-partner-help" class="mt-1 text-xs text-slate-500">
|
|
||||||
Optional for non-assurance services. The selected Review Partner becomes the subscription default.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Effective From</label>
|
|
||||||
<input type="date"
|
|
||||||
name="effective_from"
|
|
||||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Effective To</label>
|
|
||||||
<input type="date"
|
|
||||||
name="effective_to"
|
|
||||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-2 xl:col-span-3">
|
|
||||||
<label class="mb-2 block text-sm font-medium text-slate-700">Remarks applied to all subscriptions and generated engagements</label>
|
|
||||||
<textarea name="remarks"
|
|
||||||
rows="2"
|
|
||||||
class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div><label class="mb-2 block text-sm font-medium">Return / Engagement Period</label><select name="period_label" id="bulk-period" class="w-full rounded-xl border px-4 py-2 text-sm"><option value="">Not applicable</option></select></div>
|
||||||
|
<div><label class="mb-2 block text-sm font-medium">Engagement Partner</label><select name="default_partner_user_id" required class="w-full rounded-xl border px-4 py-2 text-sm"><option value="">Select partner</option>{% for u in partners %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||||
|
<div><label class="mb-2 block text-sm font-medium">Performing Partner</label><select name="default_performing_partner_user_id" class="w-full rounded-xl border px-4 py-2 text-sm"><option value="">Same as Engagement Partner</option>{% for u in partners %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||||
|
<div><label class="mb-2 block text-sm font-medium">Default Manager</label><select name="default_manager_user_id" class="w-full rounded-xl border px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in managers %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||||
|
<div><label class="mb-2 block text-sm font-medium">Default Staff</label><select name="default_staff_user_id" class="w-full rounded-xl border px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in staff_users %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||||
|
<div><label class="mb-2 block text-sm font-medium">Review Partner</label><select name="default_review_partner_user_id" id="bulk-review-partner" class="w-full rounded-xl border px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in review_partners %}<option value="{{ u.id }}">{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||||
|
<div><label class="mb-2 block text-sm font-medium">Effective From</label><input type="date" name="effective_from" class="w-full rounded-xl border px-4 py-2 text-sm"></div>
|
||||||
|
<div><label class="mb-2 block text-sm font-medium">Effective To</label><input type="date" name="effective_to" class="w-full rounded-xl border px-4 py-2 text-sm"></div>
|
||||||
|
<div class="md:col-span-2 xl:col-span-3"><label class="mb-2 block text-sm font-medium">Remarks applied to all subscriptions and generated engagements</label><textarea name="remarks" rows="2" class="w-full rounded-xl border px-4 py-2 text-sm"></textarea></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl bg-white p-5 shadow-soft">
|
<div class="rounded-2xl bg-white p-5 shadow-soft">
|
||||||
<h3 class="text-base font-semibold text-slate-900">Engagement Generation</h3>
|
<h3 class="font-semibold">Engagement Generation</h3>
|
||||||
<div class="mt-4 grid gap-3 lg:grid-cols-3">
|
<div class="mt-4 grid gap-3 lg:grid-cols-3">
|
||||||
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-4 hover:bg-slate-50">
|
{% for value,title,help in [
|
||||||
<input type="radio"
|
('subscription_only','Subscriptions only','Create or reuse the service master without generating engagements.'),
|
||||||
name="generation_mode"
|
('current_period','One engagement','Generate one selected period for each chosen scope.'),
|
||||||
value="subscription_only"
|
('all_periods','All FY engagements','Generate all monthly or quarterly periods, or one annual engagement.')
|
||||||
checked
|
] %}
|
||||||
class="mt-1 border-slate-300">
|
<label class="flex items-start gap-3 rounded-xl border p-4"><input type="radio" name="generation_mode" value="{{ value }}" {% if value=='subscription_only' %}checked{% endif %} class="mt-1"><span><span class="block text-sm font-medium">{{ title }}</span><span class="text-xs text-slate-500">{{ help }}</span></span></label>
|
||||||
<span>
|
{% endfor %}
|
||||||
<span class="block text-sm font-medium text-slate-900">Subscriptions only</span>
|
|
||||||
<span class="block text-xs text-slate-500">Create or reuse the client-service master without generating engagement instances.</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-4 hover:bg-slate-50">
|
|
||||||
<input type="radio"
|
|
||||||
name="generation_mode"
|
|
||||||
value="current_period"
|
|
||||||
class="mt-1 border-slate-300">
|
|
||||||
<span>
|
|
||||||
<span class="block text-sm font-medium text-slate-900">One engagement</span>
|
|
||||||
<span class="block text-xs text-slate-500">Generate one selected month, quarter, annual, or one-time engagement for each client.</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-4 hover:bg-slate-50">
|
|
||||||
<input type="radio"
|
|
||||||
name="generation_mode"
|
|
||||||
value="all_periods"
|
|
||||||
class="mt-1 border-slate-300">
|
|
||||||
<span>
|
|
||||||
<span class="block text-sm font-medium text-slate-900">All FY engagements</span>
|
|
||||||
<span class="block text-xs text-slate-500">Generate 12 monthly, 4 quarterly, or one annual/one-time engagement for each client.</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
<label class="mt-4 inline-flex gap-2 text-sm"><input type="checkbox" name="auto_generate_periods" value="1"> Keep automatic period generation enabled</label>
|
||||||
<label class="mt-4 inline-flex items-center gap-2 text-sm text-slate-700">
|
|
||||||
<input type="checkbox"
|
|
||||||
name="auto_generate_periods"
|
|
||||||
value="1"
|
|
||||||
class="rounded border-slate-300">
|
|
||||||
Keep automatic period generation enabled on the subscription
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl bg-white shadow-soft">
|
<div class="rounded-2xl bg-white shadow-soft">
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3 border-b border-slate-200 p-4">
|
<div class="flex flex-wrap items-end justify-between gap-3 border-b p-4">
|
||||||
<div class="flex flex-wrap items-end gap-3">
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
<div>
|
<div><label id="scope-search-label" class="mb-1 block text-xs font-semibold uppercase text-slate-500">Select subscription scope</label>
|
||||||
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Search clients</label>
|
<input type="search" id="scope-search" disabled placeholder="Select an enabled firm service first" class="w-96 max-w-full rounded-xl border px-3 py-2 text-sm"></div>
|
||||||
<input type="search"
|
<button type="button" id="clear-search" class="rounded-xl border px-4 py-2 text-sm">Clear</button>
|
||||||
id="client-search"
|
|
||||||
placeholder="Code, name, PAN, GSTIN or type"
|
|
||||||
class="w-80 max-w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<div class="text-sm"><span id="selected-count" class="font-semibold">0</span> <span id="selected-noun">items</span> selected</div>
|
||||||
id="clear-client-search"
|
|
||||||
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-slate-600">
|
<div id="scope-message" class="p-8 text-center text-sm text-slate-500">Select an enabled firm service to load Clients, Business Units, Client Branches or registrations.</div>
|
||||||
<span id="selected-client-count" class="font-semibold text-slate-900">0</span> clients selected
|
<div id="scope-table-wrap" class="hidden max-h-[32rem] overflow-auto">
|
||||||
</div>
|
<table class="min-w-full divide-y">
|
||||||
</div>
|
<thead class="sticky top-0 bg-slate-50"><tr id="scope-table-head"></tr></thead>
|
||||||
|
<tbody id="scope-table-body" class="divide-y"></tbody>
|
||||||
<div class="max-h-[32rem] overflow-auto">
|
|
||||||
<table class="min-w-full divide-y divide-slate-200">
|
|
||||||
<thead class="sticky top-0 z-10 bg-slate-50">
|
|
||||||
<tr>
|
|
||||||
<th class="w-12 px-4 py-3 text-left">
|
|
||||||
<input type="checkbox"
|
|
||||||
id="select-all-visible"
|
|
||||||
class="rounded border-slate-300"
|
|
||||||
title="Select all visible clients">
|
|
||||||
</th>
|
|
||||||
<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">PAN / GSTIN</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Type</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Current Partner</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="client-table-body" class="divide-y divide-slate-100">
|
|
||||||
{% 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 %}
|
|
||||||
<tr class="client-row" data-search="{{ search_text|e }}">
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<input type="checkbox"
|
|
||||||
name="client_ids"
|
|
||||||
value="{{ client.id }}"
|
|
||||||
class="client-checkbox rounded border-slate-300">
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm font-medium text-slate-700">{{ client.client_code }}</td>
|
|
||||||
<td class="px-4 py-3 text-sm">
|
|
||||||
<div class="font-medium text-slate-900">{{ client.client_name }}</div>
|
|
||||||
{% if client.trade_name %}
|
|
||||||
<div class="text-xs text-slate-500">{{ client.trade_name }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-xs text-slate-600">
|
|
||||||
<div>PAN: {{ client.pan or '-' }}</div>
|
|
||||||
<div>GSTIN: {{ client.gstin or '-' }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-slate-600">{{ client.client_type or '-' }}</td>
|
|
||||||
<td class="px-4 py-3 text-xs text-slate-600">
|
|
||||||
{{ client_partner_names.get(client.partner_id, '-') if client.partner_id else '-' }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-4 py-8 text-center text-sm text-slate-500">
|
|
||||||
No clients are available for subscription.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
<div class="flex justify-end gap-3 rounded-2xl bg-white p-4 shadow-soft">
|
||||||
<p class="text-sm text-slate-600">
|
<a href="/services/subscriptions" class="rounded-xl border px-4 py-2 text-sm">Cancel</a>
|
||||||
Existing client-service subscriptions are reused. Existing engagements for the same client, service, financial year and period are skipped automatically.
|
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm text-white">Create Subscriptions</button>
|
||||||
</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<a href="/services/subscriptions"
|
|
||||||
class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
|
||||||
Cancel
|
|
||||||
</a>
|
|
||||||
<button type="submit"
|
|
||||||
id="create-subscriptions-button"
|
|
||||||
class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
|
||||||
Create Subscriptions
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
const form = document.getElementById('bulk-subscription-form');
|
const form=document.getElementById('bulk-subscription-form'), service=document.getElementById('bulk-service'),
|
||||||
const service = document.getElementById('bulk-service');
|
search=document.getElementById('scope-search'), clear=document.getElementById('clear-search'),
|
||||||
const info = document.getElementById('service-derived-info');
|
head=document.getElementById('scope-table-head'), body=document.getElementById('scope-table-body'),
|
||||||
const reviewPartner = document.getElementById('bulk-review-partner');
|
wrap=document.getElementById('scope-table-wrap'), message=document.getElementById('scope-message'),
|
||||||
const reviewRequirement = document.getElementById('review-partner-requirement');
|
count=document.getElementById('selected-count'), noun=document.getElementById('selected-noun'),
|
||||||
const reviewHelp = document.getElementById('review-partner-help');
|
label=document.getElementById('scope-search-label'), info=document.getElementById('service-derived-info'),
|
||||||
const financialYear = document.getElementById('bulk-financial-year');
|
fy=document.getElementById('bulk-financial-year'), period=document.getElementById('bulk-period'),
|
||||||
const period = document.getElementById('bulk-period');
|
review=document.getElementById('bulk-review-partner');
|
||||||
const generationRadios = Array.from(document.querySelectorAll('input[name="generation_mode"]'));
|
let targets=[], scopeType='client', registrationType='';
|
||||||
const search = document.getElementById('client-search');
|
|
||||||
const clear = document.getElementById('clear-client-search');
|
|
||||||
const selectAll = document.getElementById('select-all-visible');
|
|
||||||
const count = document.getElementById('selected-client-count');
|
|
||||||
const rows = Array.from(document.querySelectorAll('.client-row'));
|
|
||||||
const boxes = Array.from(document.querySelectorAll('.client-checkbox'));
|
|
||||||
|
|
||||||
function currentGenerationMode() {
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
const selected = generationRadios.find(radio => radio.checked);
|
const meta={
|
||||||
return selected ? selected.value : 'subscription_only';
|
client:{title:'Search Clients',noun:'clients',placeholder:'Code, client name, PAN or entity type',columns:[['client_code','Code'],['client_name','Client'],['pan','PAN'],['entity_type','Type']]},
|
||||||
}
|
business_unit:{title:'Search Business Units',noun:'business units',placeholder:'Client, Business Unit, trade name, PAN or nature',columns:[['client_name','Client'],['pan','PAN'],['business_unit','Business Unit'],['trade_name','Trade Name']]},
|
||||||
|
client_branch:{title:'Search Client Branches',noun:'client branches',placeholder:'Client, Business Unit, Client Branch, code or state',columns:[['client_name','Client'],['business_unit','Business Unit'],['client_branch','Client Branch'],['state','State']]},
|
||||||
|
registration:{title:'Search Registrations',noun:'registrations',placeholder:'Client, business, branch, registration number or state',columns:[['client_name','Client'],['business_unit','Business Unit'],['client_branch','Client Branch'],['trade_name','Trade / Unit Name'],['registration_number','Registration'],['state','State']]}
|
||||||
|
};
|
||||||
|
|
||||||
function financialYearParts() {
|
function checked(){return Array.from(body.querySelectorAll('.target-box:checked'));}
|
||||||
const raw = (financialYear.value || '').trim();
|
function updateCount(){count.textContent=checked().length;}
|
||||||
const start = parseInt(raw.split('-')[0], 10);
|
function render(){
|
||||||
return Number.isFinite(start)
|
const m=meta[scopeType]||meta.client;
|
||||||
? [start, start + 1]
|
label.textContent=registrationType?`Search ${registrationType} Registrations`:m.title;
|
||||||
: [new Date().getFullYear(), new Date().getFullYear() + 1];
|
noun.textContent=registrationType?`${registrationType} registrations`:m.noun;
|
||||||
}
|
search.placeholder=m.placeholder;
|
||||||
|
head.innerHTML=`<th class="w-12 px-4 py-3"><input type="checkbox" id="select-all"></th>`+
|
||||||
function rebuildPeriodOptions() {
|
m.columns.map(c=>`<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">${esc(c[1])}</th>`).join('')+
|
||||||
const option = service.options[service.selectedIndex];
|
`<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Subscription Status</th>`;
|
||||||
const recurrence = option ? (option.dataset.recurrence || '').trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_') : '';
|
const term=search.value.trim().toLowerCase();
|
||||||
const mode = currentGenerationMode();
|
const visible=targets.filter(t=>!term||Object.values(t).join(' ').toLowerCase().includes(term));
|
||||||
const requiresSelectedPeriod = mode === 'current_period' && (recurrence === 'monthly' || recurrence === 'quarterly');
|
body.innerHTML=visible.map(t=>`<tr>
|
||||||
|
<td class="px-4 py-3"><input class="target-box" type="checkbox" name="scope_targets" value="${esc(t.token)}"></td>
|
||||||
period.innerHTML = '<option value="">Not applicable</option>';
|
${m.columns.map(c=>`<td class="px-4 py-3 text-sm ${c[0]==='client_name'||c[0]==='business_unit'||c[0]==='client_branch'||c[0]==='registration_number'?'font-medium':''}">${esc(t[c[0]]||'-')}</td>`).join('')}
|
||||||
period.required = requiresSelectedPeriod;
|
<td class="px-4 py-3 text-xs"><span class="rounded-full px-2 py-1 ${t.subscription_status==='not_subscribed'?'bg-slate-100 text-slate-600':'bg-emerald-100 text-emerald-700'}">${esc(t.subscription_status.replaceAll('_',' '))}</span></td>
|
||||||
period.disabled = mode !== 'current_period';
|
</tr>`).join('');
|
||||||
|
document.querySelectorAll('.target-box').forEach(x=>x.addEventListener('change',updateCount));
|
||||||
if (!requiresSelectedPeriod) {
|
const all=document.getElementById('select-all');
|
||||||
return;
|
if(all) all.addEventListener('change',()=>{document.querySelectorAll('.target-box').forEach(x=>x.checked=all.checked);updateCount();});
|
||||||
}
|
|
||||||
|
|
||||||
const [startYear, endYear] = financialYearParts();
|
|
||||||
|
|
||||||
if (recurrence === 'monthly') {
|
|
||||||
const months = [
|
|
||||||
[4, 'Apr'], [5, 'May'], [6, 'Jun'], [7, 'Jul'],
|
|
||||||
[8, 'Aug'], [9, 'Sep'], [10, 'Oct'], [11, 'Nov'],
|
|
||||||
[12, 'Dec'], [1, 'Jan'], [2, 'Feb'], [3, 'Mar']
|
|
||||||
];
|
|
||||||
months.forEach(([month, label]) => {
|
|
||||||
const year = month >= 4 ? startYear : endYear;
|
|
||||||
const value = `${year}-${String(month).padStart(2, '0')}`;
|
|
||||||
period.add(new Option(`${label} ${year}`, value));
|
|
||||||
});
|
|
||||||
} else if (recurrence === 'quarterly') {
|
|
||||||
['Q1', 'Q2', 'Q3', 'Q4'].forEach(quarter => {
|
|
||||||
period.add(new Option(`${quarter} ${financialYear.value}`, quarter));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateServiceInfo() {
|
|
||||||
const option = service.options[service.selectedIndex];
|
|
||||||
const type = option ? (option.dataset.type || '') : '';
|
|
||||||
const recurrence = option ? (option.dataset.recurrence || '') : '';
|
|
||||||
const normalizedType = type.trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
|
|
||||||
const assurance = normalizedType === 'assurance';
|
|
||||||
|
|
||||||
reviewPartner.required = assurance;
|
|
||||||
reviewRequirement.textContent = assurance ? 'Mandatory' : 'Optional';
|
|
||||||
reviewRequirement.className = assurance
|
|
||||||
? 'rounded-full bg-rose-100 px-2 py-0.5 text-xs font-semibold text-rose-700'
|
|
||||||
: 'rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600';
|
|
||||||
reviewHelp.textContent = assurance
|
|
||||||
? 'Mandatory because the selected enabled firm service is an assurance engagement.'
|
|
||||||
: 'Optional for non-assurance services. The selected Review Partner becomes the subscription default.';
|
|
||||||
|
|
||||||
info.textContent = option && option.value
|
|
||||||
? `Type: ${assurance ? 'Assurance' : 'Non-Assurance'}${recurrence ? ' · Recurrence: ' + recurrence.replaceAll('_', ' ') : ''} · Due-date rule and workflow are taken from the service setup.`
|
|
||||||
: 'Due-date rules, recurrence, assurance type, and task workflow are taken from the selected service setup.';
|
|
||||||
|
|
||||||
rebuildPeriodOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateVisibleRows() {
|
|
||||||
const term = (search.value || '').trim().toLowerCase();
|
|
||||||
rows.forEach(row => {
|
|
||||||
row.classList.toggle('hidden', term && !row.dataset.search.includes(term));
|
|
||||||
});
|
|
||||||
updateSelectAllState();
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibleBoxes() {
|
|
||||||
return rows
|
|
||||||
.filter(row => !row.classList.contains('hidden'))
|
|
||||||
.map(row => row.querySelector('.client-checkbox'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCount() {
|
|
||||||
count.textContent = String(boxes.filter(box => box.checked).length);
|
|
||||||
updateSelectAllState();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelectAllState() {
|
|
||||||
const visible = visibleBoxes();
|
|
||||||
const checked = visible.filter(box => box.checked).length;
|
|
||||||
selectAll.checked = visible.length > 0 && checked === visible.length;
|
|
||||||
selectAll.indeterminate = checked > 0 && checked < visible.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
service.addEventListener('change', updateServiceInfo);
|
|
||||||
financialYear.addEventListener('change', rebuildPeriodOptions);
|
|
||||||
generationRadios.forEach(radio => radio.addEventListener('change', rebuildPeriodOptions));
|
|
||||||
search.addEventListener('input', updateVisibleRows);
|
|
||||||
clear.addEventListener('click', () => {
|
|
||||||
search.value = '';
|
|
||||||
updateVisibleRows();
|
|
||||||
search.focus();
|
|
||||||
});
|
|
||||||
selectAll.addEventListener('change', () => {
|
|
||||||
visibleBoxes().forEach(box => {
|
|
||||||
box.checked = selectAll.checked;
|
|
||||||
});
|
|
||||||
updateCount();
|
updateCount();
|
||||||
});
|
|
||||||
boxes.forEach(box => box.addEventListener('change', updateCount));
|
|
||||||
|
|
||||||
form.addEventListener('submit', event => {
|
|
||||||
const selected = boxes.filter(box => box.checked).length;
|
|
||||||
if (!selected) {
|
|
||||||
event.preventDefault();
|
|
||||||
alert('Select at least one client.');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = currentGenerationMode();
|
async function loadTargets(){
|
||||||
let message = `Create or reuse subscriptions for ${selected} selected client${selected === 1 ? '' : 's'}`;
|
const id=service.value;
|
||||||
if (mode === 'current_period') {
|
targets=[]; body.innerHTML=''; count.textContent='0';
|
||||||
message += ' and generate one engagement for each?';
|
if(!id){wrap.classList.add('hidden');message.classList.remove('hidden');search.disabled=true;return;}
|
||||||
} else if (mode === 'all_periods') {
|
search.disabled=true; message.textContent='Loading applicable subscription scope...';message.classList.remove('hidden');wrap.classList.add('hidden');
|
||||||
message += ' and generate all applicable FY engagements?';
|
const response=await fetch(`/services/subscriptions/bulk/targets?service_catalogue_id=${encodeURIComponent(id)}`,{headers:{'Accept':'application/json'}});
|
||||||
} else {
|
const data=await response.json();
|
||||||
message += '?';
|
if(!response.ok){message.textContent=data.error||'Unable to load subscription scope.';return;}
|
||||||
|
scopeType=data.scope_type||'client';registrationType=data.registration_type||'';targets=data.targets||[];
|
||||||
|
search.disabled=false;message.classList.toggle('hidden',targets.length>0);wrap.classList.toggle('hidden',targets.length===0);
|
||||||
|
if(!targets.length)message.textContent='No permitted scope records are available. Add the required Business Unit, Client Branch or Registration in the Client Business Structure page.';
|
||||||
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(message)) {
|
function updateService(){
|
||||||
event.preventDefault();
|
const o=service.options[service.selectedIndex], type=o?.dataset.type||'', recurrence=o?.dataset.recurrence||'', scope=o?.dataset.scope||'client', reg=o?.dataset.registration||'';
|
||||||
|
review.required=type==='assurance';
|
||||||
|
info.textContent=o&&o.value?`Scope: ${scope.replaceAll('_',' ')}${reg?' — '+reg:''} · Type: ${type.replaceAll('_',' ')} · Recurrence: ${recurrence.replaceAll('_',' ')||'one time'}`:'Select a service to load the applicable subscription scope.';
|
||||||
|
rebuildPeriods(recurrence);
|
||||||
|
loadTargets();
|
||||||
}
|
}
|
||||||
});
|
function rebuildPeriods(recurrence){
|
||||||
|
const mode=document.querySelector('input[name="generation_mode"]:checked').value;
|
||||||
updateServiceInfo();
|
period.innerHTML='<option value="">Not applicable</option>';period.disabled=mode!=='current_period';period.required=false;
|
||||||
updateCount();
|
const start=parseInt((fy.value||'').split('-')[0]);
|
||||||
|
if(mode!=='current_period'||!start)return;
|
||||||
|
if(recurrence==='monthly'){[['04','Apr',start],['05','May',start],['06','Jun',start],['07','Jul',start],['08','Aug',start],['09','Sep',start],['10','Oct',start],['11','Nov',start],['12','Dec',start],['01','Jan',start+1],['02','Feb',start+1],['03','Mar',start+1]].forEach(r=>period.add(new Option(`${r[1]} ${r[2]}`,`${r[2]}-${r[0]}`)));period.required=true;}
|
||||||
|
else if(recurrence==='quarterly'){['Q1','Q2','Q3','Q4'].forEach(q=>period.add(new Option(`${q} ${fy.value}`,q)));period.required=true;}
|
||||||
|
}
|
||||||
|
service.addEventListener('change',updateService);search.addEventListener('input',render);clear.addEventListener('click',()=>{search.value='';render();search.focus();});
|
||||||
|
fy.addEventListener('input',updateService);document.querySelectorAll('input[name="generation_mode"]').forEach(x=>x.addEventListener('change',updateService));
|
||||||
|
form.addEventListener('submit',e=>{if(!checked().length){e.preventDefault();alert('Select at least one applicable subscription scope.');}});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ def catalogue_create_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.post('/catalogue/new')
|
@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)
|
validate_csrf(request, csrf_token)
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
try:
|
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,
|
category=selected_category.name if selected_category else None,
|
||||||
recurrence_type=recurrence_type.strip() or None,
|
recurrence_type=recurrence_type.strip() or None,
|
||||||
engagement_type=normalize_engagement_type(engagement_type),
|
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,
|
sort_order=sort_order,
|
||||||
description=description.strip() or None,
|
description=description.strip() or None,
|
||||||
applicable_individual=applicable_individual is not 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')
|
@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)
|
validate_csrf(request, csrf_token)
|
||||||
db = CommonSessionLocal()
|
db = CommonSessionLocal()
|
||||||
try:
|
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.category = selected_category.name if selected_category else None
|
||||||
row.recurrence_type = recurrence_type.strip() or None
|
row.recurrence_type = recurrence_type.strip() or None
|
||||||
row.engagement_type = normalize_engagement_type(engagement_type)
|
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.sort_order = sort_order
|
||||||
row.applicable_individual = applicable_individual is not None
|
row.applicable_individual = applicable_individual is not None
|
||||||
row.applicable_proprietorship = applicable_proprietorship is not None
|
row.applicable_proprietorship = applicable_proprietorship is not None
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router
|
from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router
|
||||||
|
from app.modules.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.client_groups.ui import router as client_groups_ui_router
|
||||||
from app.modules.consultants.ui import router as consultants_ui_router, portal_router as consultant_portal_router
|
from app.modules.consultants.ui import router as consultants_ui_router, portal_router as consultant_portal_router
|
||||||
from app.modules.employees.ui import router as employees_ui_router, portal_router as employee_portal_router
|
from app.modules.employees.ui import router as employees_ui_router, portal_router as employee_portal_router
|
||||||
@@ -69,6 +70,7 @@ def mount_ui(app: FastAPI) -> None:
|
|||||||
app.include_router(system_admin_dashboard_router)
|
app.include_router(system_admin_dashboard_router)
|
||||||
app.include_router(work_detail_ui_router)
|
app.include_router(work_detail_ui_router)
|
||||||
app.include_router(clients_ui_router)
|
app.include_router(clients_ui_router)
|
||||||
|
app.include_router(client_scope_ui_router)
|
||||||
app.include_router(client_groups_ui_router)
|
app.include_router(client_groups_ui_router)
|
||||||
app.include_router(registrations_ui_router)
|
app.include_router(registrations_ui_router)
|
||||||
app.include_router(credential_vault_ui_router)
|
app.include_router(credential_vault_ui_router)
|
||||||
|
|||||||
Reference in New Issue
Block a user