Add engagement closure checklist workflow

This commit is contained in:
A R R R Associates
2026-07-07 14:18:30 +05:30
parent 91b1811eb8
commit c3b11fb9fc
5 changed files with 545 additions and 1 deletions
+240
View File
@@ -12,6 +12,7 @@ from app.modules.services.client_services import quality_required_for_engagement
from app.modules.services.models import (
ClientServiceSubscription,
ClientServiceTaskInstance,
EngagementClosureChecklist,
ServiceTaskComment,
FirmServiceTaskTemplate,
ServiceCatalogue,
@@ -450,6 +451,245 @@ def assert_aqmm_quality_tasks_complete(db: Session, *, subscription_id: int) ->
raise ValueError("AQMM rework is open for one or more quality checklist tasks.")
# -----------------------------------------------------------------------------
# Phase 5 - Engagement closure checklist
# -----------------------------------------------------------------------------
CLOSURE_NOT_STARTED = "not_started"
CLOSURE_PENDING = "pending"
CLOSURE_READY = "ready_for_partner_closure"
CLOSURE_CLOSED = "closed"
CLOSURE_REOPENED = "reopened"
def get_or_create_engagement_closure(
db: Session,
*,
subscription: ClientServiceSubscription,
actor_user_id: int | None = None,
) -> EngagementClosureChecklist:
row = db.execute(
select(EngagementClosureChecklist).where(
EngagementClosureChecklist.subscription_id == subscription.id
)
).scalar_one_or_none()
if row:
return row
row = EngagementClosureChecklist(
tenant_id=subscription.tenant_id,
branch_id=subscription.branch_id,
client_id=subscription.client_id,
subscription_id=subscription.id,
closure_status=CLOSURE_NOT_STARTED,
created_by_user_id=actor_user_id,
)
db.add(row)
return row
def _normal_tasks_completed(db: Session, *, subscription_id: int) -> bool:
open_task = db.execute(
select(ClientServiceTaskInstance.id).where(
ClientServiceTaskInstance.subscription_id == subscription_id,
ClientServiceTaskInstance.is_active.is_(True),
ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)),
)
).first()
return open_task is None
def _final_documents_released(db: Session, *, subscription_id: int, require_final_document: bool) -> bool:
released = db.execute(
select(EngagementDocument.id).where(
EngagementDocument.engagement_id == subscription_id,
EngagementDocument.is_deleted.is_(False),
EngagementDocument.final_release_status == "released",
)
).first()
if released:
return True
if not require_final_document:
# Non-assurance closures can proceed even where no final document release
# workflow was used for that engagement. Assurance closures need at least
# one released final document.
return True
return False
def _udin_completed(db: Session, *, subscription_id: int) -> bool:
pending = db.execute(
select(EngagementDocument.id).where(
EngagementDocument.engagement_id == subscription_id,
EngagementDocument.is_deleted.is_(False),
EngagementDocument.udin_required.is_(True),
or_(
EngagementDocument.udin_number.is_(None),
EngagementDocument.udin_number == "",
EngagementDocument.udin_status.in_(["pending", "required", "not_generated"]),
),
)
).first()
return pending is None
def closure_readiness_for_subscription(db: Session, *, subscription: ClientServiceSubscription) -> dict:
assurance = quality_required_for_engagement(subscription.engagement_type)
aqmm_summary = aqmm_task_summary_for_subscription(db, subscription_id=subscription.id)
normal_tasks_done = _normal_tasks_completed(db, subscription_id=subscription.id)
aqmm_acceptance_completed = (not assurance) or getattr(subscription, "quality_acceptance_status", None) == QUALITY_APPROVED
aqmm_tasks_completed = (not assurance) or (
aqmm_summary["mandatory_done"] == aqmm_summary["mandatory"]
and aqmm_summary["evidence_missing"] == 0
and aqmm_summary["manager_review_pending"] == 0
and aqmm_summary["partner_review_pending"] == 0
and aqmm_summary["review_partner_review_pending"] == 0
and aqmm_summary["rework_open"] == 0
)
evidence_review_completed = (not assurance) or (
aqmm_summary["evidence_missing"] == 0
and aqmm_summary["manager_review_pending"] == 0
and aqmm_summary["partner_review_pending"] == 0
and aqmm_summary["review_partner_review_pending"] == 0
and aqmm_summary["rework_open"] == 0
)
final_documents_released = _final_documents_released(
db, subscription_id=subscription.id, require_final_document=assurance
)
udin_completed = _udin_completed(db, subscription_id=subscription.id)
return {
"assurance": assurance,
"aqmm_task_summary": aqmm_summary,
"normal_tasks_completed": normal_tasks_done,
"aqmm_acceptance_completed": aqmm_acceptance_completed,
"aqmm_tasks_completed": aqmm_tasks_completed,
"evidence_review_completed": evidence_review_completed,
"final_documents_released": final_documents_released,
"udin_completed": udin_completed,
}
def update_engagement_closure_from_sources(
db: Session,
*,
subscription: ClientServiceSubscription,
actor_user_id: int | None = None,
) -> tuple[EngagementClosureChecklist, dict]:
row = get_or_create_engagement_closure(db, subscription=subscription, actor_user_id=actor_user_id)
summary = closure_readiness_for_subscription(db, subscription=subscription)
row.aqmm_acceptance_completed = bool(summary["aqmm_acceptance_completed"])
row.aqmm_tasks_completed = bool(summary["aqmm_tasks_completed"])
row.evidence_review_completed = bool(summary["evidence_review_completed"])
row.final_documents_released = bool(summary["final_documents_released"])
row.udin_completed = bool(summary["udin_completed"])
row.normal_tasks_completed = bool(summary["normal_tasks_completed"])
blockers: list[str] = []
if not row.normal_tasks_completed:
blockers.append("open work tracker tasks pending")
if summary["assurance"]:
if not row.aqmm_acceptance_completed:
blockers.append("AQMM acceptance not approved")
if not row.aqmm_tasks_completed:
blockers.append("mandatory AQMM tasks/evidence/reviews pending")
if not row.evidence_review_completed:
blockers.append("AQMM evidence or review notes pending")
if not row.final_documents_released:
blockers.append("final document not released")
if not row.udin_completed:
blockers.append("UDIN pending for UDIN-required document")
if not row.deliverables_sent_to_client:
blockers.append("deliverables sent to client not confirmed")
if not row.billing_reviewed:
blockers.append("billing/fee status not reviewed")
if not row.open_points_closed:
blockers.append("open points not closed")
if not row.client_communication_completed:
blockers.append("client communication not completed")
row.closure_block_reason = "; ".join(blockers) or None
if row.closure_status == CLOSURE_CLOSED:
pass
elif blockers:
row.closure_status = CLOSURE_PENDING if row.closure_status != CLOSURE_REOPENED else CLOSURE_REOPENED
else:
row.closure_status = CLOSURE_READY
return row, {**summary, "blockers": blockers}
def save_engagement_closure_confirmations(
db: Session,
*,
subscription: ClientServiceSubscription,
deliverables_sent_to_client: bool,
billing_reviewed: bool,
open_points_closed: bool,
client_communication_completed: bool,
closure_note: str | None,
actor_user_id: int,
) -> tuple[EngagementClosureChecklist, dict]:
row = get_or_create_engagement_closure(db, subscription=subscription, actor_user_id=actor_user_id)
if row.closure_status == CLOSURE_CLOSED:
raise ValueError("Closed engagement cannot be edited. Reopen it first.")
row.deliverables_sent_to_client = bool(deliverables_sent_to_client)
row.billing_reviewed = bool(billing_reviewed)
row.open_points_closed = bool(open_points_closed)
row.client_communication_completed = bool(client_communication_completed)
row.closure_note = (closure_note or "").strip() or None
return update_engagement_closure_from_sources(db, subscription=subscription, actor_user_id=actor_user_id)
def approve_engagement_closure(
db: Session,
*,
subscription: ClientServiceSubscription,
actor_user_id: int,
) -> EngagementClosureChecklist:
row, summary = update_engagement_closure_from_sources(db, subscription=subscription, actor_user_id=actor_user_id)
if summary["blockers"]:
raise ValueError(row.closure_block_reason or "Engagement closure checklist is not complete.")
now = datetime.now(timezone.utc)
row.closure_status = CLOSURE_CLOSED
row.closure_block_reason = None
row.closure_approved_by_user_id = actor_user_id
row.closure_approved_at_utc = now
subscription.status = "completed"
subscription.is_locked = True
subscription.locked_at_utc = now
subscription.locked_by_user_id = actor_user_id
subscription.updated_by_user_id = actor_user_id
return row
def reopen_engagement_closure(
db: Session,
*,
subscription: ClientServiceSubscription,
reason: str,
actor_user_id: int,
) -> EngagementClosureChecklist:
clean_reason = (reason or "").strip()
if not clean_reason:
raise ValueError("Reopen reason is required.")
row = get_or_create_engagement_closure(db, subscription=subscription, actor_user_id=actor_user_id)
now = datetime.now(timezone.utc)
row.closure_status = CLOSURE_REOPENED
row.reopened_by_user_id = actor_user_id
row.reopened_at_utc = now
row.reopen_reason = clean_reason
row.closure_approved_by_user_id = None
row.closure_approved_at_utc = None
subscription.is_locked = False
if subscription.status == "completed":
subscription.status = "active"
subscription.locked_at_utc = None
subscription.locked_by_user_id = None
subscription.updated_by_user_id = actor_user_id
return row
def _decorate_task_for_tracker(task: ClientServiceTaskInstance, *, today: date) -> ClientServiceTaskInstance:
target_date = getattr(task, "internal_target_date", None)
subscription = getattr(task, "subscription", None)