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