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 %} -
-
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 %} +
+ +
{{ declaration.declaration_type|title }} Declaration
+ +
+
+ {% endfor %} +
+
+ {% endif %} + + {% if declarations %} +
+
Declarations
+ {% for d in declarations %}{% endfor %}
TypeUserRoleStatusResponded
{{ 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 '-' }}
+
+ {% 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.

{% endif %}