Align client onboarding documents and engagement AQMM workflow
This commit is contained in:
@@ -82,184 +82,29 @@
|
||||
</section>
|
||||
|
||||
<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 items-start justify-between gap-3">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-900">Client Acceptance Controls</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>
|
||||
<h3 class="text-base font-semibold text-slate-900">Client Onboarding & Permanent Documents</h3>
|
||||
<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>
|
||||
<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 class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{% for label, ok in [
|
||||
('Acceptance Required', row.acceptance_required),
|
||||
('Independence Check', row.independence_check_completed),
|
||||
('Conflict Check', row.conflict_check_completed),
|
||||
('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 class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<h4 class="text-sm font-semibold text-slate-900">Permanent KYC Documents</h4>
|
||||
<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 client’s legal constitution.</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-3">
|
||||
<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">Independence / Conflict Declarations</h5>
|
||||
{% 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 class="rounded-2xl border border-indigo-200 bg-indigo-50 p-4">
|
||||
<h4 class="text-sm font-semibold text-indigo-950">AQMM at Engagement Level</h4>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-6 shadow-soft">
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, File, Form, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.db.common import CommonSessionLocal
|
||||
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.email_integration.services import send_auth_otp_email
|
||||
from app.modules.documents.models import PermanentClientDocument
|
||||
from app.modules.documents.services import (
|
||||
get_permanent_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_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:
|
||||
return _render(
|
||||
request,
|
||||
@@ -662,7 +664,12 @@ def client_detail(request: Request, client_id: int):
|
||||
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 []
|
||||
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(
|
||||
request,
|
||||
"modules/clients/templates/clients/detail.html",
|
||||
@@ -671,12 +678,7 @@ def client_detail(request: Request, client_id: int):
|
||||
title=f"Client • {row['client_name']}",
|
||||
row=row,
|
||||
audit_logs=audit_logs,
|
||||
acceptance_declarations=workflow.get("acceptance_declarations", []),
|
||||
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),
|
||||
permanent_document_count=permanent_document_count,
|
||||
scope=scope,
|
||||
can_edit=has("clients.edit"),
|
||||
can_deactivate=has("clients.deactivate"),
|
||||
|
||||
@@ -12,13 +12,59 @@
|
||||
</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('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('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 %}
|
||||
<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>
|
||||
<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 }}">
|
||||
|
||||
+151
-1
@@ -95,6 +95,144 @@ router = APIRouter(prefix="/documents", tags=["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):
|
||||
base = {
|
||||
"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):
|
||||
return _redirect_denied()
|
||||
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:
|
||||
db.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user