Add scope-aware subscriptions using existing registrations

This commit is contained in:
A R R R Associates
2026-08-05 19:36:55 +05:30
parent 9b307ebcdd
commit 28277590cc
13 changed files with 1135 additions and 643 deletions
+47
View File
@@ -203,3 +203,50 @@ class ClientEngagementLetter(CommonBase):
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
class ClientBusinessUnit(CommonBase):
__tablename__ = "client_business_units"
__table_args__ = (
UniqueConstraint("tenant_id", "client_id", "business_code", name="uq_cbu_tenant_client_code"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
business_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
business_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
nature_of_business: Mapped[str | None] = mapped_column(String(200), nullable=True)
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
class ClientBranch(CommonBase):
__tablename__ = "client_branches"
__table_args__ = (
UniqueConstraint("tenant_id", "business_unit_id", "branch_code", name="uq_cbranch_tenant_business_code"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
business_unit_id: Mapped[int] = mapped_column(ForeignKey("client_business_units.id", ondelete="CASCADE"), nullable=False, index=True)
branch_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
branch_name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
branch_type: Mapped[str] = mapped_column(String(40), nullable=False, default="branch", index=True)
address_line_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
address_line_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
pincode: Mapped[str | None] = mapped_column(String(20), nullable=True)
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
+202
View File
@@ -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">
{% 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="/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">
+5
View File
@@ -29,6 +29,8 @@ class ClientRelatedPerson(CommonBase):
id: Mapped[int]=mapped_column(Integer, primary_key=True)
tenant_id: Mapped[int]=mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
client_id: Mapped[int]=mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
business_unit_id: Mapped[int|None]=mapped_column(ForeignKey("client_business_units.id", ondelete="SET NULL"), index=True)
client_branch_id: Mapped[int|None]=mapped_column(ForeignKey("client_branches.id", ondelete="SET NULL"), index=True)
person_type: Mapped[str]=mapped_column(String(60), nullable=False, index=True)
full_name: Mapped[str]=mapped_column(String(200), nullable=False)
designation: Mapped[str|None]=mapped_column(String(120))
@@ -56,6 +58,9 @@ class ClientRegistration(CommonBase):
registration_type_id: Mapped[int]=mapped_column(ForeignKey("registration_types.id"), nullable=False, index=True)
related_person_id: Mapped[int|None]=mapped_column(ForeignKey("client_related_persons.id", ondelete="SET NULL"), index=True)
registration_number: Mapped[str]=mapped_column(String(120), nullable=False, index=True)
legal_name: Mapped[str|None]=mapped_column(String(200))
trade_name: Mapped[str|None]=mapped_column(String(200))
state: Mapped[str|None]=mapped_column(String(100))
jurisdiction: Mapped[str|None]=mapped_column(String(160))
state_code: Mapped[str|None]=mapped_column(String(10))
issue_date: Mapped[date|None]=mapped_column(Date)
+16 -4
View File
@@ -37,6 +37,8 @@ class ServiceCatalogue(CommonBase):
category_id: Mapped[int | None] = mapped_column(ForeignKey("service_categories.id"), nullable=True, index=True)
recurrence_type: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
engagement_type: Mapped[str] = mapped_column(String(20), nullable=False, default="non_assurance", index=True)
service_scope_type: Mapped[str] = mapped_column(String(30), nullable=False, default="client", server_default="client", index=True)
required_registration_type: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -377,8 +379,8 @@ class ClientServicePlan(CommonBase):
__tablename__ = "client_service_plans"
__table_args__ = (
UniqueConstraint(
"tenant_id", "client_id", "service_catalogue_id",
name="uq_csp_tenant_client_service",
"tenant_id", "service_catalogue_id", "scope_key",
name="uq_csp_tenant_service_scope",
),
)
@@ -386,6 +388,11 @@ class ClientServicePlan(CommonBase):
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
scope_type: Mapped[str] = mapped_column(String(30), nullable=False, default="client", server_default="client", index=True)
scope_key: Mapped[str] = mapped_column(String(80), nullable=False, default="", server_default="", index=True)
business_unit_id: Mapped[int | None] = mapped_column(ForeignKey("client_business_units.id", ondelete="SET NULL"), nullable=True, index=True)
client_branch_id: Mapped[int | None] = mapped_column(ForeignKey("client_branches.id", ondelete="SET NULL"), nullable=True, index=True)
registration_id: Mapped[int | None] = mapped_column(ForeignKey("client_registrations.id", ondelete="SET NULL"), nullable=True, index=True)
service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True)
firm_service_selection_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_selections.id", ondelete="SET NULL"), nullable=True, index=True)
@@ -426,11 +433,11 @@ class ClientServiceSubscription(CommonBase):
__table_args__ = (
UniqueConstraint(
"tenant_id",
"client_id",
"service_catalogue_id",
"scope_key",
"financial_year",
"period_label",
name="uq_css_tenant_client_service_fy_period",
name="uq_css_tenant_service_scope_fy_period",
),
)
@@ -440,6 +447,11 @@ class ClientServiceSubscription(CommonBase):
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
service_plan_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_plans.id", ondelete="SET NULL"), nullable=True, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
scope_type: Mapped[str] = mapped_column(String(30), nullable=False, default="client", server_default="client", index=True)
scope_key: Mapped[str] = mapped_column(String(80), nullable=False, default="", server_default="", index=True)
business_unit_id: Mapped[int | None] = mapped_column(ForeignKey("client_business_units.id", ondelete="SET NULL"), nullable=True, index=True)
client_branch_id: Mapped[int | None] = mapped_column(ForeignKey("client_branches.id", ondelete="SET NULL"), nullable=True, index=True)
registration_id: Mapped[int | None] = mapped_column(ForeignKey("client_registrations.id", ondelete="SET NULL"), nullable=True, index=True)
service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True)
firm_service_selection_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_selections.id", ondelete="SET NULL"), nullable=True, index=True)
+354
View File
@@ -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.")
+145 -188
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import date, datetime, timezone
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload
@@ -36,6 +36,7 @@ from app.modules.services.client_services import (
)
from app.modules.clients.models import Client
from app.modules.services.due_dates import apply_due_date_rule_to_subscription
from app.modules.services.scope_targets import list_scope_targets, resolve_scope_target
from app.modules.services.models import (
ClientServicePlan,
ClientServiceSubscription,
@@ -240,27 +241,13 @@ def subscription_bulk_page(
tenant_id = _tenant_id(request, user)
branch_id = _branch_id(request, user)
clients = list_clients_for_assignment(
db,
tenant_id=tenant_id,
branch_id=branch_id,
partner_id=_partner_scope_id(db, user),
)
enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id)
partners = list_assignable_users(
db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Partner",)
)
managers = list_assignable_users(
db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Branch Manager",)
)
staff_users = list_assignable_users(
db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Staff",)
)
review_partners = list_review_partners(
db, tenant_id=tenant_id, branch_id=branch_id
)
partners = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Partner",))
managers = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Branch Manager",))
staff_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Staff",))
review_partners = list_review_partners(db, tenant_id=tenant_id, branch_id=branch_id)
error_messages = {
"clients": "Select at least one permitted client.",
"targets": "Select at least one permitted Client, Business Unit, Client Branch or Registration.",
"service": "Select a valid enabled firm service.",
"partner": "Select a valid Engagement Partner.",
"performing_partner": "Select a valid Performing Partner.",
@@ -276,20 +263,9 @@ def subscription_bulk_page(
return templates.TemplateResponse(
"modules/services/templates/services/subscriptions/bulk.html",
_ctx(
request,
db,
user,
title="Bulk Client Subscriptions",
clients=clients,
enabled_services=enabled_services,
partners=partners,
managers=managers,
staff_users=staff_users,
review_partners=review_partners,
client_partner_names={
row.id: (row.full_name or row.email)
for row in partners
},
request, db, user, title="Bulk Client Subscriptions",
enabled_services=enabled_services, partners=partners, managers=managers,
staff_users=staff_users, review_partners=review_partners,
financial_year=_active_financial_year(request),
error_message=error_messages.get(error, ""),
subscriptions_created=subscriptions_created,
@@ -302,10 +278,59 @@ def subscription_bulk_page(
db.close()
@router.get("/bulk/targets")
def subscription_bulk_targets(request: Request, service_catalogue_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return JSONResponse({"error": "Authentication required."}, status_code=401)
try:
require_permission(db, user, "clients.edit")
except Exception:
return JSONResponse({"error": "Access denied."}, status_code=403)
tenant_id = _tenant_id(request, user)
branch_id = _branch_id(request, user)
firm_selection = get_enabled_firm_service(db, tenant_id=tenant_id, service_catalogue_id=service_catalogue_id)
if not firm_selection:
return JSONResponse({"error": "Enabled firm service not found."}, status_code=404)
clients = list_clients_for_assignment(
db, tenant_id=tenant_id, branch_id=branch_id, partner_id=_partner_scope_id(db, user)
)
targets = list_scope_targets(db, tenant_id=tenant_id, clients=clients, catalogue=firm_selection.catalogue)
scope_type = getattr(firm_selection.catalogue, "service_scope_type", "client") or "client"
registration_type = getattr(firm_selection.catalogue, "required_registration_type", None)
return {
"scope_type": scope_type,
"registration_type": registration_type,
"targets": [
{
"token": row.token,
"client_code": row.client_code,
"client_name": row.client_name,
"pan": row.pan,
"business_unit": row.business_unit,
"client_branch": row.client_branch,
"registration_type": row.registration_type,
"registration_number": row.registration_number,
"trade_name": row.trade_name,
"state": row.state,
"entity_type": row.entity_type,
"subscription_status": row.existing_plan_status or "not_subscribed",
}
for row in targets
],
}
finally:
db.close()
@router.post("/bulk")
def subscription_bulk_submit(
request: Request,
client_ids: list[int] = Form([]),
scope_targets: list[str] = Form([]),
service_catalogue_id: int = Form(...),
default_partner_user_id: int = Form(...),
default_performing_partner_user_id: str = Form(""),
@@ -323,10 +348,7 @@ def subscription_bulk_submit(
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
subscriptions_created = 0
subscriptions_reused = 0
engagements_created = 0
engagements_skipped = 0
subscriptions_created = subscriptions_reused = engagements_created = engagements_skipped = 0
try:
user = get_current_user(request, db=db)
if not user:
@@ -338,70 +360,41 @@ def subscription_bulk_submit(
tenant_id = _tenant_id(request, user)
branch_id = _branch_id(request, user)
permitted_clients = list_clients_for_assignment(
db,
tenant_id=tenant_id,
branch_id=branch_id,
partner_id=_partner_scope_id(db, user),
)
permitted_client_map = {row.id: row for row in permitted_clients}
selected_client_ids = list(dict.fromkeys(int(value) for value in client_ids))
if not selected_client_ids or any(value not in permitted_client_map for value in selected_client_ids):
return RedirectResponse("/services/subscriptions/bulk?error=clients", 303)
firm_selection = get_enabled_firm_service(
db,
tenant_id=tenant_id,
service_catalogue_id=service_catalogue_id,
)
firm_selection = get_enabled_firm_service(db, tenant_id=tenant_id, service_catalogue_id=service_catalogue_id)
if not firm_selection:
return RedirectResponse("/services/subscriptions/bulk?error=service", 303)
plan_branch_id = (
branch_id
or getattr(firm_selection, "default_branch_id", None)
or getattr(user, "branch_id", None)
)
partners = list_assignable_users(
db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Partner",)
)
managers = list_assignable_users(
db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Branch Manager",)
)
staff_users = list_assignable_users(
db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Staff",)
)
review_partners = list_review_partners(
db, tenant_id=tenant_id, branch_id=plan_branch_id
permitted_clients = list_clients_for_assignment(
db, tenant_id=tenant_id, branch_id=branch_id, partner_id=_partner_scope_id(db, user)
)
clients_by_id = {int(row.id): row for row in permitted_clients}
selected_tokens = list(dict.fromkeys(value for value in scope_targets if value))
if not selected_tokens:
return RedirectResponse("/services/subscriptions/bulk?error=targets", 303)
partner_ids = {row.id for row in partners}
manager_ids = {row.id for row in managers}
staff_ids = {row.id for row in staff_users}
review_partner_ids = {row.id for row in review_partners}
plan_branch_id = branch_id or getattr(firm_selection, "default_branch_id", None) or getattr(user, "branch_id", None)
partners = list_assignable_users(db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Partner",))
managers = list_assignable_users(db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Branch Manager",))
staff_users = list_assignable_users(db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Staff",))
review_partners = list_review_partners(db, tenant_id=tenant_id, branch_id=plan_branch_id)
if default_partner_user_id not in partner_ids:
if default_partner_user_id not in {row.id for row in partners}:
return RedirectResponse("/services/subscriptions/bulk?error=partner", 303)
performing_partner_id = _optional_int(default_performing_partner_user_id) or default_partner_user_id
if performing_partner_id not in partner_ids:
if performing_partner_id not in {row.id for row in partners}:
return RedirectResponse("/services/subscriptions/bulk?error=performing_partner", 303)
manager_id = _optional_int(default_manager_user_id)
if manager_id is not None and manager_id not in manager_ids:
if manager_id is not None and manager_id not in {row.id for row in managers}:
return RedirectResponse("/services/subscriptions/bulk?error=manager", 303)
staff_id = _optional_int(default_staff_user_id)
if staff_id is not None and staff_id not in staff_ids:
if staff_id is not None and staff_id not in {row.id for row in staff_users}:
return RedirectResponse("/services/subscriptions/bulk?error=staff", 303)
review_partner_id = _optional_int(default_review_partner_user_id)
if review_partner_id is not None and review_partner_id not in review_partner_ids:
if review_partner_id is not None and review_partner_id not in {row.id for row in review_partners}:
return RedirectResponse("/services/subscriptions/bulk?error=review_partner", 303)
engagement_type = (
getattr(firm_selection.catalogue, "engagement_type", None)
or "non_assurance"
)
review_required = review_partner_required_for_engagement(
db, tenant_id=tenant_id, engagement_type=engagement_type
)
engagement_type = getattr(firm_selection.catalogue, "engagement_type", None) or "non_assurance"
review_required = review_partner_required_for_engagement(db, tenant_id=tenant_id, engagement_type=engagement_type)
if review_required and review_partner_id is None:
return RedirectResponse("/services/subscriptions/bulk?error=review_partner_required", 303)
if review_required and review_partner_id in {default_partner_user_id, performing_partner_id}:
@@ -409,39 +402,26 @@ def subscription_bulk_submit(
if generation_mode not in {"subscription_only", "current_period", "all_periods"}:
return RedirectResponse("/services/subscriptions/bulk?error=generation", 303)
selected_financial_year = normalize_financial_year(
financial_year or _active_financial_year(request)
)
selected_financial_year = normalize_financial_year(financial_year or _active_financial_year(request))
if generation_mode != "subscription_only":
locked_response = redirect_if_financial_year_locked(
db,
tenant_id=tenant_id,
year_code=selected_financial_year,
locked = redirect_if_financial_year_locked(
db, tenant_id=tenant_id, year_code=selected_financial_year,
redirect_url="/services/subscriptions/bulk?error=financial_year",
)
if locked_response:
return locked_response
if locked:
return locked
recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None)
if generation_mode == "subscription_only":
requested_periods: list[str] = []
requested_periods = []
elif recurrence_requires_period(recurrence_type):
if generation_mode == "all_periods":
requested_periods = [
code for code, _label in period_choices_for_service(
selected_financial_year, recurrence_type
)
]
requested_periods = [code for code, _ in period_choices_for_service(selected_financial_year, recurrence_type)]
else:
try:
requested_periods = [
normalize_period_label(
period_label,
financial_year=selected_financial_year,
recurrence_type=recurrence_type,
)
]
requested_periods = [normalize_period_label(
period_label, financial_year=selected_financial_year, recurrence_type=recurrence_type
)]
except ValueError:
return RedirectResponse("/services/subscriptions/bulk?error=period", 303)
else:
@@ -452,54 +432,51 @@ def subscription_bulk_submit(
if plan_effective_from and plan_effective_to and plan_effective_to < plan_effective_from:
return RedirectResponse("/services/subscriptions/bulk?error=period", 303)
for client_id in selected_client_ids:
client = permitted_client_map[client_id]
existing_plan = db.execute(
select(ClientServicePlan).where(
ClientServicePlan.tenant_id == tenant_id,
ClientServicePlan.client_id == client.id,
ClientServicePlan.service_catalogue_id == service_catalogue_id,
for token in selected_tokens:
try:
client, scope_type, scope_key, business_unit_id, client_branch_id, registration_id = resolve_scope_target(
db, tenant_id=tenant_id, clients_by_id=clients_by_id, token=token, actor_user_id=user.id
)
).scalar_one_or_none()
except ValueError:
db.rollback()
return RedirectResponse("/services/subscriptions/bulk?error=targets", 303)
plan = get_or_create_client_service_plan(
db,
tenant_id=tenant_id,
client=client,
catalogue=firm_selection.catalogue,
firm_selection=firm_selection,
branch_id=plan_branch_id or getattr(client, "branch_id", None),
partner_user_id=default_partner_user_id,
performing_partner_user_id=performing_partner_id,
manager_user_id=manager_id,
staff_user_id=staff_id,
review_partner_user_id=review_partner_id,
actor_user_id=user.id,
remarks=remarks.strip() or None,
)
plan.effective_from = plan_effective_from
plan.effective_to = plan_effective_to
plan.auto_generate_periods = auto_generate_periods is not None
plan.status = "active"
plan.is_active = True
plan.updated_by_user_id = user.id
if remarks.strip():
plan.remarks = remarks.strip()
plan = db.execute(select(ClientServicePlan).where(
ClientServicePlan.tenant_id == tenant_id,
ClientServicePlan.service_catalogue_id == service_catalogue_id,
ClientServicePlan.scope_key == scope_key,
)).scalar_one_or_none()
if existing_plan is None:
subscriptions_created += 1
else:
if plan:
subscriptions_reused += 1
else:
plan = ClientServicePlan(
tenant_id=tenant_id, branch_id=plan_branch_id or client.branch_id,
client_id=client.id, scope_type=scope_type, scope_key=scope_key,
business_unit_id=business_unit_id, client_branch_id=client_branch_id,
registration_id=registration_id, service_catalogue_id=service_catalogue_id,
firm_service_selection_id=firm_selection.id,
default_partner_user_id=default_partner_user_id,
default_performing_partner_user_id=performing_partner_id,
default_manager_user_id=manager_id, default_staff_user_id=staff_id,
default_review_partner_user_id=review_partner_id,
recurrence_type=(recurrence_type or "one_time"),
effective_from=plan_effective_from, effective_to=plan_effective_to,
auto_generate_periods=auto_generate_periods is not None,
status="active", is_active=True, remarks=remarks.strip() or None,
created_by_user_id=user.id, updated_by_user_id=user.id,
)
db.add(plan); db.flush()
subscriptions_created += 1
for requested_period in requested_periods:
existing = get_existing_subscription(
db,
tenant_id=tenant_id,
client_id=client.id,
service_catalogue_id=service_catalogue_id,
financial_year=selected_financial_year,
period_label=requested_period,
)
existing = db.execute(select(ClientServiceSubscription).where(
ClientServiceSubscription.tenant_id == tenant_id,
ClientServiceSubscription.service_catalogue_id == service_catalogue_id,
ClientServiceSubscription.scope_key == scope_key,
ClientServiceSubscription.financial_year == selected_financial_year,
ClientServiceSubscription.period_label == requested_period,
)).scalar_one_or_none()
if existing:
if existing.service_plan_id is None:
existing.service_plan_id = plan.id
@@ -507,57 +484,37 @@ def subscription_bulk_submit(
continue
engagement = ClientServiceSubscription(
tenant_id=tenant_id,
branch_id=plan.branch_id or getattr(client, "branch_id", None),
service_plan_id=plan.id,
client_id=client.id,
service_catalogue_id=service_catalogue_id,
tenant_id=tenant_id, branch_id=plan.branch_id, service_plan_id=plan.id,
client_id=client.id, scope_type=scope_type, scope_key=scope_key,
business_unit_id=business_unit_id, client_branch_id=client_branch_id,
registration_id=registration_id, service_catalogue_id=service_catalogue_id,
firm_service_selection_id=firm_selection.id,
assigned_partner_user_id=default_partner_user_id,
performing_partner_user_id=performing_partner_id,
assigned_manager_user_id=manager_id,
assigned_staff_user_id=staff_id,
assigned_manager_user_id=manager_id, assigned_staff_user_id=staff_id,
review_partner_user_id=review_partner_id if review_required else None,
financial_year=selected_financial_year,
period_label=requested_period,
financial_year=selected_financial_year, period_label=requested_period,
assessment_year=assessment_year_from_financial_year(selected_financial_year),
engagement_type=engagement_type,
start_date=plan_effective_from,
end_date=plan_effective_to,
status="active",
remarks=remarks.strip() or None,
is_active=True,
created_by_user_id=user.id,
updated_by_user_id=user.id,
engagement_type=engagement_type, start_date=plan_effective_from,
end_date=plan_effective_to, status="active", remarks=remarks.strip() or None,
is_active=True, created_by_user_id=user.id, updated_by_user_id=user.id,
)
db.add(engagement)
db.flush()
db.add(engagement); db.flush()
attach_engagement_to_plan(
db,
engagement=engagement,
client=client,
catalogue=firm_selection.catalogue,
firm_selection=firm_selection,
actor_user_id=user.id,
db, engagement=engagement, client=client, catalogue=firm_selection.catalogue,
firm_selection=firm_selection, actor_user_id=user.id,
)
apply_due_date_rule_to_subscription(db, engagement, force=True)
ensure_engagement_quality_workflow(
db,
subscription=engagement,
actor_user_id=user.id,
create_declarations=False,
)
ensure_engagement_quality_workflow(db, subscription=engagement, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(engagement)
engagements_created += 1
db.commit()
return RedirectResponse(
"/services/subscriptions/bulk"
f"?subscriptions_created={subscriptions_created}"
f"&subscriptions_reused={subscriptions_reused}"
f"&engagements_created={engagements_created}"
f"&engagements_skipped={engagements_skipped}",
status_code=303,
f"?subscriptions_created={subscriptions_created}&subscriptions_reused={subscriptions_reused}"
f"&engagements_created={engagements_created}&engagements_skipped={engagements_skipped}",
303,
)
except Exception:
db.rollback()
@@ -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">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">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 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>
@@ -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>
</form>
</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 %}
@@ -2,493 +2,161 @@
{% block content %}
<div class="space-y-6">
{% set _uiux_partner_role_text = (current_user_roles or [])|join('|')|lower %}
{% if 'partner' in _uiux_partner_role_text %}
{% include "ui/templates/components/partner_navigation_v2.html" %}
{% endif %}
{% if 'partner' in _uiux_partner_role_text %}{% include "ui/templates/components/partner_navigation_v2.html" %}{% endif %}
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-xl font-semibold text-slate-900">Bulk Subscription Setup</h2>
<p class="text-sm text-slate-500">
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><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>
<a href="/services/subscriptions" class="rounded-xl border border-slate-300 px-4 py-2 text-sm">Back</a>
</div>
{% if error_message %}
<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 error_message %}<div class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{{ error_message }}</div>{% endif %}
{% 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="rounded-2xl bg-white p-4 shadow-soft">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Subscriptions Created</div>
<div class="mt-1 text-2xl font-semibold text-slate-900">{{ subscriptions_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">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 %}
{% for label,value in [('Subscriptions Created',subscriptions_created),('Existing Reused',subscriptions_reused),('Engagements Created',engagements_created),('Duplicates Skipped',engagements_skipped)] %}
<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>
{% endfor %}
</div>{% endif %}
<form method="post" action="/services/subscriptions/bulk" id="bulk-subscription-form" class="space-y-5">
<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>
<label class="mb-2 block text-sm font-medium text-slate-700">Financial Year</label>
<input type="text"
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">
<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>
<div class="xl:col-span-2"><label class="mb-2 block text-sm font-medium">Enabled Firm Service</label>
<select name="service_catalogue_id" id="bulk-service" required class="w-full rounded-xl border px-4 py-2 text-sm">
<option value="">Select service</option>
{% for selection in enabled_services %}
<option value="{{ selection.catalogue.id }}"
data-type="{{ selection.catalogue.engagement_type or 'non_assurance' }}"
data-recurrence="{{ selection.catalogue.recurrence_type or '' }}">
<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 '' }}">
{{ selection.catalogue.service_name }} ({{ selection.catalogue.service_code }})
</option>
{% endfor %}
</option>{% endfor %}
</select>
<p id="service-derived-info" class="mt-1 text-xs text-slate-500">
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>
<p id="service-derived-info" class="mt-1 text-xs text-slate-500">Select a service to load the applicable subscription scope.</p>
</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 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">
<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="subscription_only"
checked
class="mt-1 border-slate-300">
<span>
<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>
{% for value,title,help in [
('subscription_only','Subscriptions only','Create or reuse the service master without generating engagements.'),
('current_period','One engagement','Generate one selected period for each chosen scope.'),
('all_periods','All FY engagements','Generate all monthly or quarterly periods, or one annual engagement.')
] %}
<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>
{% endfor %}
</div>
<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>
<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>
</div>
<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>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Search clients</label>
<input type="search"
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>
<button type="button"
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 class="text-sm text-slate-600">
<span id="selected-client-count" class="font-semibold text-slate-900">0</span> clients selected
<div><label id="scope-search-label" class="mb-1 block text-xs font-semibold uppercase text-slate-500">Select subscription scope</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>
<button type="button" id="clear-search" class="rounded-xl border px-4 py-2 text-sm">Clear</button>
</div>
<div class="text-sm"><span id="selected-count" class="font-semibold">0</span> <span id="selected-noun">items</span> selected</div>
</div>
<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>
<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>
<div id="scope-table-wrap" class="hidden max-h-[32rem] overflow-auto">
<table class="min-w-full divide-y">
<thead class="sticky top-0 bg-slate-50"><tr id="scope-table-head"></tr></thead>
<tbody id="scope-table-body" class="divide-y"></tbody>
</table>
</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">
<p class="text-sm text-slate-600">
Existing client-service subscriptions are reused. Existing engagements for the same client, service, financial year and period are skipped automatically.
</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 class="flex justify-end gap-3 rounded-2xl bg-white p-4 shadow-soft">
<a href="/services/subscriptions" class="rounded-xl border px-4 py-2 text-sm">Cancel</a>
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm text-white">Create Subscriptions</button>
</div>
</form>
</div>
<script>
(function () {
const form = document.getElementById('bulk-subscription-form');
const service = document.getElementById('bulk-service');
const info = document.getElementById('service-derived-info');
const reviewPartner = document.getElementById('bulk-review-partner');
const reviewRequirement = document.getElementById('review-partner-requirement');
const reviewHelp = document.getElementById('review-partner-help');
const financialYear = document.getElementById('bulk-financial-year');
const period = document.getElementById('bulk-period');
const generationRadios = Array.from(document.querySelectorAll('input[name="generation_mode"]'));
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(){
const form=document.getElementById('bulk-subscription-form'), service=document.getElementById('bulk-service'),
search=document.getElementById('scope-search'), clear=document.getElementById('clear-search'),
head=document.getElementById('scope-table-head'), body=document.getElementById('scope-table-body'),
wrap=document.getElementById('scope-table-wrap'), message=document.getElementById('scope-message'),
count=document.getElementById('selected-count'), noun=document.getElementById('selected-noun'),
label=document.getElementById('scope-search-label'), info=document.getElementById('service-derived-info'),
fy=document.getElementById('bulk-financial-year'), period=document.getElementById('bulk-period'),
review=document.getElementById('bulk-review-partner');
let targets=[], scopeType='client', registrationType='';
function currentGenerationMode() {
const selected = generationRadios.find(radio => radio.checked);
return selected ? selected.value : 'subscription_only';
}
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const meta={
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() {
const raw = (financialYear.value || '').trim();
const start = parseInt(raw.split('-')[0], 10);
return Number.isFinite(start)
? [start, start + 1]
: [new Date().getFullYear(), new Date().getFullYear() + 1];
}
function rebuildPeriodOptions() {
const option = service.options[service.selectedIndex];
const recurrence = option ? (option.dataset.recurrence || '').trim().toLowerCase().replaceAll('-', '_').replaceAll(' ', '_') : '';
const mode = currentGenerationMode();
const requiresSelectedPeriod = mode === 'current_period' && (recurrence === 'monthly' || recurrence === 'quarterly');
period.innerHTML = '<option value="">Not applicable</option>';
period.required = requiresSelectedPeriod;
period.disabled = mode !== 'current_period';
if (!requiresSelectedPeriod) {
return;
}
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();
});
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();
let message = `Create or reuse subscriptions for ${selected} selected client${selected === 1 ? '' : 's'}`;
if (mode === 'current_period') {
message += ' and generate one engagement for each?';
} else if (mode === 'all_periods') {
message += ' and generate all applicable FY engagements?';
} else {
message += '?';
}
if (!confirm(message)) {
event.preventDefault();
}
});
updateServiceInfo();
function checked(){return Array.from(body.querySelectorAll('.target-box:checked'));}
function updateCount(){count.textContent=checked().length;}
function render(){
const m=meta[scopeType]||meta.client;
label.textContent=registrationType?`Search ${registrationType} Registrations`:m.title;
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>`+
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('')+
`<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Subscription Status</th>`;
const term=search.value.trim().toLowerCase();
const visible=targets.filter(t=>!term||Object.values(t).join(' ').toLowerCase().includes(term));
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>
${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('')}
<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>
</tr>`).join('');
document.querySelectorAll('.target-box').forEach(x=>x.addEventListener('change',updateCount));
const all=document.getElementById('select-all');
if(all) all.addEventListener('change',()=>{document.querySelectorAll('.target-box').forEach(x=>x.checked=all.checked);updateCount();});
updateCount();
}
async function loadTargets(){
const id=service.value;
targets=[]; body.innerHTML=''; count.textContent='0';
if(!id){wrap.classList.add('hidden');message.classList.remove('hidden');search.disabled=true;return;}
search.disabled=true; message.textContent='Loading applicable subscription scope...';message.classList.remove('hidden');wrap.classList.add('hidden');
const response=await fetch(`/services/subscriptions/bulk/targets?service_catalogue_id=${encodeURIComponent(id)}`,{headers:{'Accept':'application/json'}});
const data=await response.json();
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();
}
function updateService(){
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;
period.innerHTML='<option value="">Not applicable</option>';period.disabled=mode!=='current_period';period.required=false;
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>
{% endblock %}
+6 -2
View File
@@ -484,7 +484,7 @@ def catalogue_create_page(request: Request):
@router.post('/catalogue/new')
def catalogue_create_submit(request: Request, service_code: str = Form(...), service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: str | None = Form(None), csrf_token: str = Form(...)):
def catalogue_create_submit(request: Request, service_code: str = Form(...), service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), service_scope_type: str = Form('client'), required_registration_type: str = Form(''), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: str | None = Form(None), csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
@@ -502,6 +502,8 @@ def catalogue_create_submit(request: Request, service_code: str = Form(...), ser
category=selected_category.name if selected_category else None,
recurrence_type=recurrence_type.strip() or None,
engagement_type=normalize_engagement_type(engagement_type),
service_scope_type=(service_scope_type or 'client').strip().lower(),
required_registration_type=(required_registration_type or '').strip().upper() or None,
sort_order=sort_order,
description=description.strip() or None,
applicable_individual=applicable_individual is not None,
@@ -812,7 +814,7 @@ def catalogue_edit_page(request: Request, catalogue_id: int):
@router.post('/catalogue/{catalogue_id}/edit')
def catalogue_edit_submit(request: Request, catalogue_id: int, service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: str | None = Form(None), csrf_token: str = Form(...)):
def catalogue_edit_submit(request: Request, catalogue_id: int, service_name: str = Form(...), category_id: str = Form(''), recurrence_type: str = Form(''), engagement_type: str = Form('non_assurance'), service_scope_type: str = Form('client'), required_registration_type: str = Form(''), sort_order: int = Form(100), description: str = Form(''), applicable_individual: str | None = Form(None), applicable_proprietorship: str | None = Form(None), applicable_partnership: str | None = Form(None), applicable_llp: str | None = Form(None), applicable_company: str | None = Form(None), applicable_trust: str | None = Form(None), applicable_society: str | None = Form(None), is_active: str | None = Form(None), is_client_requestable: str | None = Form(None), is_consultant_requestable: str | None = Form(None), csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
@@ -831,6 +833,8 @@ def catalogue_edit_submit(request: Request, catalogue_id: int, service_name: str
row.category = selected_category.name if selected_category else None
row.recurrence_type = recurrence_type.strip() or None
row.engagement_type = normalize_engagement_type(engagement_type)
row.service_scope_type = (service_scope_type or 'client').strip().lower()
row.required_registration_type = (required_registration_type or '').strip().upper() or None
row.sort_order = sort_order
row.applicable_individual = applicable_individual is not None
row.applicable_proprietorship = applicable_proprietorship is not None
+2
View File
@@ -2,6 +2,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router
from app.modules.clients.scope_ui import router as client_scope_ui_router
from app.modules.client_groups.ui import router as client_groups_ui_router
from app.modules.consultants.ui import router as consultants_ui_router, portal_router as consultant_portal_router
from app.modules.employees.ui import router as employees_ui_router, portal_router as employee_portal_router
@@ -69,6 +70,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(system_admin_dashboard_router)
app.include_router(work_detail_ui_router)
app.include_router(clients_ui_router)
app.include_router(client_scope_ui_router)
app.include_router(client_groups_ui_router)
app.include_router(registrations_ui_router)
app.include_router(credential_vault_ui_router)