Add engagement closure checklist workflow
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user