diff --git a/app/modules/services/engagements_ui.py b/app/modules/services/engagements_ui.py index 97f52d0..bca543b 100644 --- a/app/modules/services/engagements_ui.py +++ b/app/modules/services/engagements_ui.py @@ -18,6 +18,7 @@ from app.modules.services.execution import ( reopen_engagement_closure, ) from app.modules.clients.models import Client +from app.modules.core.iam.models import User from app.modules.services.client_services import ( SUBSCRIPTION_STATUSES, assessment_year_from_financial_year, @@ -146,7 +147,17 @@ def _lock_subscription_row(row: ClientServiceSubscription, user) -> bool: @router.get("") -def subscription_list(request: Request, q: str = "", financial_year: str = "", include_inactive: bool = True, locked: int = 0, skipped: int = 0): +def subscription_list( + request: Request, + q: str = "", + financial_year: str = "", + include_inactive: bool = True, + locked: int = 0, + skipped: int = 0, + bulk_created: int = 0, + bulk_existing: int = 0, + bulk_skipped: int = 0, +): db = CommonSessionLocal() try: user = get_current_user(request, db=db) @@ -180,6 +191,9 @@ def subscription_list(request: Request, q: str = "", financial_year: str = "", i include_inactive=include_inactive, locked_count=locked, skipped_count=skipped, + bulk_created_count=bulk_created, + bulk_existing_count=bulk_existing, + bulk_skipped_count=bulk_skipped, can_manage=_can_manage_client_services(db, user), can_lock_engagements=_can_lock_engagements(db, user), ) @@ -327,6 +341,225 @@ def subscription_create_submit( db.close() + +@router.get("/bulk-new") +def subscription_bulk_create_page(request: Request, error: str = ""): + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + branch_id = _active_branch_id(request, user, db) + clients = list_clients_for_assignment( + db, + tenant_id=tenant_id, + branch_id=branch_id, + partner_id=_locked_partner_id(db, user), + ) + enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id) + partners = list_assignable_users(db, tenant_id=tenant_id, role_names=("Partner",)) + managers = list_assignable_users(db, tenant_id=tenant_id, role_names=("Branch Manager",)) + staff_users = list_assignable_users(db, tenant_id=tenant_id, role_names=("Staff",)) + review_partners = list_review_partners(db, tenant_id=tenant_id) + + return _render( + request, + "modules/services/templates/services/engagements/bulk_form.html", + db, + user, + title="Bulk Engagement Setup", + clients=clients, + enabled_services=enabled_services, + partners=partners, + client_partner_names={row.id: (row.full_name or row.email) for row in partners}, + managers=managers, + staff_users=staff_users, + review_partners=review_partners, + financial_year=_active_financial_year(request), + error_message={ + "service": "Select a valid enabled firm service.", + "partner": "Select a valid active Partner.", + "manager": "The selected Manager is not available for this firm.", + "staff": "The selected Staff member is not available for this firm.", + "review_partner": "The selected Review Partner is not available for this firm.", + "clients": "Select at least one permitted client.", + }.get(error), + ) + finally: + db.close() + + +@router.post("/bulk-new") +def subscription_bulk_create_submit( + request: Request, + client_ids: list[int] = Form([]), + service_catalogue_id: int = Form(...), + assigned_partner_user_id: int = Form(...), + assigned_manager_user_id: str = Form(""), + assigned_staff_user_id: str = Form(""), + review_partner_user_id: str = Form(""), + financial_year: str = Form(""), + remarks: str = Form(""), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + created_count = 0 + existing_count = 0 + skipped_count = 0 + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + try: + require_permission(db, user, "clients.edit") + except Exception: + return _redirect_denied() + + tenant_id = _active_tenant_id(request, user) + selected_financial_year = normalize_financial_year(financial_year or _active_financial_year(request)) + locked_response = redirect_if_financial_year_locked( + db, + tenant_id=tenant_id, + year_code=selected_financial_year, + redirect_url=f"/services/engagements?financial_year={selected_financial_year}", + ) + if locked_response: + return locked_response + + firm_selection = get_enabled_firm_service( + db, + tenant_id=tenant_id, + service_catalogue_id=service_catalogue_id, + ) + if not firm_selection: + return RedirectResponse(url="/services/engagements/bulk-new?error=service", status_code=303) + + partners = list_assignable_users(db, tenant_id=tenant_id, role_names=("Partner",)) + managers = list_assignable_users(db, tenant_id=tenant_id, role_names=("Branch Manager",)) + staff_users = list_assignable_users(db, tenant_id=tenant_id, role_names=("Staff",)) + review_partners = list_review_partners(db, tenant_id=tenant_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 assigned_partner_user_id not in partner_ids: + return RedirectResponse(url="/services/engagements/bulk-new?error=partner", status_code=303) + + manager_id = int(assigned_manager_user_id) if assigned_manager_user_id.strip() else None + staff_id = int(assigned_staff_user_id) if assigned_staff_user_id.strip() else None + review_partner_id = int(review_partner_user_id) if review_partner_user_id.strip() else None + if manager_id is not None and manager_id not in manager_ids: + return RedirectResponse(url="/services/engagements/bulk-new?error=manager", status_code=303) + if staff_id is not None and staff_id not in staff_ids: + return RedirectResponse(url="/services/engagements/bulk-new?error=staff", status_code=303) + if review_partner_id is not None and review_partner_id not in review_partner_ids: + return RedirectResponse(url="/services/engagements/bulk-new?error=review_partner", status_code=303) + + partner = db.get(User, assigned_partner_user_id) + if not partner or partner.tenant_id != tenant_id or not partner.is_active: + return RedirectResponse(url="/services/engagements/bulk-new?error=partner", status_code=303) + engagement_branch_id = partner.branch_id + + allowed_clients = list_clients_for_assignment( + db, + tenant_id=tenant_id, + branch_id=_active_branch_id(request, user, db), + partner_id=_locked_partner_id(db, user), + ) + allowed_client_ids = {row.id for row in allowed_clients} + selected_client_ids = [] + seen_client_ids = set() + for value in client_ids: + client_id = int(value) + if client_id in seen_client_ids: + continue + seen_client_ids.add(client_id) + if client_id in allowed_client_ids: + selected_client_ids.append(client_id) + else: + skipped_count += 1 + + if not selected_client_ids: + return RedirectResponse(url="/services/engagements/bulk-new?error=clients", status_code=303) + + engagement_type = getattr(firm_selection.catalogue, "engagement_type", "non_assurance") or "non_assurance" + requires_review_partner = review_partner_required_for_engagement( + db, + tenant_id=tenant_id, + engagement_type=engagement_type, + ) + + for client_id in selected_client_ids: + existing = get_existing_subscription( + db, + tenant_id=tenant_id, + client_id=client_id, + service_catalogue_id=service_catalogue_id, + financial_year=selected_financial_year, + ) + if existing: + existing_count += 1 + continue + + client = db.get(Client, client_id) + if not client or client.tenant_id != tenant_id: + skipped_count += 1 + continue + + row = ClientServiceSubscription( + tenant_id=tenant_id, + branch_id=engagement_branch_id, + client_id=client_id, + service_catalogue_id=service_catalogue_id, + firm_service_selection_id=firm_selection.id, + assigned_partner_user_id=assigned_partner_user_id, + assigned_manager_user_id=manager_id, + assigned_staff_user_id=staff_id, + review_partner_user_id=( + review_partner_id or getattr(client, "default_review_partner_user_id", None) + if requires_review_partner + else None + ), + financial_year=selected_financial_year, + assessment_year=assessment_year_from_financial_year(selected_financial_year), + engagement_type=engagement_type, + status="active", + remarks=remarks.strip() or None, + is_active=True, + created_by_user_id=user.id, + updated_by_user_id=user.id, + ) + db.add(row) + db.flush() + apply_due_date_rule_to_subscription(db, row) + ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False) + enforce_quality_gate_on_subscription(row) + created_count += 1 + + db.commit() + return RedirectResponse( + url=( + f"/services/engagements?financial_year={selected_financial_year}" + f"&bulk_created={created_count}&bulk_existing={existing_count}&bulk_skipped={skipped_count}" + ), + status_code=303, + ) + except Exception: + db.rollback() + raise + finally: + db.close() + + @router.post("/bulk-lock") def subscription_bulk_lock( request: Request, diff --git a/app/modules/services/templates/services/engagements/bulk_form.html b/app/modules/services/templates/services/engagements/bulk_form.html new file mode 100644 index 0000000..9c044e1 --- /dev/null +++ b/app/modules/services/templates/services/engagements/bulk_form.html @@ -0,0 +1,200 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
Choose one enabled firm service, assign the engagement team, and create the same engagement for multiple clients.
+