diff --git a/app/modules/services/execution.py b/app/modules/services/execution.py index 63714b5..d0038f9 100644 --- a/app/modules/services/execution.py +++ b/app/modules/services/execution.py @@ -279,6 +279,128 @@ def _default_internal_target_date(subscription: ClientServiceSubscription, templ return None +def _normalise_task_name_for_setup_sync(value: str | None) -> str: + return " ".join((value or "").strip().lower().replace("&", "and").split()) + + +def _is_engagement_acceptance_setup_task_name(value: str | None) -> bool: + """Return True only for execution rows that duplicate the engagement-level AQMM setup. + + These tasks are retained in the work tracker as historical/scope rows, but once the + engagement-level AQMM workflow is approved they must not ask staff to repeat the + same acceptance, independence, KYC or team-setup approvals. + """ + name = _normalise_task_name_for_setup_sync(value) + if not name: + return False + if "acceptance and continuance" in name: + return True + if "independence and conflict" in name: + return True + if "kyc" in name and "engagement letter" in name: + return True + if name.startswith("create ") and "engagement" in name and "assign team" in name: + return True + return False + + +def _apply_approved_engagement_acceptance_to_task( + task: ClientServiceTaskInstance, + *, + actor_user_id: int, +) -> bool: + """Synchronise one duplicate setup task from an already-approved engagement AQMM gate. + + No task is deleted and no executed/reviewed normal work is rewritten. Only the four + pre-execution setup controls that duplicate the engagement-level acceptance workflow + are satisfied automatically. This removes the circular gate where an active, AQMM- + approved engagement asks staff to obtain AQMM approval again before starting work. + """ + if not _is_engagement_acceptance_setup_task_name(getattr(task, "task_name", None)): + return False + + # Never rewrite genuine task execution/rework history. This repair is intentionally + # limited to untouched setup rows, plus rows already auto-completed by this sync. + status = (getattr(task, "status", None) or "pending").strip().lower() + if status not in {"pending", "completed"}: + return False + if getattr(task, "started_at_utc", None) is not None and status != "completed": + return False + if (getattr(task, "rework_status", None) or "none").strip().lower() == "open": + return False + + now = datetime.now(timezone.utc) + changed = False + + def set_if_different(attr: str, value) -> None: + nonlocal changed + if getattr(task, attr, None) != value: + setattr(task, attr, value) + changed = True + + set_if_different("status", "completed") + if getattr(task, "completed_at_utc", None) is None: + task.completed_at_utc = now + changed = True + + # The engagement-level AQMM approval has already performed these reviews. Keep the + # task row, but do not make staff repeat Manager/Partner/Review-Partner approvals. + set_if_different("normal_review_required", False) + set_if_different("normal_review_role", None) + set_if_different("normal_review_status", "not_required") + set_if_different("aqmm_manager_review_required", False) + set_if_different("aqmm_partner_review_required", False) + set_if_different("aqmm_review_partner_required", False) + set_if_different("manager_review_status", "not_required") + set_if_different("partner_review_status", "not_required") + set_if_different("review_partner_review_status", "not_required") + + # Evidence for these four setup controls lives in the engagement-level AQMM records + # (declarations/KYC/engagement letter). Do not require duplicate task-document evidence. + set_if_different("aqmm_evidence_required", False) + set_if_different("evidence_status", "not_required") + set_if_different("aqmm_review_status", "not_required") + set_if_different("aqmm_status", "completed") + if getattr(task, "aqmm_completed_at_utc", None) is None: + task.aqmm_completed_at_utc = now + changed = True + + if changed: + task.updated_by_user_id = actor_user_id + return changed + + +def synchronise_approved_engagement_acceptance_tasks( + db: Session, + *, + subscription: ClientServiceSubscription, + actor_user_id: int, +) -> int: + """Self-heal duplicate pre-acceptance execution rows after AQMM approval. + + Returns the number of task rows changed. The caller can use this to decide whether + a commit is required. Existing non-setup tasks, task responses, evidence, rework and + reviews are left untouched. + """ + if not quality_required_for_engagement(subscription.engagement_type): + return 0 + if getattr(subscription, "quality_acceptance_status", None) != QUALITY_APPROVED: + return 0 + + tasks = db.execute( + select(ClientServiceTaskInstance).where( + ClientServiceTaskInstance.subscription_id == subscription.id, + ClientServiceTaskInstance.is_active.is_(True), + ) + ).scalars().all() + + changed = 0 + for task in tasks: + if _apply_approved_engagement_acceptance_to_task(task, actor_user_id=actor_user_id): + changed += 1 + return changed + + def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceSubscription, user_id: int) -> int: if quality_required_for_engagement(subscription.engagement_type) and getattr(subscription, "quality_acceptance_status", None) != QUALITY_APPROVED: raise ValueError(getattr(subscription, "quality_block_reason", None) or "AQMM acceptance is pending for this assurance engagement. Complete AQMM before generating tasks.") @@ -372,11 +494,19 @@ def generate_tasks_for_subscription_if_ready( and getattr(subscription, "quality_acceptance_status", None) != QUALITY_APPROVED ): return 0 - return generate_tasks_for_subscription( + created = generate_tasks_for_subscription( db, subscription=subscription, user_id=user_id, ) + repaired = synchronise_approved_engagement_acceptance_tasks( + db, + subscription=subscription, + actor_user_id=user_id, + ) + # Preserve the historical meaning (positive = database changed) while allowing + # existing already-generated engagements to self-heal and be committed by callers. + return int(created) + int(repaired) def _sync_normal_review_snapshot_preserving_history(