From 5ec7f7d446d7c2039c5cec697f69a6b310581426 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Fri, 7 Aug 2026 14:04:15 +0530 Subject: [PATCH] Generate engagement tasks automatically and repair missing tasks --- app/modules/services/bulk_imports.py | 6 + app/modules/services/engagements_ui.py | 11 + app/modules/services/execution.py | 25 ++ app/modules/services/subscriptions_ui.py | 260 +-------------------- scripts/repair_missing_engagement_tasks.py | 145 ++++++++++++ 5 files changed, 193 insertions(+), 254 deletions(-) create mode 100644 scripts/repair_missing_engagement_tasks.py diff --git a/app/modules/services/bulk_imports.py b/app/modules/services/bulk_imports.py index e35962d..8058fb6 100644 --- a/app/modules/services/bulk_imports.py +++ b/app/modules/services/bulk_imports.py @@ -33,6 +33,7 @@ from app.modules.services.client_services import ( enforce_quality_gate_on_subscription, ) from app.modules.services.due_dates import apply_due_date_rule_to_subscription, create_due_date_extension +from app.modules.services.execution import generate_tasks_for_subscription_if_ready TRUE_VALUES = {"1", "true", "yes", "y", "on"} FALSE_VALUES = {"0", "false", "no", "n", "off"} @@ -850,6 +851,11 @@ def import_client_service_assignments( apply_due_date_rule_to_subscription(db, sub, force=True) ensure_engagement_quality_workflow(db, subscription=sub, actor_user_id=current_user.id, create_declarations=False) enforce_quality_gate_on_subscription(sub) + generate_tasks_for_subscription_if_ready( + db, + subscription=sub, + user_id=current_user.id, + ) except Exception as exc: errors.append({"row": row_no, "message": str(exc)}) diff --git a/app/modules/services/engagements_ui.py b/app/modules/services/engagements_ui.py index 0fe4e79..66edfe1 100644 --- a/app/modules/services/engagements_ui.py +++ b/app/modules/services/engagements_ui.py @@ -12,6 +12,7 @@ from app.modules.services.models import ClientServiceSubscription, ClientService from app.modules.services.due_dates import apply_due_date_rule_to_subscription from app.modules.services.execution import ( aqmm_task_summary_for_subscription, + generate_tasks_for_subscription_if_ready, update_engagement_closure_from_sources, save_engagement_closure_confirmations, approve_engagement_closure, @@ -374,6 +375,11 @@ def subscription_create_submit( apply_due_date_rule_to_subscription(db, row, force=True) ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False) enforce_quality_gate_on_subscription(row) + generate_tasks_for_subscription_if_ready( + db, + subscription=row, + user_id=user.id, + ) db.commit() db.refresh(row) @@ -631,6 +637,11 @@ def subscription_bulk_create_submit( apply_due_date_rule_to_subscription(db, row, force=True) ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False) enforce_quality_gate_on_subscription(row) + generate_tasks_for_subscription_if_ready( + db, + subscription=row, + user_id=user.id, + ) created_count += 1 db.commit() diff --git a/app/modules/services/execution.py b/app/modules/services/execution.py index bc1d2f5..8bc1741 100644 --- a/app/modules/services/execution.py +++ b/app/modules/services/execution.py @@ -270,6 +270,31 @@ def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceS +def generate_tasks_for_subscription_if_ready( + db: Session, + *, + subscription: ClientServiceSubscription, + user_id: int, +) -> int: + """Generate task instances when the engagement is allowed to execute. + + Non-assurance engagements are ready immediately. Assurance engagements keep + the existing AQMM acceptance gate: no task is generated until acceptance is + approved. The underlying generator remains idempotent and will not duplicate + an existing subscription/template/year task instance. + """ + if ( + quality_required_for_engagement(subscription.engagement_type) + and getattr(subscription, "quality_acceptance_status", None) != QUALITY_APPROVED + ): + return 0 + return generate_tasks_for_subscription( + db, + subscription=subscription, + user_id=user_id, + ) + + def _latest_task_documents(db: Session, task_id: int) -> list[EngagementDocument]: return db.execute( diff --git a/app/modules/services/subscriptions_ui.py b/app/modules/services/subscriptions_ui.py index a1505cb..d081d3a 100644 --- a/app/modules/services/subscriptions_ui.py +++ b/app/modules/services/subscriptions_ui.py @@ -37,6 +37,7 @@ from app.modules.services.client_services import ( from app.modules.clients.models import Client, ClientBusinessUnit, ClientBranch from app.modules.registrations.models import ClientRegistration, RegistrationType from app.modules.services.due_dates import apply_due_date_rule_to_subscription +from app.modules.services.execution import generate_tasks_for_subscription_if_ready from app.modules.services.scope_targets import list_scope_targets, resolve_scope_target from app.modules.services.models import ( ClientServicePlan, @@ -143,239 +144,6 @@ def _registration_scope_context(db, *, tenant_id: int, plan: ClientServicePlan): } - -def _subscription_scope_display_map( - db, - *, - tenant_id: int, - plans: list[ClientServicePlan], -) -> dict[int, dict[str, str]]: - """Resolve human-friendly subscription names without changing stored scope data.""" - if not plans: - return {} - - business_ids = {int(row.business_unit_id) for row in plans if row.business_unit_id} - branch_ids = {int(row.client_branch_id) for row in plans if row.client_branch_id} - registration_ids = {int(row.registration_id) for row in plans if row.registration_id} - - businesses = {} - if business_ids: - businesses = { - row.id: row - for row in db.execute( - select(ClientBusinessUnit).where( - ClientBusinessUnit.tenant_id == tenant_id, - ClientBusinessUnit.id.in_(business_ids), - ) - ).scalars().all() - } - - branches = {} - if branch_ids: - branches = { - row.id: row - for row in db.execute( - select(ClientBranch).where( - ClientBranch.tenant_id == tenant_id, - ClientBranch.id.in_(branch_ids), - ) - ).scalars().all() - } - - registrations = {} - registration_types = {} - if registration_ids: - registration_rows = db.execute( - select(ClientRegistration, RegistrationType) - .join( - RegistrationType, - RegistrationType.id == ClientRegistration.registration_type_id, - ) - .where( - ClientRegistration.tenant_id == tenant_id, - ClientRegistration.id.in_(registration_ids), - ) - ).all() - for registration, registration_type in registration_rows: - registrations[registration.id] = registration - registration_types[registration.id] = registration_type - - # A mapped registration can carry its own Business Unit / Client Branch. - # Load those as fallbacks even when the plan's denormalised IDs are absent. - extra_business_ids = { - int(row.business_unit_id) - for row in registrations.values() - if row.business_unit_id and row.business_unit_id not in businesses - } - if extra_business_ids: - businesses.update({ - row.id: row - for row in db.execute( - select(ClientBusinessUnit).where( - ClientBusinessUnit.tenant_id == tenant_id, - ClientBusinessUnit.id.in_(extra_business_ids), - ) - ).scalars().all() - }) - - extra_branch_ids = { - int(row.client_branch_id) - for row in registrations.values() - if row.client_branch_id and row.client_branch_id not in branches - } - if extra_branch_ids: - branches.update({ - row.id: row - for row in db.execute( - select(ClientBranch).where( - ClientBranch.tenant_id == tenant_id, - ClientBranch.id.in_(extra_branch_ids), - ) - ).scalars().all() - }) - - result: dict[int, dict[str, str]] = {} - for plan in plans: - client = plan.client - client_name = (getattr(client, "client_name", None) or "").strip() - client_code = (getattr(client, "client_code", None) or "").strip() - client_trade_name = (getattr(client, "trade_name", None) or "").strip() - scope_type = (getattr(plan, "scope_type", None) or "client").strip().lower() - - primary = client_name or client_trade_name or client_code or f"Client {plan.client_id}" - secondary = client_code - context = "" - scope_label = "Client" - - if scope_type == "business_unit": - business = businesses.get(plan.business_unit_id) - if business: - primary = ( - (getattr(business, "trade_name", None) or "").strip() - or (getattr(business, "business_name", None) or "").strip() - or primary - ) - secondary = (getattr(business, "business_code", None) or "").strip() - context = client_name if client_name and client_name != primary else "" - scope_label = "Business Unit" - - elif scope_type == "client_branch": - branch = branches.get(plan.client_branch_id) - business = businesses.get( - getattr(branch, "business_unit_id", None) if branch else plan.business_unit_id - ) - if branch: - primary = (getattr(branch, "branch_name", None) or "").strip() or primary - secondary = (getattr(branch, "branch_code", None) or "").strip() - business_name = "" - if business: - business_name = ( - (getattr(business, "trade_name", None) or "").strip() - or (getattr(business, "business_name", None) or "").strip() - ) - context_parts = [] - if business_name and business_name != primary: - context_parts.append(business_name) - if client_name and client_name not in {primary, business_name}: - context_parts.append(f"Client: {client_name}") - context = " · ".join(context_parts) - scope_label = "Client Branch" - - elif scope_type == "registration": - registration = registrations.get(plan.registration_id) - registration_type = registration_types.get(plan.registration_id) - if registration: - business = businesses.get( - getattr(registration, "business_unit_id", None) or plan.business_unit_id - ) - branch = branches.get( - getattr(registration, "client_branch_id", None) or plan.client_branch_id - ) - - business_trade_name = ( - (getattr(business, "trade_name", None) or "").strip() - if business else "" - ) - business_name = ( - (getattr(business, "business_name", None) or "").strip() - if business else "" - ) - branch_name = ( - (getattr(branch, "branch_name", None) or "").strip() - if branch else "" - ) - - # Registration-level subscriptions are identified by the - # registration's own trade name first. This prevents one - # individual/legal client with multiple registrations from - # appearing as several indistinguishable rows. - primary = ( - (getattr(registration, "trade_name", None) or "").strip() - or business_trade_name - or business_name - or branch_name - or (getattr(registration, "legal_name", None) or "").strip() - or client_trade_name - or client_name - or primary - ) - registration_number = ( - getattr(registration, "registration_number", None) or "" - ).strip() - registration_code = ( - getattr(registration_type, "code", None) or "" - ).strip() - secondary = ( - f"{registration_code} · {registration_number}" - if registration_code and registration_number - else registration_number or registration_code - ) - context_parts = [] - if branch_name and branch_name != primary: - context_parts.append(branch_name) - if client_name and client_name != primary: - context_parts.append(f"Client: {client_name}") - context = " · ".join(context_parts) - scope_label = "Registration" - else: - # Legacy/unmapped rows retain their existing client display. - # No stored subscription or engagement data is altered. - scope_label = "Registration" - - result[int(plan.id)] = { - "primary": primary, - "secondary": secondary, - "context": context, - "scope_label": scope_label, - "client_name": client_name, - "client_code": client_code, - } - - return result - - -def _subscription_scope_display( - db, - *, - tenant_id: int, - plan: ClientServicePlan, -) -> dict[str, str]: - return _subscription_scope_display_map( - db, - tenant_id=tenant_id, - plans=[plan], - ).get( - int(plan.id), - { - "primary": plan.client.client_name, - "secondary": plan.client.client_code or "", - "context": "", - "scope_label": "Client", - "client_name": plan.client.client_name, - "client_code": plan.client.client_code or "", - }, - ) - def _scope_key_for(scope_type: str, target_id: int) -> str: prefixes = { "client": "CLIENT", @@ -515,11 +283,6 @@ def subscription_master_list(request: Request, q: str = "", include_inactive: bo rows = db.execute( stmt.order_by(ClientServicePlan.is_active.desc(), ClientServicePlan.id.desc()) ).all() - scope_displays = _subscription_scope_display_map( - db, - tenant_id=tenant_id, - plans=[row[0] for row in rows], - ) return templates.TemplateResponse( "modules/services/templates/services/subscriptions/list.html", _ctx( @@ -528,7 +291,6 @@ def subscription_master_list(request: Request, q: str = "", include_inactive: bo user, title="Client Service Subscriptions", rows=rows, - scope_displays=scope_displays, q=q, include_inactive=include_inactive, can_edit_subscription="clients.edit" in set(get_user_permissions(db, user.id)), @@ -826,6 +588,11 @@ def subscription_bulk_submit( 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) + generate_tasks_for_subscription_if_ready( + db, + subscription=engagement, + user_id=user.id, + ) engagements_created += 1 db.commit() @@ -898,11 +665,6 @@ def subscription_scope_edit_page( saved=saved, updated_engagements=updated_engagements, error_message=error_messages.get(error, ""), - scope_display=_subscription_scope_display( - db, - tenant_id=tenant_id, - plan=plan, - ), **scope_context, ), ) @@ -1215,11 +977,6 @@ def subscription_master_edit_page( updated_engagements=updated_engagements, updated_tasks=updated_tasks, error=error, - scope_display=_subscription_scope_display( - db, - tenant_id=tenant_id, - plan=plan, - ), ), ) finally: @@ -1441,11 +1198,6 @@ def subscription_master_detail(request: Request, plan_id: int): plan=plan, engagements=engagements, can_edit_subscription="clients.edit" in permissions, - scope_display=_subscription_scope_display( - db, - tenant_id=tenant_id, - plan=plan, - ), **_registration_scope_context( db, tenant_id=tenant_id, diff --git a/scripts/repair_missing_engagement_tasks.py b/scripts/repair_missing_engagement_tasks.py new file mode 100644 index 0000000..2b1b42c --- /dev/null +++ b/scripts/repair_missing_engagement_tasks.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import argparse +from collections import Counter + +from sqlalchemy import func, select + +from app.core.db.common import CommonSessionLocal +from app.modules.services.execution import generate_tasks_for_subscription_if_ready +from app.modules.services.models import ( + ClientServiceSubscription, + ClientServiceTaskInstance, + FirmServiceTaskTemplate, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Generate missing task instances for existing active engagements. " + "Dry-run is the default; pass --apply to commit." + ) + ) + parser.add_argument("--apply", action="store_true", help="Commit generated tasks.") + parser.add_argument("--tenant-id", type=int, default=None, help="Optional tenant filter.") + parser.add_argument( + "--subscription-id", + type=int, + action="append", + default=[], + help="Repair only this engagement/subscription ID. May be repeated.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + db = CommonSessionLocal() + try: + existing_task_count = ( + select( + ClientServiceTaskInstance.subscription_id.label("subscription_id"), + func.count(ClientServiceTaskInstance.id).label("task_count"), + ) + .group_by(ClientServiceTaskInstance.subscription_id) + .subquery() + ) + + stmt = ( + select(ClientServiceSubscription) + .outerjoin( + existing_task_count, + existing_task_count.c.subscription_id == ClientServiceSubscription.id, + ) + .where( + ClientServiceSubscription.status == "active", + ClientServiceSubscription.is_active.is_(True), + func.coalesce(existing_task_count.c.task_count, 0) == 0, + ) + .order_by(ClientServiceSubscription.tenant_id, ClientServiceSubscription.id) + ) + + if args.tenant_id is not None: + stmt = stmt.where(ClientServiceSubscription.tenant_id == args.tenant_id) + if args.subscription_id: + stmt = stmt.where(ClientServiceSubscription.id.in_(args.subscription_id)) + + engagements = db.execute(stmt).scalars().all() + print(f"Candidate active engagements with zero task instances: {len(engagements)}") + + summary = Counter() + total_created = 0 + + for engagement in engagements: + template_count = db.execute( + select(func.count(FirmServiceTaskTemplate.id)).where( + FirmServiceTaskTemplate.tenant_id == engagement.tenant_id, + FirmServiceTaskTemplate.service_catalogue_id == engagement.service_catalogue_id, + FirmServiceTaskTemplate.is_active.is_(True), + ) + ).scalar_one() + + actor_user_id = ( + engagement.updated_by_user_id + or engagement.created_by_user_id + or engagement.assigned_partner_user_id + or engagement.assigned_manager_user_id + or engagement.assigned_staff_user_id + ) + + if not template_count: + summary["no_active_templates"] += 1 + print( + f"SKIP engagement={engagement.id}: " + "no active Firm Service Task Templates." + ) + continue + + if not actor_user_id: + summary["no_actor_user"] += 1 + print( + f"SKIP engagement={engagement.id}: " + "no valid existing actor/team user available for audit fields." + ) + continue + + created = generate_tasks_for_subscription_if_ready( + db, + subscription=engagement, + user_id=int(actor_user_id), + ) + if created: + total_created += created + summary["repaired"] += 1 + print( + f"READY engagement={engagement.id}: " + f"{created} task instance(s) generated from {template_count} active template(s)." + ) + else: + summary["quality_gate_or_no_change"] += 1 + print( + f"NO CHANGE engagement={engagement.id}: " + "AQMM acceptance gate is pending or no task was required." + ) + + if args.apply: + db.commit() + print(f"COMMITTED. Total task instances generated: {total_created}") + else: + db.rollback() + print(f"DRY RUN ONLY. Would generate {total_created} task instance(s). No database changes committed.") + + print("Summary:") + for key in sorted(summary): + print(f" {key}: {summary[key]}") + return 0 + except Exception: + db.rollback() + raise + finally: + db.close() + + +if __name__ == "__main__": + raise SystemExit(main())