From c3b11fb9fcf6f5681647cf00e0d9ac669a8d6e22 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Tue, 7 Jul 2026 14:18:30 +0530 Subject: [PATCH] Add engagement closure checklist workflow --- ...27_phase_5_engagement_closure_checklist.py | 77 ++++++ app/modules/services/engagements_ui.py | 127 ++++++++- app/modules/services/execution.py | 240 ++++++++++++++++++ app/modules/services/models.py | 55 ++++ .../services/engagements/detail.html | 47 ++++ 5 files changed, 545 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/20260627_phase_5_engagement_closure_checklist.py diff --git a/alembic/versions/20260627_phase_5_engagement_closure_checklist.py b/alembic/versions/20260627_phase_5_engagement_closure_checklist.py new file mode 100644 index 0000000..de73df0 --- /dev/null +++ b/alembic/versions/20260627_phase_5_engagement_closure_checklist.py @@ -0,0 +1,77 @@ +"""phase 5 engagement closure checklist + +Revision ID: 20260627_phase_5_engagement_closure_checklist +Revises: 20260626_phase_4_aqmm_task_evidence_review_notes +Create Date: 2026-06-27 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260627_phase_5_engagement_closure_checklist" +down_revision = "20260626_phase_4_aqmm_task_evidence_review_notes" +branch_labels = None +depends_on = None + + +def _has_table(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + bind = op.get_bind() + if not _has_table(bind, "engagement_closure_checklists"): + op.create_table( + "engagement_closure_checklists", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("branch_id", sa.Integer(), nullable=True), + sa.Column("client_id", sa.Integer(), nullable=False), + sa.Column("subscription_id", sa.Integer(), nullable=False), + sa.Column("closure_status", sa.String(length=40), nullable=False, server_default="not_started"), + sa.Column("aqmm_acceptance_completed", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("aqmm_tasks_completed", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("evidence_review_completed", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("final_documents_released", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("udin_completed", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("normal_tasks_completed", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("deliverables_sent_to_client", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("billing_reviewed", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("open_points_closed", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("client_communication_completed", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("closure_note", sa.Text(), nullable=True), + sa.Column("closure_block_reason", sa.Text(), nullable=True), + sa.Column("closure_approved_by_user_id", sa.Integer(), nullable=True), + sa.Column("closure_approved_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("reopened_by_user_id", sa.Integer(), nullable=True), + sa.Column("reopened_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("reopen_reason", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["branch_id"], ["branches.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["client_id"], ["clients.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["subscription_id"], ["client_service_subscriptions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["closure_approved_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["reopened_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"), + sa.UniqueConstraint("subscription_id", name="uq_engagement_closure_subscription"), + ) + op.create_index("ix_engagement_closure_tenant", "engagement_closure_checklists", ["tenant_id"]) + op.create_index("ix_engagement_closure_branch", "engagement_closure_checklists", ["branch_id"]) + op.create_index("ix_engagement_closure_client", "engagement_closure_checklists", ["client_id"]) + op.create_index("ix_engagement_closure_subscription", "engagement_closure_checklists", ["subscription_id"]) + op.create_index("ix_engagement_closure_status", "engagement_closure_checklists", ["closure_status"]) + + +def downgrade() -> None: + bind = op.get_bind() + if _has_table(bind, "engagement_closure_checklists"): + op.drop_index("ix_engagement_closure_status", table_name="engagement_closure_checklists") + op.drop_index("ix_engagement_closure_subscription", table_name="engagement_closure_checklists") + op.drop_index("ix_engagement_closure_client", table_name="engagement_closure_checklists") + op.drop_index("ix_engagement_closure_branch", table_name="engagement_closure_checklists") + op.drop_index("ix_engagement_closure_tenant", table_name="engagement_closure_checklists") + op.drop_table("engagement_closure_checklists") diff --git a/app/modules/services/engagements_ui.py b/app/modules/services/engagements_ui.py index a3841aa..97f52d0 100644 --- a/app/modules/services/engagements_ui.py +++ b/app/modules/services/engagements_ui.py @@ -10,7 +10,13 @@ from app.core.security.session_auth import get_current_user from app.core.templating import templates from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance from app.modules.services.due_dates import apply_due_date_rule_to_subscription -from app.modules.services.execution import aqmm_task_summary_for_subscription +from app.modules.services.execution import ( + aqmm_task_summary_for_subscription, + update_engagement_closure_from_sources, + save_engagement_closure_confirmations, + approve_engagement_closure, + reopen_engagement_closure, +) from app.modules.clients.models import Client from app.modules.services.client_services import ( SUBSCRIPTION_STATUSES, @@ -400,6 +406,7 @@ def subscription_detail(request: Request, subscription_id: int): kyc_verification = get_latest_engagement_kyc_verification(db, subscription_id=row.id) engagement_letter = get_current_engagement_letter(db, subscription_id=row.id) aqmm_task_summary = aqmm_task_summary_for_subscription(db, subscription_id=row.id) + closure_checklist, closure_summary = update_engagement_closure_from_sources(db, subscription=row, actor_user_id=user.id) tasks = db.execute( select(ClientServiceTaskInstance) @@ -420,6 +427,8 @@ def subscription_detail(request: Request, subscription_id: int): kyc_verification=kyc_verification, engagement_letter=engagement_letter, aqmm_task_summary=aqmm_task_summary, + closure_checklist=closure_checklist, + closure_summary=closure_summary, can_manage=_can_manage_client_services(db, user), ) finally: @@ -578,6 +587,122 @@ def subscription_aqmm_approve(request: Request, subscription_id: int, csrf_token db.close() +@router.post("/{subscription_id}/closure/sync") +def subscription_closure_sync(request: Request, subscription_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + 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.view") + except Exception: + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id) + if row: + update_engagement_closure_from_sources(db, subscription=row, actor_user_id=user.id) + db.commit() + return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303) + finally: + db.close() + + +@router.post("/{subscription_id}/closure/update") +def subscription_closure_update( + request: Request, + subscription_id: int, + deliverables_sent_to_client: str | None = Form(None), + billing_reviewed: str | None = Form(None), + open_points_closed: str | None = Form(None), + client_communication_completed: str | None = Form(None), + closure_note: str = Form(""), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + 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) + row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id) + if row: + try: + save_engagement_closure_confirmations( + db, + subscription=row, + deliverables_sent_to_client=deliverables_sent_to_client is not None, + billing_reviewed=billing_reviewed is not None, + open_points_closed=open_points_closed is not None, + client_communication_completed=client_communication_completed is not None, + closure_note=closure_note, + actor_user_id=user.id, + ) + db.commit() + except ValueError: + db.rollback() + return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303) + finally: + db.close() + + +@router.post("/{subscription_id}/closure/approve") +def subscription_closure_approve(request: Request, subscription_id: int, csrf_token: str = Form(...)): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_lock_engagements(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id) + if row: + try: + approve_engagement_closure(db, subscription=row, actor_user_id=user.id) + db.commit() + except ValueError: + db.rollback() + return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303) + finally: + db.close() + + +@router.post("/{subscription_id}/closure/reopen") +def subscription_closure_reopen( + request: Request, + subscription_id: int, + reason: str = Form(""), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user = get_current_user(request, db=db) + if not user: + return RedirectResponse(url="/login", status_code=303) + if not _can_lock_engagements(db, user): + return _redirect_denied() + tenant_id = _active_tenant_id(request, user) + row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id) + if row: + try: + reopen_engagement_closure(db, subscription=row, reason=reason, actor_user_id=user.id) + db.commit() + except ValueError: + db.rollback() + return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303) + finally: + db.close() + + @router.get("/{subscription_id}/edit") def subscription_edit_page(request: Request, subscription_id: int): db = CommonSessionLocal() diff --git a/app/modules/services/execution.py b/app/modules/services/execution.py index a8614f0..3714cfa 100644 --- a/app/modules/services/execution.py +++ b/app/modules/services/execution.py @@ -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) diff --git a/app/modules/services/models.py b/app/modules/services/models.py index d6ec03c..eeb542f 100644 --- a/app/modules/services/models.py +++ b/app/modules/services/models.py @@ -563,6 +563,61 @@ class EngagementLetter(CommonBase): created_by = relationship("User", foreign_keys=[created_by_user_id]) +class EngagementClosureChecklist(CommonBase): + """Engagement closure checklist attached to the existing engagement. + + Closure is the final file-completion gate. It summarises evidence already + available in AQMM acceptance, AQMM-tagged tasks, task documents/reviews, + final released documents and UDIN controls. Only a few closure confirmations + are manual; the rest are calculated from the existing workflow. + """ + + __tablename__ = "engagement_closure_checklists" + __table_args__ = ( + UniqueConstraint("subscription_id", name="uq_engagement_closure_subscription"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + subscription_id: Mapped[int] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False, index=True) + + closure_status: Mapped[str] = mapped_column(String(40), nullable=False, default="not_started", index=True) + + aqmm_acceptance_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + aqmm_tasks_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + evidence_review_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + final_documents_released: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + udin_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + normal_tasks_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + deliverables_sent_to_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + billing_reviewed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + open_points_closed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + client_communication_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + closure_note: Mapped[str | None] = mapped_column(Text, nullable=True) + closure_block_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + + closure_approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + closure_approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + reopened_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + reopened_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + reopen_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + + subscription = relationship("ClientServiceSubscription") + client = relationship("Client") + closure_approved_by = relationship("User", foreign_keys=[closure_approved_by_user_id]) + reopened_by = relationship("User", foreign_keys=[reopened_by_user_id]) + created_by = relationship("User", foreign_keys=[created_by_user_id]) + + class ClientServiceTaskInstance(CommonBase): """Execution task generated from a firm service task template for a client-service subscription.""" diff --git a/app/modules/services/templates/services/engagements/detail.html b/app/modules/services/templates/services/engagements/detail.html index a58b4a4..a761d3b 100644 --- a/app/modules/services/templates/services/engagements/detail.html +++ b/app/modules/services/templates/services/engagements/detail.html @@ -105,6 +105,53 @@ {% endif %} + {% if closure_checklist %} +
+
+
+

Engagement Closure Checklist

+

Closure uses existing AQMM acceptance, AQMM-tagged tasks, task evidence/review notes, final document release and UDIN controls. Only final delivery confirmations are manual.

+
+ {{ closure_checklist.closure_status|replace('_',' ')|title }} +
+ + {% if closure_checklist.closure_block_reason %}
Closure pending: {{ closure_checklist.closure_block_reason }}
{% endif %} + +
+
Tasks
{{ 'Completed' if closure_checklist.normal_tasks_completed else 'Pending' }}
+
AQMM Acceptance
{{ 'Completed' if closure_checklist.aqmm_acceptance_completed else 'Pending' }}
+
AQMM Tasks
{{ 'Completed' if closure_checklist.aqmm_tasks_completed else 'Pending' }}
+
Evidence / Reviews
{{ 'Completed' if closure_checklist.evidence_review_completed else 'Pending' }}
+
Final Release
{{ 'Completed' if closure_checklist.final_documents_released else 'Pending' }}
+
UDIN
{{ 'Completed' if closure_checklist.udin_completed else 'Pending' }}
+
+ +
+ +

Manual Closure Confirmations

+
+ + + + +
+ + {% if can_manage and closure_checklist.closure_status != 'closed' %}
{% endif %} +
+ + {% if can_manage %} +
+ {% if closure_checklist.closure_status != 'closed' %} +
+
+ {% else %} +
+ {% endif %} +
+ {% endif %} +
+ {% endif %} + {% if can_manage and not row.is_locked %}

Year-end Lock

Lock this engagement when the year is complete. After locking, it becomes read-only history.

{% endif %}