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
@@ -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")
+126 -1
View File
@@ -10,7 +10,13 @@ from app.core.security.session_auth import get_current_user
from app.core.templating import templates from app.core.templating import templates
from app.modules.services.models import ClientServiceSubscription, ClientServiceTaskInstance 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.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.clients.models import Client
from app.modules.services.client_services import ( from app.modules.services.client_services import (
SUBSCRIPTION_STATUSES, 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) kyc_verification = get_latest_engagement_kyc_verification(db, subscription_id=row.id)
engagement_letter = get_current_engagement_letter(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) 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( tasks = db.execute(
select(ClientServiceTaskInstance) select(ClientServiceTaskInstance)
@@ -420,6 +427,8 @@ def subscription_detail(request: Request, subscription_id: int):
kyc_verification=kyc_verification, kyc_verification=kyc_verification,
engagement_letter=engagement_letter, engagement_letter=engagement_letter,
aqmm_task_summary=aqmm_task_summary, aqmm_task_summary=aqmm_task_summary,
closure_checklist=closure_checklist,
closure_summary=closure_summary,
can_manage=_can_manage_client_services(db, user), can_manage=_can_manage_client_services(db, user),
) )
finally: finally:
@@ -578,6 +587,122 @@ def subscription_aqmm_approve(request: Request, subscription_id: int, csrf_token
db.close() 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") @router.get("/{subscription_id}/edit")
def subscription_edit_page(request: Request, subscription_id: int): def subscription_edit_page(request: Request, subscription_id: int):
db = CommonSessionLocal() db = CommonSessionLocal()
+240
View File
@@ -12,6 +12,7 @@ from app.modules.services.client_services import quality_required_for_engagement
from app.modules.services.models import ( from app.modules.services.models import (
ClientServiceSubscription, ClientServiceSubscription,
ClientServiceTaskInstance, ClientServiceTaskInstance,
EngagementClosureChecklist,
ServiceTaskComment, ServiceTaskComment,
FirmServiceTaskTemplate, FirmServiceTaskTemplate,
ServiceCatalogue, 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.") 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: def _decorate_task_for_tracker(task: ClientServiceTaskInstance, *, today: date) -> ClientServiceTaskInstance:
target_date = getattr(task, "internal_target_date", None) target_date = getattr(task, "internal_target_date", None)
subscription = getattr(task, "subscription", None) subscription = getattr(task, "subscription", None)
+55
View File
@@ -563,6 +563,61 @@ class EngagementLetter(CommonBase):
created_by = relationship("User", foreign_keys=[created_by_user_id]) 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): class ClientServiceTaskInstance(CommonBase):
"""Execution task generated from a firm service task template for a client-service subscription.""" """Execution task generated from a firm service task template for a client-service subscription."""
@@ -105,6 +105,53 @@
</section> </section>
{% endif %} {% endif %}
{% if closure_checklist %}
<section class="rounded-2xl border border-emerald-200 bg-emerald-50 p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 class="text-sm font-semibold text-emerald-950">Engagement Closure Checklist</h3>
<p class="mt-1 text-sm text-emerald-800">Closure uses existing AQMM acceptance, AQMM-tagged tasks, task evidence/review notes, final document release and UDIN controls. Only final delivery confirmations are manual.</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if closure_checklist.closure_status == 'closed' %}bg-emerald-600 text-white{% elif closure_checklist.closure_status == 'ready_for_partner_closure' %}bg-sky-100 text-sky-800{% elif closure_checklist.closure_status == 'reopened' %}bg-amber-100 text-amber-800{% else %}bg-white text-slate-700{% endif %}">{{ closure_checklist.closure_status|replace('_',' ')|title }}</span>
</div>
{% if closure_checklist.closure_block_reason %}<div class="mt-3 rounded-xl border border-amber-200 bg-white px-4 py-3 text-sm text-amber-800">Closure pending: {{ closure_checklist.closure_block_reason }}</div>{% endif %}
<div class="mt-4 grid gap-3 md:grid-cols-3 lg:grid-cols-6">
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">Tasks</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.normal_tasks_completed else 'text-amber-700' }}">{{ 'Completed' if closure_checklist.normal_tasks_completed else 'Pending' }}</div></div>
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">AQMM Acceptance</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.aqmm_acceptance_completed else 'text-amber-700' }}">{{ 'Completed' if closure_checklist.aqmm_acceptance_completed else 'Pending' }}</div></div>
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">AQMM Tasks</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.aqmm_tasks_completed else 'text-amber-700' }}">{{ 'Completed' if closure_checklist.aqmm_tasks_completed else 'Pending' }}</div></div>
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">Evidence / Reviews</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.evidence_review_completed else 'text-amber-700' }}">{{ 'Completed' if closure_checklist.evidence_review_completed else 'Pending' }}</div></div>
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">Final Release</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.final_documents_released else 'text-amber-700' }}">{{ 'Completed' if closure_checklist.final_documents_released else 'Pending' }}</div></div>
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">UDIN</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.udin_completed else 'text-amber-700' }}">{{ 'Completed' if closure_checklist.udin_completed else 'Pending' }}</div></div>
</div>
<form method="post" action="/services/engagements/{{ row.id }}/closure/update" class="mt-5 rounded-2xl bg-white p-4 border border-emerald-100">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h4 class="text-sm font-semibold text-slate-900">Manual Closure Confirmations</h4>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<label class="flex items-center gap-2 rounded-xl border border-slate-200 px-3 py-2 text-sm"><input type="checkbox" name="deliverables_sent_to_client" {% if closure_checklist.deliverables_sent_to_client %}checked{% endif %} {% if row.is_locked and closure_checklist.closure_status == 'closed' %}disabled{% endif %}> Deliverables sent to client</label>
<label class="flex items-center gap-2 rounded-xl border border-slate-200 px-3 py-2 text-sm"><input type="checkbox" name="billing_reviewed" {% if closure_checklist.billing_reviewed %}checked{% endif %} {% if row.is_locked and closure_checklist.closure_status == 'closed' %}disabled{% endif %}> Billing / fee status reviewed</label>
<label class="flex items-center gap-2 rounded-xl border border-slate-200 px-3 py-2 text-sm"><input type="checkbox" name="open_points_closed" {% if closure_checklist.open_points_closed %}checked{% endif %} {% if row.is_locked and closure_checklist.closure_status == 'closed' %}disabled{% endif %}> Open points / rework items closed</label>
<label class="flex items-center gap-2 rounded-xl border border-slate-200 px-3 py-2 text-sm"><input type="checkbox" name="client_communication_completed" {% if closure_checklist.client_communication_completed %}checked{% endif %} {% if row.is_locked and closure_checklist.closure_status == 'closed' %}disabled{% endif %}> Client communication completed</label>
</div>
<textarea name="closure_note" rows="3" placeholder="Partner closure note / file completion remarks" class="mt-3 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" {% if row.is_locked and closure_checklist.closure_status == 'closed' %}disabled{% endif %}>{{ closure_checklist.closure_note or '' }}</textarea>
{% if can_manage and closure_checklist.closure_status != 'closed' %}<div class="mt-3 flex flex-wrap gap-2"><button class="rounded-xl border border-emerald-300 bg-white px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">Save Closure Confirmations</button></div>{% endif %}
</form>
{% if can_manage %}
<div class="mt-4 flex flex-wrap gap-2">
{% if closure_checklist.closure_status != 'closed' %}
<form method="post" action="/services/engagements/{{ row.id }}/closure/sync"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="rounded-xl border border-emerald-300 bg-white px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">Recalculate Closure Readiness</button></form>
<form method="post" action="/services/engagements/{{ row.id }}/closure/approve"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700">Approve Closure & Lock</button></form>
{% else %}
<form method="post" action="/services/engagements/{{ row.id }}/closure/reopen" class="flex flex-wrap gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input name="reason" required placeholder="Reason to reopen" class="rounded-xl border border-slate-300 px-3 py-2 text-sm"><button class="rounded-xl bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700">Reopen Engagement</button></form>
{% endif %}
</div>
{% endif %}
</section>
{% endif %}
{% if can_manage and not row.is_locked %}<form method="post" action="/services/engagements/{{ row.id }}/lock" class="rounded-2xl border border-amber-200 bg-white p-5 shadow-soft"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><h3 class="text-sm font-semibold text-slate-900">Year-end Lock</h3><p class="mt-2 text-sm text-slate-600">Lock this engagement when the year is complete. After locking, it becomes read-only history.</p><button class="mt-4 rounded-xl bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700">Lock Engagement</button></form>{% endif %} {% if can_manage and not row.is_locked %}<form method="post" action="/services/engagements/{{ row.id }}/lock" class="rounded-2xl border border-amber-200 bg-white p-5 shadow-soft"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><h3 class="text-sm font-semibold text-slate-900">Year-end Lock</h3><p class="mt-2 text-sm text-slate-600">Lock this engagement when the year is complete. After locking, it becomes read-only history.</p><button class="mt-4 rounded-xl bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700">Lock Engagement</button></form>{% endif %}
<section class="overflow-hidden rounded-2xl bg-white shadow-soft"> <section class="overflow-hidden rounded-2xl bg-white shadow-soft">