Move AQMM controls to assurance engagement workflow
This commit is contained in:
@@ -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)})
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user