diff --git a/alembic/versions/20260624_phase_3_aqmm_engagement_level_rework.py b/alembic/versions/20260624_phase_3_aqmm_engagement_level_rework.py
new file mode 100644
index 0000000..b046161
--- /dev/null
+++ b/alembic/versions/20260624_phase_3_aqmm_engagement_level_rework.py
@@ -0,0 +1,157 @@
+"""phase 3 aqmm engagement level rework
+
+Revision ID: 20260624_phase_3_aqmm_engagement_level_rework
+Revises: 20260623_phase_2_1_digital_client_acceptance_workflow
+Create Date: 2026-06-24
+"""
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "20260624_phase_3_aqmm_engagement_level_rework"
+down_revision = "20260623_phase_2_1_digital_client_acceptance_workflow"
+branch_labels = None
+depends_on = None
+
+
+def _has_table(bind, table_name: str) -> bool:
+ return sa.inspect(bind).has_table(table_name)
+
+
+def _has_column(bind, table_name: str, column_name: str) -> bool:
+ if not _has_table(bind, table_name):
+ return False
+ return column_name in {c["name"] for c in sa.inspect(bind).get_columns(table_name)}
+
+
+def _add_column_once(bind, table: str, column: sa.Column) -> None:
+ if not _has_column(bind, table, column.name):
+ with op.batch_alter_table(table) as batch:
+ batch.add_column(column)
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+
+ if _has_table(bind, "client_service_subscriptions"):
+ _add_column_once(bind, "client_service_subscriptions", sa.Column("quality_workflow_required", sa.Boolean(), nullable=False, server_default=sa.false()))
+ _add_column_once(bind, "client_service_subscriptions", sa.Column("quality_workflow_status", sa.String(length=40), nullable=False, server_default="not_required"))
+ _add_column_once(bind, "client_service_subscriptions", sa.Column("quality_acceptance_status", sa.String(length=40), nullable=False, server_default="not_required"))
+ _add_column_once(bind, "client_service_subscriptions", sa.Column("quality_independence_status", sa.String(length=40), nullable=False, server_default="not_required"))
+ _add_column_once(bind, "client_service_subscriptions", sa.Column("quality_conflict_status", sa.String(length=40), nullable=False, server_default="not_required"))
+ _add_column_once(bind, "client_service_subscriptions", sa.Column("quality_kyc_status", sa.String(length=40), nullable=False, server_default="not_required"))
+ _add_column_once(bind, "client_service_subscriptions", sa.Column("quality_engagement_letter_status", sa.String(length=40), nullable=False, server_default="not_required"))
+ _add_column_once(bind, "client_service_subscriptions", sa.Column("quality_approved_by_user_id", sa.Integer(), nullable=True))
+ _add_column_once(bind, "client_service_subscriptions", sa.Column("quality_approved_at_utc", sa.DateTime(timezone=True), nullable=True))
+ _add_column_once(bind, "client_service_subscriptions", sa.Column("quality_block_reason", sa.Text(), nullable=True))
+
+ # Existing assurance engagements become pending AQMM acceptance unless already manually approved later.
+ op.execute("""
+ UPDATE client_service_subscriptions
+ SET quality_workflow_required = TRUE,
+ quality_workflow_status = 'pending_acceptance',
+ quality_acceptance_status = 'pending_acceptance',
+ quality_independence_status = 'pending_declarations',
+ quality_conflict_status = 'pending_declarations',
+ quality_kyc_status = 'pending_verification',
+ quality_engagement_letter_status = 'pending_client_acceptance',
+ quality_block_reason = 'AQMM acceptance workflow pending for assurance engagement.'
+ WHERE lower(COALESCE(engagement_type, '')) = 'assurance'
+ AND COALESCE(quality_acceptance_status, 'not_required') <> 'approved'
+ """)
+
+ if not _has_table(bind, "engagement_quality_declarations"):
+ op.create_table(
+ "engagement_quality_declarations",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("subscription_id", sa.Integer(), sa.ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("declaration_type", sa.String(length=40), nullable=False),
+ sa.Column("requested_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("requested_role", sa.String(length=80), nullable=True),
+ sa.Column("status", sa.String(length=40), nullable=False, server_default="pending"),
+ sa.Column("declaration_text", sa.Text(), nullable=True),
+ sa.Column("response_notes", sa.Text(), nullable=True),
+ sa.Column("responded_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("ip_address", sa.String(length=80), nullable=True),
+ sa.Column("user_agent", sa.String(length=500), nullable=True),
+ sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
+ sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
+ sa.UniqueConstraint("subscription_id", "declaration_type", "requested_user_id", name="uq_engagement_quality_declaration_user_type"),
+ )
+
+ if not _has_table(bind, "engagement_kyc_verifications"):
+ op.create_table(
+ "engagement_kyc_verifications",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("subscription_id", sa.Integer(), sa.ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("status", sa.String(length=40), nullable=False, server_default="pending_verification"),
+ sa.Column("source", sa.String(length=80), nullable=False, server_default="permanent_documents"),
+ sa.Column("verification_notes", sa.Text(), nullable=True),
+ sa.Column("verified_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("verified_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
+ sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
+ )
+
+ if not _has_table(bind, "engagement_letters"):
+ op.create_table(
+ "engagement_letters",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("branch_id", sa.Integer(), sa.ForeignKey("branches.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("subscription_id", sa.Integer(), sa.ForeignKey("client_service_subscriptions.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("title", sa.String(length=255), nullable=False, server_default="Engagement Letter"),
+ sa.Column("version_no", sa.Integer(), nullable=False, server_default="1"),
+ sa.Column("status", sa.String(length=50), nullable=False, server_default="draft_pending"),
+ sa.Column("acceptance_mode", sa.String(length=40), nullable=True),
+ sa.Column("body_text", sa.Text(), nullable=True),
+ sa.Column("pdf_sha256", sa.String(length=128), nullable=True),
+ sa.Column("manual_upload_filename", sa.String(length=255), nullable=True),
+ sa.Column("manual_upload_path", sa.String(length=500), nullable=True),
+ sa.Column("client_acceptance_declaration", sa.Text(), nullable=True),
+ sa.Column("partner_approved_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("partner_approved_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("client_accepted_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("client_accepted_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("manual_verified_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("manual_verified_at_utc", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("ip_address", sa.String(length=80), nullable=True),
+ sa.Column("user_agent", sa.String(length=500), nullable=True),
+ sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
+ sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
+ )
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ for table in ["engagement_letters", "engagement_kyc_verifications", "engagement_quality_declarations"]:
+ if _has_table(bind, table):
+ op.drop_table(table)
+
+ if _has_table(bind, "client_service_subscriptions"):
+ for col in [
+ "quality_block_reason",
+ "quality_approved_at_utc",
+ "quality_approved_by_user_id",
+ "quality_engagement_letter_status",
+ "quality_kyc_status",
+ "quality_conflict_status",
+ "quality_independence_status",
+ "quality_acceptance_status",
+ "quality_workflow_status",
+ "quality_workflow_required",
+ ]:
+ if _has_column(bind, "client_service_subscriptions", col):
+ with op.batch_alter_table("client_service_subscriptions") as batch:
+ batch.drop_column(col)
diff --git a/app/core/startup.py b/app/core/startup.py
index 1286ad8..7b224d4 100644
--- a/app/core/startup.py
+++ b/app/core/startup.py
@@ -17,7 +17,7 @@ from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant
from app.modules.core.tenancy.settings_models import BranchSettings
from app.modules.employees.models import Employee, EmployeeAttendance, EmployeeRegistrationRequest, EmployeeLeaveType, EmployeeLeaveBalance, EmployeeLeaveRequest, EmployeeDocumentType, EmployeeDocument, EmployeeOnboardingChecklistItem, EmployeeOnboardingTask, EmployeeOffboardingRequest, EmployeeOffboardingTask, EmployeeSalaryStructure, EmployeePayrollRun, EmployeePayslip
from app.modules.consultants.models import ClientConsultantLink, ConsultantManagedClient, ConsultantProfile, ConsultantWorkspace, ConsultantServiceRequest
-from app.modules.services.models import FirmServiceSelection, FirmServiceTaskTemplate, ServiceCatalogue, ServiceCategory, FirmTaskDocumentRequirement, FirmTaskDocumentTemplate
+from app.modules.services.models import FirmServiceSelection, FirmServiceTaskTemplate, ServiceCatalogue, ServiceCategory, FirmTaskDocumentRequirement, FirmTaskDocumentTemplate, EngagementQualityDeclaration, EngagementKycVerification, EngagementLetter
from app.modules.billing.models import BillingSettings, BillingInvoice, BillingInvoiceLine, BillingFeeGroup, BillingFeeGroupService
from app.modules.platform_billing.models import PlatformBillingAccount, PlatformInvoice, PlatformInvoiceLine, PlatformPayment, PlatformPlan, PlatformPlanFeature, PlatformSubscription
from app.modules.marketplace.models import MarketplaceLead, MarketplaceLeadAssignment
@@ -105,8 +105,6 @@ ROLE_PERMISSION_MAP = {
"clients.cross_tenant",
"clients.export",
"clients.audit_log.view",
- "clients.acceptance.manage",
- "clients.acceptance.approve",
"employees.dashboard.view",
"employees.view",
"employees.create",
@@ -229,16 +227,11 @@ ROLE_PERMISSION_MAP = {
"clients.cross_branch",
"clients.export",
"clients.audit_log.view",
- "clients.acceptance.manage",
- "clients.acceptance.approve",
"documents.view",
"documents.upload",
"documents.download",
"documents.delete",
"documents.audit.view",
- "udin.view",
- "udin.manage",
- "udin.export",
"employees.dashboard.view",
"employees.view",
"employees.create",
@@ -333,15 +326,10 @@ ROLE_PERMISSION_MAP = {
"clients.restore",
"clients.export",
"clients.audit_log.view",
- "clients.acceptance.manage",
- "clients.acceptance.approve",
"documents.view",
"documents.upload",
"documents.download",
"documents.delete",
- "udin.view",
- "udin.manage",
- "udin.export",
"clients.view.own_only",
"employees.dashboard.view",
"employees.view",
@@ -434,12 +422,9 @@ ROLE_PERMISSION_MAP = {
"clients.activate",
"clients.export",
"clients.audit_log.view",
- "clients.acceptance.manage",
- "clients.acceptance.approve",
"documents.view",
"documents.upload",
"documents.download",
- "udin.view",
"employees.dashboard.view",
"employees.view",
"employees.create",
@@ -507,7 +492,6 @@ ROLE_PERMISSION_MAP = {
"documents.view",
"documents.upload",
"documents.download",
- "udin.view",
],
"Client": [],
"Consultant": [
@@ -719,6 +703,14 @@ def on_startup(app: FastAPI) -> None:
missing_optional_tables.append(FirmServiceSelection.__table__)
if "firm_service_task_templates" not in existing_tables:
missing_optional_tables.append(FirmServiceTaskTemplate.__table__)
+ service_quality_tables = [
+ ("engagement_quality_declarations", EngagementQualityDeclaration.__table__),
+ ("engagement_kyc_verifications", EngagementKycVerification.__table__),
+ ("engagement_letters", EngagementLetter.__table__),
+ ]
+ for table_name, table in service_quality_tables:
+ if table_name not in existing_tables:
+ missing_optional_tables.append(table)
billing_tables = [
("billing_settings", BillingSettings.__table__),
("billing_fee_groups", BillingFeeGroup.__table__),
diff --git a/app/modules/clients/templates/clients/partials/form.html b/app/modules/clients/templates/clients/partials/form.html
index 1b12d46..edea78e 100644
--- a/app/modules/clients/templates/clients/partials/form.html
+++ b/app/modules/clients/templates/clients/partials/form.html
@@ -147,68 +147,20 @@
- {% set acc_status = form_data.acceptance_status or (row.acceptance_status if is_edit else 'pending_review') %}
- {% set acc_required = form_data.acceptance_required if form_data.acceptance_required is defined else (row.acceptance_required if is_edit else true) %}
- {% set ind_done = form_data.independence_check_completed if form_data.independence_check_completed is defined else (row.independence_check_completed if is_edit else false) %}
- {% set conflict_done = form_data.conflict_check_completed if form_data.conflict_check_completed is defined else (row.conflict_check_completed if is_edit else false) %}
- {% set kyc_done = form_data.kyc_completed if form_data.kyc_completed is defined else (row.kyc_completed if is_edit else false) %}
- {% set el_required = form_data.engagement_letter_required if form_data.engagement_letter_required is defined else (row.engagement_letter_required if is_edit else true) %}
- {% set el_received = form_data.engagement_letter_received if form_data.engagement_letter_received is defined else (row.engagement_letter_received if is_edit else false) %}
-
-
-
-
-
-
-
-
+ {# Client master is intentionally free of AQMM controls.
+ AQMM/quality workflow now starts only at assurance engagement creation.
+ Hidden compatibility fields prevent older backend handlers from failing
+ if Phase 2 client acceptance columns still exist in the database. #}
+
+
+
+
+
+
+
-
-
-
-
Digital Client Acceptance Workflow
-
- Independence, conflict, KYC and engagement letter controls are completed from the client detail workflow after the client is saved.
- Manual ticking is intentionally disabled on this form to preserve proper AQMM and peer review evidence.
-
-
-
{{ acc_status.replace('_',' ').title() }}
-
-
-
-
-
Independence
-
{{ 'Completed' if ind_done else 'Declaration workflow pending' }}
-
-
-
Conflict Check
-
{{ 'Completed' if conflict_done else 'Declaration workflow pending' }}
-
-
-
KYC
-
{{ 'Verified from Permanent Documents' if kyc_done else 'Permanent document verification pending' }}
-
-
-
Engagement Letter
-
- {% if not el_required %}Not required{% elif el_received %}Accepted / received{% else %}Digital OTP or manual signed upload pending{% endif %}
-
-
-
-
- {% if is_edit %}
-
- Open the client detail page to request declarations, verify KYC from permanent documents, draft engagement letter, send it to client, and approve acceptance.
-
- {% else %}
-
- Save the client first. The digital acceptance workflow will be available from the client detail page.
-
- {% endif %}
-
-
Notes
diff --git a/app/modules/core/rbac/permissions_registry.py b/app/modules/core/rbac/permissions_registry.py
index ebc0ef9..8d81e68 100644
--- a/app/modules/core/rbac/permissions_registry.py
+++ b/app/modules/core/rbac/permissions_registry.py
@@ -21,6 +21,9 @@ PERMISSIONS = {
"services.cross_tenant": "Manage Services Across Tenants",
"services.catalogue.manage": "Manage System Service Catalogue",
"services.selection.manage": "Enable or Disable Firm Services",
+ "services.aqmm.view": "View Engagement AQMM Workflow",
+ "services.aqmm.manage": "Manage Engagement AQMM Workflow",
+ "services.aqmm.approve": "Approve Engagement AQMM Workflow",
"service_tasks.view": "View Service Task Templates",
"service_tasks.create": "Create Service Task Templates",
"service_tasks.edit": "Edit Service Task Templates",
@@ -38,8 +41,6 @@ PERMISSIONS = {
"clients.cross_tenant": "Manage Clients Across Tenants",
"clients.export": "Export Clients",
"clients.audit_log.view": "View Client Audit Logs",
- "clients.acceptance.manage": "Manage Client Acceptance Controls",
- "clients.acceptance.approve": "Approve or Reject Client Acceptance",
"clients.view.own_only": "View Only Own Clients",
"employees.dashboard.view": "View HR Dashboard and Reports",
@@ -145,9 +146,6 @@ PERMISSIONS = {
"documents.download": "Download Engagement Documents",
"documents.delete": "Archive Engagement Documents",
"documents.audit.view": "View Document Access Logs",
- "udin.view": "View UDIN Register",
- "udin.manage": "Manage UDIN and Final Document Release",
- "udin.export": "Export UDIN Register",
"notice_cases.view": "View Notice and Case Management",
"notice_cases.create": "Create Notices and Cases",
diff --git a/app/modules/services/bulk_imports.py b/app/modules/services/bulk_imports.py
index 2dbe595..6ebfe38 100644
--- a/app/modules/services/bulk_imports.py
+++ b/app/modules/services/bulk_imports.py
@@ -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)})
diff --git a/app/modules/services/client_services.py b/app/modules/services/client_services.py
index 3179cb3..89963fb 100644
--- a/app/modules/services/client_services.py
+++ b/app/modules/services/client_services.py
@@ -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."
diff --git a/app/modules/services/engagements_ui.py b/app/modules/services/engagements_ui.py
index d6bdec4..0897c9d 100644
--- a/app/modules/services/engagements_ui.py
+++ b/app/modules/services/engagements_ui.py
@@ -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:
diff --git a/app/modules/services/execution.py b/app/modules/services/execution.py
index e0d51d1..1f42b9c 100644
--- a/app/modules/services/execution.py
+++ b/app/modules/services/execution.py
@@ -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(
diff --git a/app/modules/services/models.py b/app/modules/services/models.py
index 0c7dc7b..2c5c888 100644
--- a/app/modules/services/models.py
+++ b/app/modules/services/models.py
@@ -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):
diff --git a/app/modules/services/templates/services/engagements/detail.html b/app/modules/services/templates/services/engagements/detail.html
index 9093894..e493b05 100644
--- a/app/modules/services/templates/services/engagements/detail.html
+++ b/app/modules/services/templates/services/engagements/detail.html
@@ -13,6 +13,63 @@
Client & Service
Client {{ row.client.client_name if row.client else '-' }}
Service {{ row.catalogue.service_name if row.catalogue else '-' }}
Financial Year {{ row.financial_year or '-' }}
Assessment Year {{ row.assessment_year or '-' }}
Engagement Type {{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}
Original Due Date {{ row.original_due_date or '-' }}
Expiry Date {{ row.expiry_date or '-' }}
Current Due Date {{ row.current_due_date or '-' }}{% if row.due_date_source %}{{ row.due_date_source|replace('_',' ')|title }} {% endif %}
Status {{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}
Period {{ row.start_date or '-' }} to {{ row.end_date or '-' }}
Assignment
Partner {{ 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 '-') }}
Manager {{ 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 '-') }}
Staff {{ 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 '-') }}
Review Partner {{ 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 '-') }}
+
+ {% if row.quality_workflow_required %}
+
+
+
+
AQMM Engagement Quality Workflow
+
Mandatory because this is an assurance engagement. Client master creation and CSV import do not trigger AQMM; this workflow is engagement-specific.
+
+
{{ row.quality_workflow_status|replace('_',' ')|title }}
+
+
+ {% if row.quality_block_reason %}Blocked reason: {{ row.quality_block_reason }}
{% endif %}
+
+
+
Independence
{{ row.quality_independence_status|replace('_',' ')|title }}
+
Conflict
{{ row.quality_conflict_status|replace('_',' ')|title }}
+
KYC
{{ row.quality_kyc_status|replace('_',' ')|title }}
+
Engagement Letter
{{ row.quality_engagement_letter_status|replace('_',' ')|title }}
+
+
+ {% if can_manage and not row.is_locked %}
+
+
+
+
+
+
+
+
+
+ {% endif %}
+
+ {% if my_pending_declarations %}
+
+
My Pending Declarations
+
+ {% for declaration in my_pending_declarations %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if declarations %}
+
+
Declarations
+
Type User Role Status Responded {% for d in declarations %}{{ d.declaration_type|title }} {{ 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 '-') }} {{ d.requested_role or '-' }} {{ d.status|replace('_',' ')|title }} {{ d.responded_at_utc or '-' }} {% endfor %}
+
+ {% endif %}
+
+ {% endif %}
+
{% if can_manage and not row.is_locked %}Year-end Lock Lock this engagement when the year is complete. After locking, it becomes read-only history.
Lock Engagement {% endif %}