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
+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">