Move AQMM controls to assurance engagement workflow

This commit is contained in:
A R R R Associates
2026-07-07 10:29:27 +05:30
parent cd355d6fc5
commit 05c882304b
10 changed files with 943 additions and 82 deletions
@@ -147,68 +147,20 @@
<input name="country" value="{{ form_data.country or (row.country if is_edit else 'India') }}" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
</div>
{% set acc_status = form_data.acceptance_status or (row.acceptance_status if is_edit else 'pending_review') %}
{% set acc_required = form_data.acceptance_required if form_data.acceptance_required is defined else (row.acceptance_required if is_edit else true) %}
{% set ind_done = form_data.independence_check_completed if form_data.independence_check_completed is defined else (row.independence_check_completed if is_edit else false) %}
{% set conflict_done = form_data.conflict_check_completed if form_data.conflict_check_completed is defined else (row.conflict_check_completed if is_edit else false) %}
{% set kyc_done = form_data.kyc_completed if form_data.kyc_completed is defined else (row.kyc_completed if is_edit else false) %}
{% set el_required = form_data.engagement_letter_required if form_data.engagement_letter_required is defined else (row.engagement_letter_required if is_edit else true) %}
{% set el_received = form_data.engagement_letter_received if form_data.engagement_letter_received is defined else (row.engagement_letter_received if is_edit else false) %}
<input type="hidden" name="acceptance_status" value="{{ acc_status }}">
<input type="hidden" name="acceptance_required" value="{{ '1' if acc_required else '0' }}">
<input type="hidden" name="independence_check_completed" value="{{ '1' if ind_done else '0' }}">
<input type="hidden" name="conflict_check_completed" value="{{ '1' if conflict_done else '0' }}">
<input type="hidden" name="kyc_completed" value="{{ '1' if kyc_done else '0' }}">
<input type="hidden" name="engagement_letter_required" value="{{ '1' if el_required else '0' }}">
<input type="hidden" name="engagement_letter_received" value="{{ '1' if el_received else '0' }}">
{# Client master is intentionally free of AQMM controls.
AQMM/quality workflow now starts only at assurance engagement creation.
Hidden compatibility fields prevent older backend handlers from failing
if Phase 2 client acceptance columns still exist in the database. #}
<input type="hidden" name="acceptance_status" value="{{ form_data.acceptance_status or (row.acceptance_status if is_edit else 'pending_review') }}">
<input type="hidden" name="acceptance_required" value="0">
<input type="hidden" name="independence_check_completed" value="0">
<input type="hidden" name="conflict_check_completed" value="0">
<input type="hidden" name="kyc_completed" value="0">
<input type="hidden" name="engagement_letter_required" value="0">
<input type="hidden" name="engagement_letter_received" value="0">
<input type="hidden" name="acceptance_review_notes" value="{{ form_data.acceptance_review_notes or (row.acceptance_review_notes if is_edit else '') }}">
<input type="hidden" name="acceptance_rejection_reason" value="{{ form_data.acceptance_rejection_reason or (row.acceptance_rejection_reason if is_edit else '') }}">
<div class="md:col-span-2 rounded-2xl border border-sky-200 bg-sky-50 p-4">
<div class="flex items-start justify-between gap-3">
<div>
<h4 class="text-sm font-semibold text-sky-950">Digital Client Acceptance Workflow</h4>
<p class="mt-1 text-xs text-sky-800">
Independence, conflict, KYC and engagement letter controls are completed from the client detail workflow after the client is saved.
Manual ticking is intentionally disabled on this form to preserve proper AQMM and peer review evidence.
</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if acc_status == 'approved' %}bg-emerald-100 text-emerald-700{% elif acc_status == 'rejected' %}bg-rose-100 text-rose-700{% else %}bg-amber-100 text-amber-800{% endif %}">{{ acc_status.replace('_',' ').title() }}</span>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2 text-sm text-slate-700">
<div class="rounded-xl bg-white/80 px-3 py-2 border border-sky-100">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Independence</div>
<div class="mt-1">{{ 'Completed' if ind_done else 'Declaration workflow pending' }}</div>
</div>
<div class="rounded-xl bg-white/80 px-3 py-2 border border-sky-100">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Conflict Check</div>
<div class="mt-1">{{ 'Completed' if conflict_done else 'Declaration workflow pending' }}</div>
</div>
<div class="rounded-xl bg-white/80 px-3 py-2 border border-sky-100">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">KYC</div>
<div class="mt-1">{{ 'Verified from Permanent Documents' if kyc_done else 'Permanent document verification pending' }}</div>
</div>
<div class="rounded-xl bg-white/80 px-3 py-2 border border-sky-100">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Engagement Letter</div>
<div class="mt-1">
{% if not el_required %}Not required{% elif el_received %}Accepted / received{% else %}Digital OTP or manual signed upload pending{% endif %}
</div>
</div>
</div>
{% if is_edit %}
<p class="mt-3 text-xs text-slate-600">
Open the client detail page to request declarations, verify KYC from permanent documents, draft engagement letter, send it to client, and approve acceptance.
</p>
{% else %}
<p class="mt-3 text-xs text-slate-600">
Save the client first. The digital acceptance workflow will be available from the client detail page.
</p>
{% endif %}
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-700">Notes</label>
<textarea name="notes" rows="4" class="mt-1 w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">{{ form_data.notes or (row.notes if is_edit else '') }}</textarea>
@@ -21,6 +21,9 @@ PERMISSIONS = {
"services.cross_tenant": "Manage Services Across Tenants",
"services.catalogue.manage": "Manage System Service Catalogue",
"services.selection.manage": "Enable or Disable Firm Services",
"services.aqmm.view": "View Engagement AQMM Workflow",
"services.aqmm.manage": "Manage Engagement AQMM Workflow",
"services.aqmm.approve": "Approve Engagement AQMM Workflow",
"service_tasks.view": "View Service Task Templates",
"service_tasks.create": "Create Service Task Templates",
"service_tasks.edit": "Edit Service Task Templates",
@@ -38,8 +41,6 @@ PERMISSIONS = {
"clients.cross_tenant": "Manage Clients Across Tenants",
"clients.export": "Export Clients",
"clients.audit_log.view": "View Client Audit Logs",
"clients.acceptance.manage": "Manage Client Acceptance Controls",
"clients.acceptance.approve": "Approve or Reject Client Acceptance",
"clients.view.own_only": "View Only Own Clients",
"employees.dashboard.view": "View HR Dashboard and Reports",
@@ -145,9 +146,6 @@ PERMISSIONS = {
"documents.download": "Download Engagement Documents",
"documents.delete": "Archive Engagement Documents",
"documents.audit.view": "View Document Access Logs",
"udin.view": "View UDIN Register",
"udin.manage": "Manage UDIN and Final Document Release",
"udin.export": "Export UDIN Register",
"notice_cases.view": "View Notice and Case Management",
"notice_cases.create": "Create Notices and Cases",
+3 -1
View File
@@ -23,7 +23,7 @@ from app.modules.services.models import (
ServiceDueDateRule,
)
from app.modules.services.services import normalize_code, normalize_engagement_type
from app.modules.services.client_services import assessment_year_from_financial_year, normalize_financial_year, review_partner_required_for_engagement
from app.modules.services.client_services import assessment_year_from_financial_year, normalize_financial_year, review_partner_required_for_engagement, ensure_engagement_quality_workflow, enforce_quality_gate_on_subscription
from app.modules.services.due_dates import apply_due_date_rule_to_subscription, create_due_date_extension
TRUE_VALUES = {"1", "true", "yes", "y", "on"}
@@ -811,6 +811,8 @@ def import_client_service_assignments(
sub.remarks = _clean(_cell(row, headers, "remarks")) or None
sub.updated_by_user_id = current_user.id
apply_due_date_rule_to_subscription(db, sub)
ensure_engagement_quality_workflow(db, subscription=sub, actor_user_id=current_user.id, create_declarations=False)
enforce_quality_gate_on_subscription(sub)
except Exception as exc:
errors.append({"row": row_no, "message": str(exc)})
+390
View File
@@ -13,6 +13,7 @@ from app.modules.services.models import ClientServiceSubscription, FirmServiceSe
SUBSCRIPTION_STATUSES = [
("draft", "Draft"),
("pending_acceptance", "Pending AQMM Acceptance"),
("active", "Active"),
("on_hold", "On Hold"),
("completed", "Completed"),
@@ -204,3 +205,392 @@ def review_partner_required_for_engagement(db: Session, *, tenant_id: int, engag
def list_review_partners(db: Session, *, tenant_id: int, branch_id: int | None = None):
return list_assignable_users(db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Partner",))
# -----------------------------------------------------------------------------
# AQMM engagement-level quality workflow helpers
# -----------------------------------------------------------------------------
from datetime import datetime, timezone
from app.modules.alerts.service import create_alert
from app.modules.services.models import EngagementQualityDeclaration, EngagementKycVerification, EngagementLetter
QUALITY_NOT_REQUIRED = "not_required"
QUALITY_PENDING = "pending_acceptance"
QUALITY_IN_PROGRESS = "in_progress"
QUALITY_READY = "ready_for_approval"
QUALITY_APPROVED = "approved"
QUALITY_REJECTED = "rejected"
DECLARATION_PENDING = "pending"
DECLARATION_CLEAR = "declared_clear"
DECLARATION_CONFLICT = "conflict_declared"
DECLARATION_NOT_APPLICABLE = "not_applicable"
def is_assurance_engagement(engagement_type: str | None) -> bool:
return (engagement_type or "").strip().lower() == "assurance"
def quality_required_for_engagement(engagement_type: str | None) -> bool:
return is_assurance_engagement(engagement_type)
def _engagement_team_user_ids(subscription: ClientServiceSubscription) -> list[int]:
ids = [
subscription.assigned_partner_user_id,
subscription.assigned_manager_user_id,
subscription.assigned_staff_user_id,
subscription.review_partner_user_id,
]
seen: set[int] = set()
result: list[int] = []
for uid in ids:
if uid and int(uid) not in seen:
seen.add(int(uid))
result.append(int(uid))
return result
def _role_for_engagement_user(subscription: ClientServiceSubscription, user_id: int) -> str | None:
if subscription.assigned_partner_user_id == user_id:
return "Assigned Partner"
if subscription.review_partner_user_id == user_id:
return "Review Partner"
if subscription.assigned_manager_user_id == user_id:
return "Assigned Manager"
if subscription.assigned_staff_user_id == user_id:
return "Assigned Staff"
return None
def ensure_engagement_quality_workflow(
db: Session,
*,
subscription: ClientServiceSubscription,
actor_user_id: int | None = None,
create_declarations: bool = False,
) -> ClientServiceSubscription:
"""Initialise or sync AQMM status for an engagement/subscription.
Full quality workflow is mandatory only for assurance engagements. For
non-assurance engagements, the quality fields are reset to not_required.
"""
required = quality_required_for_engagement(subscription.engagement_type)
subscription.quality_workflow_required = required
if not required:
subscription.quality_workflow_status = QUALITY_NOT_REQUIRED
subscription.quality_acceptance_status = QUALITY_NOT_REQUIRED
subscription.quality_independence_status = QUALITY_NOT_REQUIRED
subscription.quality_conflict_status = QUALITY_NOT_REQUIRED
subscription.quality_kyc_status = QUALITY_NOT_REQUIRED
subscription.quality_engagement_letter_status = QUALITY_NOT_REQUIRED
subscription.quality_block_reason = None
return subscription
if subscription.quality_workflow_status in (None, "", QUALITY_NOT_REQUIRED):
subscription.quality_workflow_status = QUALITY_PENDING
if subscription.quality_acceptance_status in (None, "", QUALITY_NOT_REQUIRED):
subscription.quality_acceptance_status = QUALITY_PENDING
if subscription.quality_independence_status in (None, "", QUALITY_NOT_REQUIRED):
subscription.quality_independence_status = "pending_declarations"
if subscription.quality_conflict_status in (None, "", QUALITY_NOT_REQUIRED):
subscription.quality_conflict_status = "pending_declarations"
if subscription.quality_kyc_status in (None, "", QUALITY_NOT_REQUIRED):
subscription.quality_kyc_status = "pending_verification"
if subscription.quality_engagement_letter_status in (None, "", QUALITY_NOT_REQUIRED):
subscription.quality_engagement_letter_status = "pending_client_acceptance"
if create_declarations:
request_engagement_quality_declarations(db, subscription=subscription, actor_user_id=actor_user_id)
update_engagement_quality_summary(db, subscription=subscription)
return subscription
def request_engagement_quality_declarations(
db: Session,
*,
subscription: ClientServiceSubscription,
actor_user_id: int | None = None,
) -> list[EngagementQualityDeclaration]:
ensure_engagement_quality_workflow(db, subscription=subscription, actor_user_id=actor_user_id, create_declarations=False)
created_or_existing: list[EngagementQualityDeclaration] = []
target_url = f"/services/engagements/{subscription.id}"
for user_id in _engagement_team_user_ids(subscription):
role = _role_for_engagement_user(subscription, user_id)
for declaration_type in ("independence", "conflict"):
existing = db.execute(
select(EngagementQualityDeclaration).where(
EngagementQualityDeclaration.subscription_id == subscription.id,
EngagementQualityDeclaration.declaration_type == declaration_type,
EngagementQualityDeclaration.requested_user_id == user_id,
)
).scalar_one_or_none()
if existing:
created_or_existing.append(existing)
continue
row = EngagementQualityDeclaration(
tenant_id=subscription.tenant_id,
branch_id=subscription.branch_id,
client_id=subscription.client_id,
subscription_id=subscription.id,
declaration_type=declaration_type,
requested_user_id=user_id,
requested_role=role,
status=DECLARATION_PENDING,
created_by_user_id=actor_user_id,
)
db.add(row)
db.flush()
created_or_existing.append(row)
try:
create_alert(
db,
user_id=user_id,
tenant_id=subscription.tenant_id,
branch_id=subscription.branch_id,
alert_type="general",
priority="high" if declaration_type == "conflict" else "normal",
title=f"AQMM {declaration_type.title()} Declaration Required",
message=f"Please submit your {declaration_type} declaration for this assurance engagement.",
target_url=target_url,
created_by_user_id=actor_user_id,
commit=False,
)
except Exception:
pass
update_engagement_quality_summary(db, subscription=subscription)
return created_or_existing
def list_engagement_quality_declarations(db: Session, *, subscription_id: int) -> list[EngagementQualityDeclaration]:
return db.execute(
select(EngagementQualityDeclaration)
.where(EngagementQualityDeclaration.subscription_id == subscription_id)
.order_by(EngagementQualityDeclaration.declaration_type.asc(), EngagementQualityDeclaration.id.asc())
).scalars().all()
def respond_engagement_quality_declaration(
db: Session,
*,
declaration_id: int,
current_user_id: int,
status: str,
notes: str | None = None,
request=None,
) -> EngagementQualityDeclaration:
row = db.get(EngagementQualityDeclaration, declaration_id)
if not row or int(row.requested_user_id) != int(current_user_id):
raise ValueError("Declaration not found for current user.")
if status not in {DECLARATION_CLEAR, DECLARATION_CONFLICT, DECLARATION_NOT_APPLICABLE}:
raise ValueError("Invalid declaration status.")
row.status = status
row.response_notes = (notes or "").strip() or None
row.responded_at_utc = datetime.now(timezone.utc)
if request is not None:
row.ip_address = getattr(getattr(request, "client", None), "host", None)
row.user_agent = request.headers.get("user-agent") if hasattr(request, "headers") else None
subscription = db.get(ClientServiceSubscription, row.subscription_id)
if subscription:
update_engagement_quality_summary(db, subscription=subscription)
return row
def get_latest_engagement_kyc_verification(db: Session, *, subscription_id: int) -> EngagementKycVerification | None:
return db.execute(
select(EngagementKycVerification)
.where(EngagementKycVerification.subscription_id == subscription_id)
.order_by(EngagementKycVerification.id.desc())
).scalars().first()
def verify_engagement_kyc_from_permanent_documents(
db: Session,
*,
subscription: ClientServiceSubscription,
actor_user_id: int,
notes: str | None = None,
) -> EngagementKycVerification:
ensure_engagement_quality_workflow(db, subscription=subscription, actor_user_id=actor_user_id, create_declarations=False)
row = get_latest_engagement_kyc_verification(db, subscription_id=subscription.id)
if not row:
row = EngagementKycVerification(
tenant_id=subscription.tenant_id,
branch_id=subscription.branch_id,
client_id=subscription.client_id,
subscription_id=subscription.id,
created_by_user_id=actor_user_id,
)
db.add(row)
row.status = "verified"
row.source = "permanent_documents"
row.verification_notes = (notes or "").strip() or "Verified from permanent document vault."
row.verified_by_user_id = actor_user_id
row.verified_at_utc = datetime.now(timezone.utc)
subscription.quality_kyc_status = "verified"
update_engagement_quality_summary(db, subscription=subscription)
return row
def get_current_engagement_letter(db: Session, *, subscription_id: int) -> EngagementLetter | None:
return db.execute(
select(EngagementLetter)
.where(EngagementLetter.subscription_id == subscription_id)
.order_by(EngagementLetter.version_no.desc(), EngagementLetter.id.desc())
).scalars().first()
def mark_engagement_letter_completed(
db: Session,
*,
subscription: ClientServiceSubscription,
actor_user_id: int,
acceptance_mode: str,
notes: str | None = None,
request=None,
) -> EngagementLetter:
ensure_engagement_quality_workflow(db, subscription=subscription, actor_user_id=actor_user_id, create_declarations=False)
if acceptance_mode not in {"digital_otp", "manual_signed_upload"}:
raise ValueError("Invalid engagement letter acceptance mode.")
letter = get_current_engagement_letter(db, subscription_id=subscription.id)
if not letter:
letter = EngagementLetter(
tenant_id=subscription.tenant_id,
branch_id=subscription.branch_id,
client_id=subscription.client_id,
subscription_id=subscription.id,
title="Engagement Letter",
version_no=1,
status="draft_pending",
created_by_user_id=actor_user_id,
)
db.add(letter)
letter.status = "digitally_accepted" if acceptance_mode == "digital_otp" else "manual_signed_verified"
letter.acceptance_mode = acceptance_mode
letter.client_acceptance_declaration = (notes or "").strip() or None
letter.client_accepted_by_user_id = actor_user_id if acceptance_mode == "digital_otp" else letter.client_accepted_by_user_id
letter.client_accepted_at_utc = datetime.now(timezone.utc) if acceptance_mode == "digital_otp" else letter.client_accepted_at_utc
letter.manual_verified_by_user_id = actor_user_id if acceptance_mode == "manual_signed_upload" else letter.manual_verified_by_user_id
letter.manual_verified_at_utc = datetime.now(timezone.utc) if acceptance_mode == "manual_signed_upload" else letter.manual_verified_at_utc
if request is not None:
letter.ip_address = getattr(getattr(request, "client", None), "host", None)
letter.user_agent = request.headers.get("user-agent") if hasattr(request, "headers") else None
subscription.quality_engagement_letter_status = "completed"
update_engagement_quality_summary(db, subscription=subscription)
return letter
def update_engagement_quality_summary(db: Session, *, subscription: ClientServiceSubscription) -> ClientServiceSubscription:
if not quality_required_for_engagement(subscription.engagement_type):
subscription.quality_workflow_required = False
subscription.quality_workflow_status = QUALITY_NOT_REQUIRED
subscription.quality_acceptance_status = QUALITY_NOT_REQUIRED
subscription.quality_independence_status = QUALITY_NOT_REQUIRED
subscription.quality_conflict_status = QUALITY_NOT_REQUIRED
subscription.quality_kyc_status = QUALITY_NOT_REQUIRED
subscription.quality_engagement_letter_status = QUALITY_NOT_REQUIRED
subscription.quality_block_reason = None
return subscription
subscription.quality_workflow_required = True
declarations = list_engagement_quality_declarations(db, subscription_id=subscription.id) if subscription.id else []
independence = [d for d in declarations if d.declaration_type == "independence"]
conflicts = [d for d in declarations if d.declaration_type == "conflict"]
if independence:
if any(d.status == DECLARATION_PENDING for d in independence):
subscription.quality_independence_status = "pending_declarations"
elif any(d.status == DECLARATION_CONFLICT for d in independence):
subscription.quality_independence_status = "issue_reported"
else:
subscription.quality_independence_status = "completed"
else:
subscription.quality_independence_status = "pending_declarations"
if conflicts:
if any(d.status == DECLARATION_PENDING for d in conflicts):
subscription.quality_conflict_status = "pending_declarations"
elif any(d.status == DECLARATION_CONFLICT for d in conflicts):
subscription.quality_conflict_status = "conflict_reported"
else:
subscription.quality_conflict_status = "clear"
else:
subscription.quality_conflict_status = "pending_declarations"
kyc = get_latest_engagement_kyc_verification(db, subscription_id=subscription.id) if subscription.id else None
if kyc and kyc.status == "verified":
subscription.quality_kyc_status = "verified"
elif subscription.quality_kyc_status not in {"verified", "not_required"}:
subscription.quality_kyc_status = "pending_verification"
letter = get_current_engagement_letter(db, subscription_id=subscription.id) if subscription.id else None
if letter and letter.status in {"digitally_accepted", "manual_signed_verified", "completed"}:
subscription.quality_engagement_letter_status = "completed"
elif subscription.quality_engagement_letter_status not in {"completed", "not_required"}:
subscription.quality_engagement_letter_status = "pending_client_acceptance"
ready = (
subscription.quality_independence_status == "completed"
and subscription.quality_conflict_status == "clear"
and subscription.quality_kyc_status == "verified"
and subscription.quality_engagement_letter_status == "completed"
)
if subscription.quality_acceptance_status == QUALITY_APPROVED:
subscription.quality_workflow_status = QUALITY_APPROVED
subscription.quality_block_reason = None
elif ready:
subscription.quality_workflow_status = QUALITY_READY
subscription.quality_acceptance_status = QUALITY_READY
subscription.quality_block_reason = None
else:
subscription.quality_workflow_status = QUALITY_IN_PROGRESS
subscription.quality_acceptance_status = QUALITY_PENDING
blockers = []
if subscription.quality_independence_status != "completed":
blockers.append("independence declarations pending/issue")
if subscription.quality_conflict_status != "clear":
blockers.append("conflict declarations pending/conflict")
if subscription.quality_kyc_status != "verified":
blockers.append("KYC verification pending")
if subscription.quality_engagement_letter_status != "completed":
blockers.append("engagement letter acceptance pending")
subscription.quality_block_reason = "; ".join(blockers) or None
return subscription
def approve_engagement_quality_workflow(
db: Session,
*,
subscription: ClientServiceSubscription,
actor_user_id: int,
) -> ClientServiceSubscription:
ensure_engagement_quality_workflow(db, subscription=subscription, actor_user_id=actor_user_id, create_declarations=False)
update_engagement_quality_summary(db, subscription=subscription)
if subscription.quality_workflow_status != QUALITY_READY:
raise ValueError(subscription.quality_block_reason or "AQMM workflow is not ready for approval.")
subscription.quality_acceptance_status = QUALITY_APPROVED
subscription.quality_workflow_status = QUALITY_APPROVED
subscription.quality_approved_by_user_id = actor_user_id
subscription.quality_approved_at_utc = datetime.now(timezone.utc)
subscription.quality_block_reason = None
subscription.status = "active"
subscription.is_active = True
return subscription
def enforce_quality_gate_on_subscription(subscription: ClientServiceSubscription) -> None:
"""Prevent assurance engagement from becoming active before AQMM approval."""
if quality_required_for_engagement(subscription.engagement_type) and subscription.quality_acceptance_status != QUALITY_APPROVED:
subscription.quality_workflow_required = True
if subscription.quality_workflow_status in (None, "", QUALITY_NOT_REQUIRED):
subscription.quality_workflow_status = QUALITY_PENDING
if subscription.status == "active":
subscription.status = "pending_acceptance"
subscription.is_active = False
if not subscription.quality_block_reason:
subscription.quality_block_reason = "AQMM acceptance workflow pending for assurance engagement."
+177
View File
@@ -26,6 +26,16 @@ from app.modules.services.client_services import (
list_review_partners,
parse_date,
review_partner_required_for_engagement,
ensure_engagement_quality_workflow,
enforce_quality_gate_on_subscription,
request_engagement_quality_declarations,
list_engagement_quality_declarations,
respond_engagement_quality_declaration,
verify_engagement_kyc_from_permanent_documents,
get_latest_engagement_kyc_verification,
get_current_engagement_letter,
mark_engagement_letter_completed,
approve_engagement_quality_workflow,
)
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission
@@ -300,6 +310,8 @@ def subscription_create_submit(
row.is_active = is_active is not None
row.updated_by_user_id = user.id
apply_due_date_rule_to_subscription(db, row)
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(row)
db.commit()
db.refresh(row)
@@ -380,6 +392,13 @@ def subscription_detail(request: Request, subscription_id: int):
if row.financial_year != active_fy:
return RedirectResponse(url=f"/services/engagements?financial_year={active_fy}", status_code=303)
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
db.flush()
declarations = list_engagement_quality_declarations(db, subscription_id=row.id)
my_pending_declarations = [d for d in declarations if d.requested_user_id == user.id and d.status == "pending"]
kyc_verification = get_latest_engagement_kyc_verification(db, subscription_id=row.id)
engagement_letter = get_current_engagement_letter(db, subscription_id=row.id)
tasks = db.execute(
select(ClientServiceTaskInstance)
.where(ClientServiceTaskInstance.subscription_id == row.id)
@@ -394,12 +413,168 @@ def subscription_detail(request: Request, subscription_id: int):
title="Engagement Subscription",
row=row,
tasks=tasks,
declarations=declarations,
my_pending_declarations=my_pending_declarations,
kyc_verification=kyc_verification,
engagement_letter=engagement_letter,
can_manage=_can_manage_client_services(db, user),
)
finally:
db.close()
@router.post("/{subscription_id}/aqmm/initiate")
def subscription_aqmm_initiate(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.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:
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=True)
enforce_quality_gate_on_subscription(row)
db.commit()
return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303)
finally:
db.close()
@router.post("/{subscription_id}/aqmm/request-declarations")
def subscription_aqmm_request_declarations(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.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:
request_engagement_quality_declarations(db, subscription=row, actor_user_id=user.id)
enforce_quality_gate_on_subscription(row)
db.commit()
return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303)
finally:
db.close()
@router.post("/{subscription_id}/aqmm/declarations/{declaration_id}/respond")
def subscription_aqmm_declaration_respond(
request: Request,
subscription_id: int,
declaration_id: int,
status: str = Form(...),
notes: 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)
respond_engagement_quality_declaration(
db,
declaration_id=declaration_id,
current_user_id=user.id,
status=status,
notes=notes,
request=request,
)
db.commit()
return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303)
finally:
db.close()
@router.post("/{subscription_id}/aqmm/kyc/verify")
def subscription_aqmm_kyc_verify(request: Request, subscription_id: int, notes: 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:
verify_engagement_kyc_from_permanent_documents(db, subscription=row, actor_user_id=user.id, notes=notes)
enforce_quality_gate_on_subscription(row)
db.commit()
return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303)
finally:
db.close()
@router.post("/{subscription_id}/aqmm/engagement-letter/complete")
def subscription_aqmm_engagement_letter_complete(
request: Request,
subscription_id: int,
acceptance_mode: str = Form(...),
notes: 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:
mark_engagement_letter_completed(db, subscription=row, actor_user_id=user.id, acceptance_mode=acceptance_mode, notes=notes, request=request)
enforce_quality_gate_on_subscription(row)
db.commit()
return RedirectResponse(url=f"/services/engagements/{subscription_id}", status_code=303)
finally:
db.close()
@router.post("/{subscription_id}/aqmm/approve")
def subscription_aqmm_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)
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:
approve_engagement_quality_workflow(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.get("/{subscription_id}/edit")
def subscription_edit_page(request: Request, subscription_id: int):
db = CommonSessionLocal()
@@ -503,6 +678,8 @@ def subscription_edit_submit(
row.is_active = is_active is not None
row.updated_by_user_id = user.id
apply_due_date_rule_to_subscription(db, row)
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(row)
db.commit()
return RedirectResponse(url=f"/services/engagements/{row.id}", status_code=303)
finally:
+4
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session, selectinload
from app.modules.clients.models import Client
from app.modules.core.iam.models import User
from app.modules.services.client_services import quality_required_for_engagement, QUALITY_APPROVED
from app.modules.services.models import (
ClientServiceSubscription,
ClientServiceTaskInstance,
@@ -178,6 +179,9 @@ def _default_internal_target_date(subscription: ClientServiceSubscription, templ
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.")
templates = db.execute(
select(FirmServiceTaskTemplate)
.where(
+132
View File
@@ -386,6 +386,21 @@ class ClientServiceSubscription(CommonBase):
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True)
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
# AQMM / engagement-level quality workflow. This is intentionally attached
# to the engagement/subscription, not to the client master, because one
# client can have both assurance and non-assurance services in the same FY.
quality_workflow_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
quality_workflow_status: Mapped[str] = mapped_column(String(40), nullable=False, default="not_required", index=True)
quality_acceptance_status: Mapped[str] = mapped_column(String(40), nullable=False, default="not_required", index=True)
quality_independence_status: Mapped[str] = mapped_column(String(40), nullable=False, default="not_required", index=True)
quality_conflict_status: Mapped[str] = mapped_column(String(40), nullable=False, default="not_required", index=True)
quality_kyc_status: Mapped[str] = mapped_column(String(40), nullable=False, default="not_required", index=True)
quality_engagement_letter_status: Mapped[str] = mapped_column(String(40), nullable=False, default="not_required", index=True)
quality_approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
quality_approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
quality_block_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -405,6 +420,123 @@ class ClientServiceSubscription(CommonBase):
assigned_staff = relationship("User", foreign_keys=[assigned_staff_user_id])
review_partner = relationship("User", foreign_keys=[review_partner_user_id])
locked_by = relationship("User", foreign_keys=[locked_by_user_id])
quality_approved_by = relationship("User", foreign_keys=[quality_approved_by_user_id])
class EngagementQualityDeclaration(CommonBase):
"""Digital AQMM declaration attached to an assurance engagement.
Used for independence and conflict confirmations from the assigned partner,
review partner, manager, staff and any other engagement team member.
"""
__tablename__ = "engagement_quality_declarations"
__table_args__ = (
UniqueConstraint(
"subscription_id",
"declaration_type",
"requested_user_id",
name="uq_engagement_quality_declaration_user_type",
),
)
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)
declaration_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True) # independence / conflict
requested_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
requested_role: Mapped[str | None] = mapped_column(String(80), nullable=True)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
declaration_text: Mapped[str | None] = mapped_column(Text, nullable=True)
response_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
responded_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(80), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(500), 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")
requested_user = relationship("User", foreign_keys=[requested_user_id])
created_by = relationship("User", foreign_keys=[created_by_user_id])
class EngagementKycVerification(CommonBase):
"""KYC verification for an assurance engagement using existing permanent documents."""
__tablename__ = "engagement_kyc_verifications"
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)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending_verification", index=True)
source: Mapped[str] = mapped_column(String(80), nullable=False, default="permanent_documents")
verification_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
verified_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
verified_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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")
verified_by = relationship("User", foreign_keys=[verified_by_user_id])
created_by = relationship("User", foreign_keys=[created_by_user_id])
class EngagementLetter(CommonBase):
"""Engagement-specific letter acceptance evidence.
Supports both client OTP acceptance and manual signed PDF upload/verification.
File upload storage can be extended without changing the AQMM gate fields.
"""
__tablename__ = "engagement_letters"
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)
title: Mapped[str] = mapped_column(String(255), nullable=False, default="Engagement Letter")
version_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
status: Mapped[str] = mapped_column(String(50), nullable=False, default="draft_pending", index=True)
acceptance_mode: Mapped[str | None] = mapped_column(String(40), nullable=True) # digital_otp / manual_signed_upload
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
pdf_sha256: Mapped[str | None] = mapped_column(String(128), nullable=True)
manual_upload_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
manual_upload_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
client_acceptance_declaration: Mapped[str | None] = mapped_column(Text, nullable=True)
partner_approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
partner_approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
client_accepted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
client_accepted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
manual_verified_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
manual_verified_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(80), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(500), 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")
partner_approved_by = relationship("User", foreign_keys=[partner_approved_by_user_id])
client_accepted_by = relationship("User", foreign_keys=[client_accepted_by_user_id])
manual_verified_by = relationship("User", foreign_keys=[manual_verified_by_user_id])
created_by = relationship("User", foreign_keys=[created_by_user_id])
class ClientServiceTaskInstance(CommonBase):
@@ -13,6 +13,63 @@
<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Client & Service</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Client</dt><dd class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</dd></div><div><dt class="text-slate-500">Service</dt><dd class="font-medium text-slate-900">{{ row.catalogue.service_name if row.catalogue else '-' }}</dd></div><div><dt class="text-slate-500">Financial Year</dt><dd>{{ row.financial_year or '-' }}</dd></div><div><dt class="text-slate-500">Assessment Year</dt><dd>{{ row.assessment_year or '-' }}</dd></div><div><dt class="text-slate-500">Engagement Type</dt><dd>{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}</dd></div><div><dt class="text-slate-500">Original Due Date</dt><dd>{{ row.original_due_date or '-' }}</dd></div><div><dt class="text-slate-500">Expiry Date</dt><dd>{{ row.expiry_date or '-' }}</dd></div><div><dt class="text-slate-500">Current Due Date</dt><dd class="font-medium text-slate-900">{{ row.current_due_date or '-' }}{% if row.due_date_source %}<span class="ml-2 rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{ row.due_date_source|replace('_',' ')|title }}</span>{% endif %}</dd></div><div><dt class="text-slate-500">Status</dt><dd>{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}</dd></div><div><dt class="text-slate-500">Period</dt><dd>{{ row.start_date or '-' }} to {{ row.end_date or '-' }}</dd></div></dl></section>
<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Assignment</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Partner</dt><dd>{{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}</dd></div><div><dt class="text-slate-500">Manager</dt><dd>{{ row.assigned_manager.full_name if row.assigned_manager and row.assigned_manager.full_name else (row.assigned_manager.email if row.assigned_manager else '-') }}</dd></div><div><dt class="text-slate-500">Staff</dt><dd>{{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}</dd></div><div><dt class="text-slate-500">Review Partner</dt><dd>{{ row.review_partner.full_name if row.review_partner and row.review_partner.full_name else (row.review_partner.email if row.review_partner else '-') }}</dd></div></dl></section>
</div>
{% if row.quality_workflow_required %}
<section class="rounded-2xl border border-indigo-200 bg-indigo-50 p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 class="text-sm font-semibold text-indigo-950">AQMM Engagement Quality Workflow</h3>
<p class="mt-1 text-sm text-indigo-800">Mandatory because this is an assurance engagement. Client master creation and CSV import do not trigger AQMM; this workflow is engagement-specific.</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if row.quality_acceptance_status == 'approved' %}bg-emerald-100 text-emerald-700{% elif row.quality_workflow_status == 'ready_for_approval' %}bg-sky-100 text-sky-700{% else %}bg-amber-100 text-amber-800{% endif %}">{{ row.quality_workflow_status|replace('_',' ')|title }}</span>
</div>
{% if row.quality_block_reason %}<div class="mt-3 rounded-xl border border-amber-200 bg-white/80 px-4 py-3 text-sm text-amber-800">Blocked reason: {{ row.quality_block_reason }}</div>{% endif %}
<div class="mt-4 grid gap-3 md:grid-cols-4">
<div class="rounded-xl bg-white px-4 py-3 border border-indigo-100"><div class="text-xs font-semibold uppercase text-slate-500">Independence</div><div class="mt-1 text-sm font-medium text-slate-900">{{ row.quality_independence_status|replace('_',' ')|title }}</div></div>
<div class="rounded-xl bg-white px-4 py-3 border border-indigo-100"><div class="text-xs font-semibold uppercase text-slate-500">Conflict</div><div class="mt-1 text-sm font-medium text-slate-900">{{ row.quality_conflict_status|replace('_',' ')|title }}</div></div>
<div class="rounded-xl bg-white px-4 py-3 border border-indigo-100"><div class="text-xs font-semibold uppercase text-slate-500">KYC</div><div class="mt-1 text-sm font-medium text-slate-900">{{ row.quality_kyc_status|replace('_',' ')|title }}</div></div>
<div class="rounded-xl bg-white px-4 py-3 border border-indigo-100"><div class="text-xs font-semibold uppercase text-slate-500">Engagement Letter</div><div class="mt-1 text-sm font-medium text-slate-900">{{ row.quality_engagement_letter_status|replace('_',' ')|title }}</div></div>
</div>
{% if can_manage and not row.is_locked %}
<div class="mt-5 flex flex-wrap gap-2">
<form method="post" action="/services/engagements/{{ row.id }}/aqmm/initiate"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">Initiate / Sync AQMM</button></form>
<form method="post" action="/services/engagements/{{ row.id }}/aqmm/request-declarations"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="rounded-xl border border-indigo-300 bg-white px-4 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50">Request Declarations</button></form>
<form method="post" action="/services/engagements/{{ row.id }}/aqmm/kyc/verify" class="flex gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input name="notes" placeholder="KYC notes" class="rounded-xl border border-indigo-200 px-3 py-2 text-sm"><button class="rounded-xl border border-indigo-300 bg-white px-4 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50">Verify KYC</button></form>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<form method="post" action="/services/engagements/{{ row.id }}/aqmm/engagement-letter/complete" class="flex gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><select name="acceptance_mode" class="rounded-xl border border-indigo-200 px-3 py-2 text-sm"><option value="digital_otp">Digital OTP accepted</option><option value="manual_signed_upload">Manual signed copy verified</option></select><input name="notes" placeholder="Letter evidence note" class="rounded-xl border border-indigo-200 px-3 py-2 text-sm"><button class="rounded-xl border border-indigo-300 bg-white px-4 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50">Complete Engagement Letter</button></form>
<form method="post" action="/services/engagements/{{ row.id }}/aqmm/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 AQMM & Activate</button></form>
</div>
{% endif %}
{% if my_pending_declarations %}
<div class="mt-5 rounded-2xl border border-white bg-white p-4">
<h4 class="text-sm font-semibold text-slate-900">My Pending Declarations</h4>
<div class="mt-3 space-y-3">
{% for declaration in my_pending_declarations %}
<form method="post" action="/services/engagements/{{ row.id }}/aqmm/declarations/{{ declaration.id }}/respond" class="rounded-xl border border-slate-200 p-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="text-sm font-medium text-slate-900">{{ declaration.declaration_type|title }} Declaration</div>
<textarea name="notes" rows="2" placeholder="Declaration remarks / safeguards if any" class="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea>
<div class="mt-2 flex flex-wrap gap-2"><button name="status" value="declared_clear" class="rounded-xl bg-emerald-600 px-3 py-2 text-xs font-medium text-white">Declare Clear</button><button name="status" value="conflict_declared" class="rounded-xl bg-rose-600 px-3 py-2 text-xs font-medium text-white">Report Issue / Conflict</button><button name="status" value="not_applicable" class="rounded-xl border border-slate-300 px-3 py-2 text-xs font-medium text-slate-700">Not Applicable</button></div>
</form>
{% endfor %}
</div>
</div>
{% endif %}
{% if declarations %}
<div class="mt-5 overflow-hidden rounded-2xl bg-white border border-indigo-100">
<div class="border-b border-slate-100 px-4 py-3 text-sm font-semibold text-slate-900">Declarations</div>
<table class="min-w-full divide-y divide-slate-200 text-sm"><thead class="bg-slate-50"><tr><th class="px-3 py-2 text-left">Type</th><th class="px-3 py-2 text-left">User</th><th class="px-3 py-2 text-left">Role</th><th class="px-3 py-2 text-left">Status</th><th class="px-3 py-2 text-left">Responded</th></tr></thead><tbody class="divide-y divide-slate-100">{% for d in declarations %}<tr><td class="px-3 py-2">{{ d.declaration_type|title }}</td><td class="px-3 py-2">{{ d.requested_user.full_name if d.requested_user and d.requested_user.full_name else (d.requested_user.email if d.requested_user else '-') }}</td><td class="px-3 py-2">{{ d.requested_role or '-' }}</td><td class="px-3 py-2">{{ d.status|replace('_',' ')|title }}</td><td class="px-3 py-2">{{ d.responded_at_utc or '-' }}</td></tr>{% endfor %}</tbody></table>
</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 %}
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">