Align client onboarding documents and engagement AQMM workflow

This commit is contained in:
A R R R Associates
2026-07-11 22:20:26 +05:30
parent a9dda197ad
commit c36f419923
4 changed files with 223 additions and 180 deletions
+15 -170
View File
@@ -82,184 +82,29 @@
</section> </section>
<section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2"> <section class="rounded-2xl bg-white p-6 shadow-soft xl:col-span-2">
{% set acc_status = row.acceptance_status or 'pending_review' %} <div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="flex items-start justify-between gap-3">
<div> <div>
<h3 class="text-base font-semibold text-slate-900">Client Acceptance Controls</h3> <h3 class="text-base font-semibold text-slate-900">Client Onboarding & Permanent Documents</h3>
<p class="mt-1 text-sm text-slate-500">Embedded AQMM / peer-review evidence for acceptance, independence, conflict, KYC and engagement letter controls.</p> <p class="mt-1 text-sm text-slate-500">Client-master onboarding is limited to legal/KYC records and permanent documents. Engagement-specific AQMM formalities are managed from each engagement according to its existing Assurance / Non-Assurance classification.</p>
</div> </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> <span class="shrink-0 rounded-full px-3 py-1 text-xs font-semibold {% if permanent_document_count %}bg-emerald-100 text-emerald-700{% else %}bg-amber-100 text-amber-800{% endif %}">
{{ permanent_document_count }} permanent document{{ '' if permanent_document_count == 1 else 's' }}
</span>
</div> </div>
<div class="mt-4 grid gap-3 md:grid-cols-2"> <div class="mt-5 grid gap-4 md:grid-cols-2">
{% for label, ok in [ <div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
('Acceptance Required', row.acceptance_required), <h4 class="text-sm font-semibold text-slate-900">Permanent KYC Documents</h4>
('Independence Check', row.independence_check_completed), <p class="mt-1 text-sm text-slate-600">Upload PAN, registration/constitution records, authorised-person KYC, bank proof and other standing documents. The checklist adapts to the clients legal constitution.</p>
('Conflict Check', row.conflict_check_completed), <a href="/documents/permanent/clients/{{ row.id }}?onboarding=1" class="mt-4 inline-flex rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Open Onboarding Documents</a>
('KYC Completed', row.kyc_completed),
('Engagement Letter Required', row.engagement_letter_required),
('Engagement Letter Received', row.engagement_letter_received)
] %}
<div class="rounded-xl border border-slate-200 px-4 py-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ label }}</div>
<div class="mt-1 text-sm font-medium {% if ok %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ 'Yes' if ok else 'No' }}</div>
</div>
{% endfor %}
<div class="rounded-xl border border-slate-200 px-4 py-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Approved By</div>
<div class="mt-1 text-sm text-slate-800">{{ row.acceptance_approved_by_user_id or '-' }}</div>
</div>
<div class="rounded-xl border border-slate-200 px-4 py-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Approved At</div>
<div class="mt-1 text-sm text-slate-800">{{ row.acceptance_approved_at_utc or '-' }}</div>
</div>
</div>
{% if row.acceptance_review_notes %}
<div class="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
<div class="font-semibold text-slate-900">Review Notes</div>
<div class="mt-1 whitespace-pre-wrap">{{ row.acceptance_review_notes }}</div>
</div>
{% endif %}
{% if row.acceptance_rejection_reason %}
<div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">
<div class="font-semibold text-rose-900">Rejection / Remediation Notes</div>
<div class="mt-1 whitespace-pre-wrap">{{ row.acceptance_rejection_reason }}</div>
</div>
{% endif %}
{% if can_approve_acceptance or can_manage_acceptance %}
<div class="mt-5 grid gap-3 md:grid-cols-3">
{% if can_approve_acceptance %}
<form method="post" action="/clients/{{ row.id }}/acceptance/approve" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="acceptance_review_notes" rows="2" placeholder="Approval notes" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ row.acceptance_review_notes or '' }}</textarea>
<button class="w-full rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Approve Acceptance</button>
</form>
<form method="post" action="/clients/{{ row.id }}/acceptance/reject" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="acceptance_rejection_reason" rows="2" placeholder="Rejection/remediation reason" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ row.acceptance_rejection_reason or '' }}</textarea>
<button class="w-full rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-700">Reject</button>
</form>
{% endif %}
{% if can_manage_acceptance %}
<form method="post" action="/clients/{{ row.id }}/acceptance/pending" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="acceptance_review_notes" rows="2" placeholder="Pending review notes" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ row.acceptance_review_notes or '' }}</textarea>
<button class="w-full rounded-xl border border-amber-300 px-4 py-2 text-sm font-semibold text-amber-800 hover:bg-amber-50">Move to Pending</button>
</form>
{% endif %}
</div>
{% endif %}
<div class="mt-6 rounded-2xl border border-indigo-100 bg-indigo-50/50 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h4 class="text-sm font-semibold uppercase tracking-wide text-indigo-900">Digital Acceptance Workflow</h4>
<p class="mt-1 text-sm text-indigo-800">Declarations, KYC from permanent documents and engagement letter acceptance are captured as audit evidence.</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if workflow_ready %}bg-emerald-100 text-emerald-700{% else %}bg-amber-100 text-amber-800{% endif %}">{{ 'Ready for Approval' if workflow_ready else 'Pending Evidence' }}</span>
</div> </div>
<div class="mt-4 grid gap-4 lg:grid-cols-3"> <div class="rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
<div class="rounded-xl border border-white bg-white p-4 shadow-sm"> <h4 class="text-sm font-semibold text-indigo-950">AQMM at Engagement Level</h4>
<div class="flex items-center justify-between gap-2"> <p class="mt-1 text-sm text-indigo-800">Create or open an engagement to complete the applicable acceptance, independence, conflict, engagement-letter, evidence, review and closure requirements. The existing engagement classification remains the single source of truth.</p>
<h5 class="text-sm font-semibold text-slate-900">Independence / Conflict Declarations</h5> <a href="/services/engagements/new?client_id={{ row.id }}" class="mt-4 inline-flex rounded-xl border border-indigo-300 bg-white px-4 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-100">Assign Service / Create Engagement</a>
{% if can_manage_acceptance %}
<form method="post" action="/clients/{{ row.id }}/acceptance/request-declarations">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700">Request</button>
</form>
{% endif %}
</div>
<div class="mt-3 space-y-2">
{% if acceptance_declarations %}
{% for d in acceptance_declarations %}
<div class="rounded-lg border border-slate-200 p-3 text-xs">
<div class="font-semibold text-slate-800">{{ d.declaration_type.replace('_',' ').title() }} · User #{{ d.assigned_user_id }}</div>
<div class="mt-1 {% if d.status == 'declared_clear' %}text-emerald-700{% elif d.status == 'declared_issue' %}text-rose-700{% else %}text-amber-700{% endif %}">{{ d.status.replace('_',' ').title() }}</div>
{% if d.assigned_user_id == current_user.id and d.status == 'pending' %}
<form method="post" action="/clients/{{ row.id }}/acceptance/declarations/{{ d.id }}/submit" class="mt-2 space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<select name="declaration_result" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs">
<option value="clear">Declare Clear / No Issue</option>
<option value="issue">Declare Issue / Conflict</option>
</select>
<textarea name="response_notes" rows="2" placeholder="Notes" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
<textarea name="issue_details" rows="2" placeholder="Issue details if any" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
<button class="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white">Submit Declaration</button>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="text-xs text-slate-500">No declaration requests have been created yet.</div>
{% endif %}
</div>
</div>
<div class="rounded-xl border border-white bg-white p-4 shadow-sm">
<div class="flex items-center justify-between gap-2">
<h5 class="text-sm font-semibold text-slate-900">KYC from Permanent Documents</h5>
{% if can_manage_acceptance %}
<form method="post" action="/clients/{{ row.id }}/acceptance/kyc/sync">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">Sync</button>
</form>
{% endif %}
</div>
<div class="mt-3 text-xs text-slate-600">Permanent documents found: <span class="font-semibold">{{ permanent_document_count }}</span></div>
<div class="mt-2 rounded-lg bg-slate-50 p-3 text-xs text-slate-600 whitespace-pre-wrap max-h-40 overflow-auto">{{ permanent_document_summary or 'No permanent document summary available. Click Sync after client uploads permanent documents.' }}</div>
{% if kyc_verification %}
<div class="mt-3 text-xs font-semibold {% if kyc_verification.status == 'verified' %}text-emerald-700{% elif kyc_verification.status == 'rejected' %}text-rose-700{% else %}text-amber-700{% endif %}">KYC Status: {{ kyc_verification.status.replace('_',' ').title() }}</div>
{% endif %}
{% if can_manage_acceptance or can_approve_acceptance %}
<div class="mt-3 grid gap-2">
<form method="post" action="/clients/{{ row.id }}/acceptance/kyc/verify" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="verification_notes" rows="2" placeholder="KYC verification notes" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
<button class="w-full rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Verify KYC</button>
</form>
<form method="post" action="/clients/{{ row.id }}/acceptance/kyc/reject" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="verification_notes" rows="2" placeholder="Resubmission remarks" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
<button class="w-full rounded-lg border border-rose-300 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-50">Reject / Request Resubmission</button>
</form>
</div>
{% endif %}
</div>
<div class="rounded-xl border border-white bg-white p-4 shadow-sm">
<h5 class="text-sm font-semibold text-slate-900">Engagement Letter</h5>
{% if engagement_letter %}
<div class="mt-2 text-xs text-slate-600">Version {{ engagement_letter.version_no }} · <span class="font-semibold">{{ engagement_letter.status.replace('_',' ').title() }}</span></div>
<a href="/clients/{{ row.id }}/acceptance/engagement-letter/{{ engagement_letter.id }}/download" target="_blank" class="mt-3 inline-flex rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-semibold text-slate-700 hover:bg-slate-50">View / Print PDF</a>
{% if can_approve_acceptance and engagement_letter.status == 'draft' %}
<form method="post" action="/clients/{{ row.id }}/acceptance/engagement-letter/{{ engagement_letter.id }}/approve-send" class="mt-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="w-full rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-700">Partner Approve & Send</button>
</form>
{% endif %}
{% if (can_manage_acceptance or can_approve_acceptance) and engagement_letter.status == 'manual_uploaded' %}
<form method="post" action="/clients/{{ row.id }}/acceptance/engagement-letter/{{ engagement_letter.id }}/verify-manual" class="mt-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="w-full rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Verify Manual Signed Copy</button>
</form>
{% endif %}
{% else %}
<div class="mt-2 text-xs text-slate-500">No engagement letter drafted yet.</div>
{% endif %}
{% if can_manage_acceptance %}
<form method="post" action="/clients/{{ row.id }}/acceptance/engagement-letter/draft" class="mt-4 space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input name="title" placeholder="Letter title" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs" value="Engagement Letter - {{ row.client_name }}">
<textarea name="body_text" rows="4" placeholder="Leave blank to use standard engagement letter draft" class="w-full rounded-lg border border-slate-300 px-2 py-1 text-xs"></textarea>
<button class="w-full rounded-lg border border-indigo-300 px-3 py-1.5 text-xs font-semibold text-indigo-700 hover:bg-indigo-50">Create New Draft</button>
</form>
{% endif %}
</div>
</div> </div>
</div> </div>
</section> </section>
<section class="rounded-2xl bg-white p-6 shadow-soft"> <section class="rounded-2xl bg-white p-6 shadow-soft">
+10 -8
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, File, Form, Request, UploadFile from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import func, select
from app.core.db.common import CommonSessionLocal from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
@@ -74,6 +75,7 @@ from app.modules.billing.client_portal_service import (
) )
from app.modules.billing.services import build_invoice_print_context, create_cashfree_transaction, create_payumoney_transaction, process_cashfree_return, process_cashfree_webhook, process_payumoney_response from app.modules.billing.services import build_invoice_print_context, create_cashfree_transaction, create_payumoney_transaction, process_cashfree_return, process_cashfree_webhook, process_payumoney_response
from app.modules.email_integration.services import send_auth_otp_email from app.modules.email_integration.services import send_auth_otp_email
from app.modules.documents.models import PermanentClientDocument
from app.modules.documents.services import ( from app.modules.documents.services import (
get_permanent_version, get_permanent_version,
get_version, get_version,
@@ -623,7 +625,7 @@ async def client_create(request: Request):
portal_password=(form.get("portal_password") or "").strip() or None, portal_password=(form.get("portal_password") or "").strip() or None,
portal_password_confirm=(form.get("portal_password_confirm") or "").strip() or None, portal_password_confirm=(form.get("portal_password_confirm") or "").strip() or None,
) )
return RedirectResponse(url=f"/clients/{row.id}", status_code=303) return RedirectResponse(url=f"/documents/permanent/clients/{row.id}?onboarding=1", status_code=303)
except Exception as exc: except Exception as exc:
return _render( return _render(
request, request,
@@ -662,7 +664,12 @@ def client_detail(request: Request, client_id: int):
return _redirect_denied() return _redirect_denied()
audit_logs = list_client_audit_logs(db, row=type("Tmp", (), {"id": row["id"]})(), limit=10) if has("clients.audit_log.view") else [] audit_logs = list_client_audit_logs(db, row=type("Tmp", (), {"id": row["id"]})(), limit=10) if has("clients.audit_log.view") else []
workflow = build_client_acceptance_workflow_payload(db, client_id=client_id) permanent_document_count = db.execute(
select(func.count(PermanentClientDocument.id)).where(
PermanentClientDocument.client_id == client_id,
PermanentClientDocument.is_deleted.is_(False),
)
).scalar_one()
return _render( return _render(
request, request,
"modules/clients/templates/clients/detail.html", "modules/clients/templates/clients/detail.html",
@@ -671,12 +678,7 @@ def client_detail(request: Request, client_id: int):
title=f"Client • {row['client_name']}", title=f"Client • {row['client_name']}",
row=row, row=row,
audit_logs=audit_logs, audit_logs=audit_logs,
acceptance_declarations=workflow.get("acceptance_declarations", []), permanent_document_count=permanent_document_count,
kyc_verification=workflow.get("kyc_verification"),
engagement_letter=workflow.get("engagement_letter"),
permanent_document_summary=workflow.get("permanent_document_summary"),
permanent_document_count=workflow.get("permanent_document_count", 0),
workflow_ready=workflow.get("workflow_ready", False),
scope=scope, scope=scope,
can_edit=has("clients.edit"), can_edit=has("clients.edit"),
can_deactivate=has("clients.deactivate"), can_deactivate=has("clients.deactivate"),
@@ -12,13 +12,59 @@
</div> </div>
</div> </div>
{% if not request.query_params.get('onboarding') %}
<div class="rounded-2xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-800">
Permanent documents belong to the client master. Acceptance, independence, conflict, engagement-letter and other AQMM requirements are completed from the applicable engagement according to its Assurance / Non-Assurance classification.
</div>
{% endif %}
{% if request.query_params.get('onboarding') %}
<section class="rounded-2xl border border-indigo-200 bg-indigo-50 p-5 shadow-soft">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-700">Client Onboarding · Permanent Documents</div>
<h3 class="mt-1 text-lg font-semibold text-indigo-950">Complete the legal/KYC document checklist</h3>
<p class="mt-1 max-w-3xl text-sm text-indigo-800">The client master has been created. Upload the standing documents below using the existing permanent-document, versioning and branch-local-storage workflow. Engagement-specific AQMM formalities are intentionally completed only inside the relevant Assurance / Non-Assurance engagement.</p>
</div>
<div class="shrink-0 rounded-xl bg-white px-4 py-3 text-center shadow-sm">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Onboarding status</div>
<div class="mt-1 text-sm font-semibold {{ 'text-emerald-700' if onboarding_complete else 'text-amber-700' }}">{{ 'Document checklist complete' if onboarding_complete else 'Documents pending' }}</div>
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{% for item in onboarding_checklist %}
<div class="rounded-xl border px-4 py-3 {{ 'border-emerald-200 bg-emerald-50' if item.complete else 'border-amber-200 bg-white' }}">
<div class="flex items-start gap-3">
<div class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold {{ 'bg-emerald-600 text-white' if item.complete else 'bg-amber-100 text-amber-800' }}">{{ '✓' if item.complete else '!' }}</div>
<div>
<div class="text-sm font-semibold text-slate-900">{{ item.label }}</div>
<div class="mt-1 text-xs {{ 'text-emerald-700' if item.complete else 'text-amber-700' }}">{{ 'Uploaded' if item.complete else 'Required to complete onboarding' }}</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="mt-5 flex flex-wrap gap-3">
{% if onboarding_complete %}
<a href="/clients/{{ client.id }}" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Complete Onboarding</a>
{% else %}
<a href="#permanent-upload-form" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Upload Required Documents</a>
<a href="/clients/{{ client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Save and Continue Later</a>
{% endif %}
<a href="/services/engagements/new?client_id={{ client.id }}" class="rounded-xl border border-indigo-300 bg-white px-4 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-100">Assign Service / Create Engagement</a>
</div>
</section>
{% endif %}
{% if request.query_params.get('uploaded') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Permanent document uploaded successfully.</div>{% endif %} {% if request.query_params.get('uploaded') %}<div class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">Permanent document uploaded successfully.</div>{% endif %}
{% if request.query_params.get('deleted') %}<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">Permanent document archived successfully.</div>{% endif %} {% if request.query_params.get('deleted') %}<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">Permanent document archived successfully.</div>{% endif %}
{% if request.query_params.get('download_queued') %}<div class="rounded-2xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-800">The file is stored in branch local storage. Retrieval request #{{ request.query_params.get('download_queued') }} has been queued.</div>{% endif %} {% if request.query_params.get('download_queued') %}<div class="rounded-2xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-800">The file is stored in branch local storage. Retrieval request #{{ request.query_params.get('download_queued') }} has been queued.</div>{% endif %}
{% if request.query_params.get('error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Action failed. Please check file size, permission and storage path.</div>{% endif %} {% if request.query_params.get('error') %}<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">Action failed. Please check file size, permission and storage path.</div>{% endif %}
{% if can_upload %} {% if can_upload %}
<section class="rounded-2xl bg-white p-5 shadow-soft"> <section id="permanent-upload-form" class="rounded-2xl bg-white p-5 shadow-soft">
<h3 class="text-sm font-semibold text-slate-900">Upload Permanent Document / New Revision</h3> <h3 class="text-sm font-semibold text-slate-900">Upload Permanent Document / New Revision</h3>
<form method="post" action="/documents/permanent/clients/{{ client.id }}/upload" enctype="multipart/form-data" class="mt-4 grid gap-4 md:grid-cols-2"> <form method="post" action="/documents/permanent/clients/{{ client.id }}/upload" enctype="multipart/form-data" class="mt-4 grid gap-4 md:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+151 -1
View File
@@ -95,6 +95,144 @@ router = APIRouter(prefix="/documents", tags=["documents-ui"])
logger = logging.getLogger("audit_storage_agent.documents_ui") logger = logging.getLogger("audit_storage_agent.documents_ui")
def _normalise_document_text(value) -> str:
return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()
def _permanent_onboarding_requirements(client) -> list[dict[str, object]]:
"""Return the client-master KYC checklist without creating a second document system."""
constitution = _normalise_document_text(getattr(client, "client_type", ""))
requirements: list[dict[str, object]] = [
{
"code": "pan",
"label": "PAN / Income-tax identity proof",
"categories": {"income tax", "kyc"},
"keywords": {"pan", "income tax"},
},
{
"code": "kyc",
"label": "Authorised person / proprietor KYC",
"categories": {"kyc"},
"keywords": {"kyc", "aadhaar", "aadhar", "identity", "authorised signatory", "authorized signatory"},
},
{
"code": "bank",
"label": "Bank account proof",
"categories": {"bank"},
"keywords": {"bank", "cancelled cheque", "canceled cheque"},
},
]
if getattr(client, "gstin", None):
requirements.append({
"code": "gst",
"label": "GST registration certificate",
"categories": {"gst registration"},
"keywords": {"gst", "gstin"},
})
if "partnership" in constitution:
requirements.append({
"code": "constitution",
"label": "Partnership deed",
"categories": {"agreements", "company registration"},
"keywords": {"partnership deed", "deed"},
})
elif constitution == "llp" or "limited liability partnership" in constitution:
requirements.extend([
{
"code": "registration",
"label": "LLP incorporation / registration certificate",
"categories": {"company registration", "roc master data"},
"keywords": {"llp incorporation", "certificate of incorporation", "incorporation"},
},
{
"code": "constitution",
"label": "LLP agreement",
"categories": {"agreements"},
"keywords": {"llp agreement", "agreement"},
},
])
elif "limited company" in constitution or constitution in {"private limited company", "public limited company"}:
requirements.extend([
{
"code": "registration",
"label": "Certificate of Incorporation",
"categories": {"company registration", "roc master data"},
"keywords": {"certificate of incorporation", "incorporation"},
},
{
"code": "constitution",
"label": "MOA / AOA",
"categories": {"company registration", "agreements", "roc master data"},
"keywords": {"moa", "aoa", "memorandum", "articles of association"},
},
])
elif "trust" in constitution:
requirements.append({
"code": "constitution",
"label": "Trust deed / registration document",
"categories": {"agreements", "company registration", "licenses"},
"keywords": {"trust deed", "trust registration"},
})
elif "society" in constitution:
requirements.extend([
{
"code": "registration",
"label": "Society registration certificate",
"categories": {"company registration", "licenses"},
"keywords": {"society registration", "registration certificate"},
},
{
"code": "constitution",
"label": "Bye-laws / governing document",
"categories": {"agreements"},
"keywords": {"bye laws", "byelaws", "governing document"},
},
])
elif "proprietorship" in constitution:
requirements.append({
"code": "constitution",
"label": "Business constitution / registration proof",
"categories": {"licenses", "company registration", "gst registration"},
"keywords": {"proprietorship", "shop act", "udyam", "business registration"},
})
elif constitution in {"aop", "huf"}:
requirements.append({
"code": "constitution",
"label": "Constitution / formation document",
"categories": {"agreements", "kyc", "company registration"},
"keywords": {"formation", "constitution", "deed", "huf"},
})
return requirements
def _build_permanent_onboarding_checklist(client, documents) -> tuple[list[dict[str, object]], bool]:
haystacks = []
for document in documents or []:
haystacks.append({
"category": _normalise_document_text(getattr(document, "category", "")),
"text": _normalise_document_text(" ".join([
str(getattr(document, "title", "") or ""),
str(getattr(document, "description", "") or ""),
str(getattr(document, "document_code", "") or ""),
])),
})
checklist = []
for requirement in _permanent_onboarding_requirements(client):
category_tokens = {_normalise_document_text(v) for v in requirement["categories"]}
keyword_tokens = {_normalise_document_text(v) for v in requirement["keywords"]}
complete = any(
item["category"] in category_tokens
or any(keyword and keyword in item["text"] for keyword in keyword_tokens)
for item in haystacks
)
checklist.append({**requirement, "complete": complete})
return checklist, all(item["complete"] for item in checklist)
def _base_ctx(request: Request, user, db, **ctx): def _base_ctx(request: Request, user, db, **ctx):
base = { base = {
"request": request, "request": request,
@@ -723,7 +861,19 @@ def permanent_client_documents(request: Request, client_id: int):
if not client or not user_can_view_client_documents(db, user, client, scope): if not client or not user_can_view_client_documents(db, user, client, scope):
return _redirect_denied() return _redirect_denied()
documents = list_permanent_documents_for_client(db, client_id) documents = list_permanent_documents_for_client(db, client_id)
return _render(request, "modules/documents/templates/documents/permanent_client_documents.html", db, user, title="Permanent Client Documents", client=client, documents=documents, can_upload=user_can_upload_client_documents(db, user, client, scope)) onboarding_checklist, onboarding_complete = _build_permanent_onboarding_checklist(client, documents)
return _render(
request,
"modules/documents/templates/documents/permanent_client_documents.html",
db,
user,
title="Permanent Client Documents",
client=client,
documents=documents,
can_upload=user_can_upload_client_documents(db, user, client, scope),
onboarding_checklist=onboarding_checklist,
onboarding_complete=onboarding_complete,
)
finally: finally:
db.close() db.close()