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 %} +
+ Choose one enabled firm service, create the subscription for multiple clients, and optionally generate period-wise engagements. +
+