From 9b307ebcddf5997692107dc200fdc996ce5210b9 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Wed, 5 Aug 2026 18:31:30 +0530 Subject: [PATCH] Align bulk subscriptions with bulk engagement setup --- app/modules/services/subscriptions_ui.py | 376 +++++++++++++ .../services/subscriptions/bulk.html | 494 ++++++++++++++++++ .../components/partner_navigation_v2.html | 7 +- 3 files changed, 876 insertions(+), 1 deletion(-) create mode 100644 app/modules/services/templates/services/subscriptions/bulk.html diff --git a/app/modules/services/subscriptions_ui.py b/app/modules/services/subscriptions_ui.py index aee5da8..8366824 100644 --- a/app/modules/services/subscriptions_ui.py +++ b/app/modules/services/subscriptions_ui.py @@ -13,12 +13,29 @@ from app.core.security.session_auth import get_current_user from app.core.templating import templates from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.core.rbac.permission_guard import require_permission +from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked from app.modules.services.client_services import ( SUBSCRIPTION_STATUSES, + assessment_year_from_financial_year, + attach_engagement_to_plan, + ensure_engagement_quality_workflow, + enforce_quality_gate_on_subscription, + get_enabled_firm_service, + get_existing_subscription, + get_or_create_client_service_plan, list_assignable_users, + list_clients_for_assignment, + list_enabled_services_for_assignment, list_review_partners, + normalize_financial_year, + normalize_period_label, parse_date, + period_choices_for_service, + recurrence_requires_period, + review_partner_required_for_engagement, ) +from app.modules.clients.models import Client +from app.modules.services.due_dates import apply_due_date_rule_to_subscription from app.modules.services.models import ( ClientServicePlan, ClientServiceSubscription, @@ -44,6 +61,18 @@ def _branch_id(request, user): return int(value) + +def _active_financial_year(request: Request) -> str: + return normalize_financial_year( + request.session.get("active_financial_year") + or getattr(request.state, "year_code", None) + ) + + +def _partner_scope_id(db, user) -> int | None: + roles = set(get_user_roles(db, user.id)) + return int(user.id) if "Partner" in roles else None + def _ctx(request, db, user, **extra): data = { "request": request, @@ -189,6 +218,353 @@ def subscription_master_list(request: Request, q: str = "", include_inactive: bo db.close() + +@router.get("/bulk") +def subscription_bulk_page( + request: Request, + error: str = "", + subscriptions_created: int = 0, + subscriptions_reused: int = 0, + engagements_created: int = 0, + engagements_skipped: int = 0, +): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse("/login", 303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _denied() + + 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 + ) + error_messages = { + "clients": "Select at least one permitted client.", + "service": "Select a valid enabled firm service.", + "partner": "Select a valid Engagement Partner.", + "performing_partner": "Select a valid Performing Partner.", + "manager": "Select a valid Manager.", + "staff": "Select a valid Staff member.", + "review_partner": "Select a valid Review Partner.", + "review_partner_required": "Review Partner is mandatory for this assurance service.", + "review_partner_independence": "Review Partner must differ from the Engagement and Performing Partners.", + "period": "Select a valid month or quarter.", + "generation": "Select a valid engagement generation option.", + "financial_year": "The selected financial year is locked.", + } + 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 + }, + financial_year=_active_financial_year(request), + error_message=error_messages.get(error, ""), + subscriptions_created=subscriptions_created, + subscriptions_reused=subscriptions_reused, + engagements_created=engagements_created, + engagements_skipped=engagements_skipped, + ), + ) + finally: + db.close() + + +@router.post("/bulk") +def subscription_bulk_submit( + request: Request, + client_ids: list[int] = Form([]), + service_catalogue_id: int = Form(...), + default_partner_user_id: int = Form(...), + default_performing_partner_user_id: str = Form(""), + default_manager_user_id: str = Form(""), + default_staff_user_id: str = Form(""), + default_review_partner_user_id: str = Form(""), + effective_from: str = Form(""), + effective_to: str = Form(""), + auto_generate_periods: str | None = Form(None), + remarks: str = Form(""), + generation_mode: str = Form("subscription_only"), + financial_year: str = Form(""), + period_label: str = Form(""), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + subscriptions_created = 0 + subscriptions_reused = 0 + engagements_created = 0 + engagements_skipped = 0 + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse("/login", 303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _denied() + + 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, + ) + 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 + ) + + 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} + + if default_partner_user_id not in partner_ids: + 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: + 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: + 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: + 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: + 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 + ) + 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}: + return RedirectResponse("/services/subscriptions/bulk?error=review_partner_independence", 303) + + 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) + ) + if generation_mode != "subscription_only": + locked_response = 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 + + recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None) + if generation_mode == "subscription_only": + requested_periods: list[str] = [] + 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 + ) + ] + else: + try: + 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: + requested_periods = [""] + + plan_effective_from = parse_date(effective_from) + plan_effective_to = parse_date(effective_to) + 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, + ) + ).scalar_one_or_none() + + 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() + + if existing_plan is None: + subscriptions_created += 1 + else: + subscriptions_reused += 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, + ) + if existing: + if existing.service_plan_id is None: + existing.service_plan_id = plan.id + engagements_skipped += 1 + 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, + 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, + review_partner_user_id=review_partner_id if review_required else None, + 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, + ) + 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, + ) + 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, + ) + 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, + ) + except Exception: + db.rollback() + raise + finally: + db.close() + @router.get("/{plan_id}/edit") def subscription_master_edit_page( request: Request, diff --git a/app/modules/services/templates/services/subscriptions/bulk.html b/app/modules/services/templates/services/subscriptions/bulk.html new file mode 100644 index 0000000..cd6ec06 --- /dev/null +++ b/app/modules/services/templates/services/subscriptions/bulk.html @@ -0,0 +1,494 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+ {% set _uiux_partner_role_text = (current_user_roles or [])|join('|')|lower %} + {% if 'partner' in _uiux_partner_role_text %} + {% include "ui/templates/components/partner_navigation_v2.html" %} + {% endif %} + +
+
+

Bulk Subscription Setup

+

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

+
+ + Back + +
+ + {% if error_message %} +
+ {{ error_message }} +
+ {% endif %} + + {% if subscriptions_created or subscriptions_reused or engagements_created or engagements_skipped %} +
+
+
Subscriptions Created
+
{{ subscriptions_created }}
+
+
+
Existing Reused
+
{{ subscriptions_reused }}
+
+
+
Engagements Created
+
{{ engagements_created }}
+
+
+
Duplicates Skipped
+
{{ engagements_skipped }}
+
+
+ {% endif %} + +
+ + +
+
+ + +
+ +
+ + +

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

+
+ +
+ + +

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

+
+ +
+ + +

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

+
+ +
+ + +

+ Defaults to the Engagement Partner when left blank. +

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

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

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

Engagement Generation

+
+ + + + + +
+ + +
+ +
+
+
+
+ + +
+ +
+
+ 0 clients selected +
+
+ +
+ + + + + + + + + + + + + {% for client in clients %} + {% set search_text = ((client.client_code or '') ~ ' ' ~ (client.client_name or '') ~ ' ' ~ (client.trade_name or '') ~ ' ' ~ (client.pan or '') ~ ' ' ~ (client.gstin or '') ~ ' ' ~ (client.client_type or ''))|lower %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
+ + CodeClientPAN / GSTINTypeCurrent Partner
+ + {{ client.client_code }} +
{{ client.client_name }}
+ {% if client.trade_name %} +
{{ client.trade_name }}
+ {% endif %} +
+
PAN: {{ client.pan or '-' }}
+
GSTIN: {{ client.gstin or '-' }}
+
{{ client.client_type or '-' }} + {{ client_partner_names.get(client.partner_id, '-') if client.partner_id else '-' }} +
+ No clients are available for subscription. +
+
+
+ +
+

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

+
+ + Cancel + + +
+
+
+
+ + +{% endblock %} diff --git a/app/ui/templates/components/partner_navigation_v2.html b/app/ui/templates/components/partner_navigation_v2.html index e5e21fa..1be3452 100644 --- a/app/ui/templates/components/partner_navigation_v2.html +++ b/app/ui/templates/components/partner_navigation_v2.html @@ -82,7 +82,12 @@ { 'label': 'Client Subscriptions', 'url': '/services/subscriptions', - 'active': _partner_path.startswith('/services/subscriptions') + 'active': _partner_path == '/services/subscriptions' or (_partner_path.startswith('/services/subscriptions/') and not _partner_path.startswith('/services/subscriptions/bulk')) + }, + { + 'label': 'Bulk Subscriptions', + 'url': '/services/subscriptions/bulk', + 'active': _partner_path.startswith('/services/subscriptions/bulk') }, { 'label': 'All Engagements',