Integrate UDIN controls into final document workflow

This commit is contained in:
A R R R Associates
2026-07-06 18:05:00 +05:30
parent eb1cf6cbf2
commit 561de117e4
10 changed files with 723 additions and 10 deletions
+197
View File
@@ -6,6 +6,8 @@ import os
import re
import secrets
import shutil
from datetime import date, datetime, timezone
from decimal import Decimal, InvalidOperation
from dataclasses import dataclass
from pathlib import Path
from typing import BinaryIO
@@ -45,6 +47,10 @@ DOCUMENT_TYPES = [
"ACKNOWLEDGEMENT",
]
UDIN_STATUSES = ["not_required", "pending", "generated", "cancelled"]
FINAL_RELEASE_STATUSES = ["draft", "released"]
UDIN_REQUIRED_DOCUMENT_TYPES = {"AUDIT_REPORT", "SIGNED_OUTPUT", "ACKNOWLEDGEMENT"}
MAX_UPLOAD_BYTES = int(os.getenv("DOCUMENT_MAX_UPLOAD_MB", "50")) * 1024 * 1024
@@ -107,6 +113,47 @@ def _has_perm(db: Session, user, code: str) -> bool:
return False
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def _clean_udin_number(value: str | None) -> str | None:
text = (value or "").strip().upper().replace(" ", "")
return text or None
def _to_decimal(value: str | Decimal | int | float | None) -> Decimal | None:
if value in (None, ""):
return None
try:
return Decimal(str(value).replace(",", "")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
raise ValueError("Invalid UDIN amount.")
def _normalize_udin_required(document_type: str | None, explicit_value: bool | str | None = None) -> bool:
if isinstance(explicit_value, str):
lowered = explicit_value.strip().lower()
if lowered in {"1", "true", "yes", "on", "required"}:
return True
if lowered in {"0", "false", "no", "off", "not_required"}:
return False
if explicit_value is not None:
return bool(explicit_value)
return (document_type or "").strip().upper() in UDIN_REQUIRED_DOCUMENT_TYPES
def user_can_manage_udin(db: Session, user, document: EngagementDocument, scope: DocumentScope) -> bool:
if not _has_perm(db, user, "udin.manage"):
return False
engagement = getattr(document, "engagement", None) or db.get(ClientServiceSubscription, document.engagement_id)
return bool(engagement and user_can_view_engagement(db, user, engagement, scope))
def user_can_view_udin_register(db: Session, user, scope: DocumentScope) -> bool:
return _has_perm(db, user, "udin.view")
def build_document_scope(request, db: Session, user) -> DocumentScope:
from app.modules.core.rbac.deps import get_user_roles
@@ -421,6 +468,146 @@ def log_document_access(db: Session, *, action: str, result: str, user, request=
def update_document_udin(
db: Session,
*,
document: EngagementDocument,
udin_required: bool | str | None,
udin_number: str | None,
udin_date: date | None,
udin_document_date: date | None,
udin_document_type: str | None,
udin_financial_year: str | None,
udin_amount: str | Decimal | int | float | None,
udin_notes: str | None,
user,
) -> EngagementDocument:
required = _normalize_udin_required(document.document_type, udin_required)
number = _clean_udin_number(udin_number)
document.udin_required = required
document.udin_number = number
document.udin_date = udin_date
document.udin_document_date = udin_document_date
document.udin_document_type = (udin_document_type or "").strip()[:120] or None
document.udin_financial_year = (udin_financial_year or document.financial_year or "").strip()[:9] or None
document.udin_amount = _to_decimal(udin_amount)
document.udin_notes = (udin_notes or "").strip() or None
document.updated_by_user_id = getattr(user, "id", None)
if required:
if number:
document.udin_status = "generated"
document.udin_generated_by_user_id = getattr(user, "id", None)
document.udin_verified_at_utc = _now_utc()
else:
document.udin_status = "pending"
document.udin_generated_by_user_id = None
document.udin_verified_at_utc = None
else:
document.udin_status = "not_required"
if not number:
document.udin_generated_by_user_id = None
document.udin_verified_at_utc = None
db.flush()
return document
def release_document_final(
db: Session,
*,
document: EngagementDocument,
release_notes: str | None,
user,
) -> EngagementDocument:
if not get_latest_version(document):
raise ValueError("Cannot release a document without an uploaded version.")
if document.udin_required and not _clean_udin_number(document.udin_number):
raise ValueError("UDIN is required before final release for this document.")
if document.udin_required and document.udin_status != "generated":
raise ValueError("UDIN status must be Generated before final release.")
document.final_release_status = "released"
document.status = "final"
document.released_by_user_id = getattr(user, "id", None)
document.released_at_utc = _now_utc()
document.release_notes = (release_notes or "").strip() or None
document.updated_by_user_id = getattr(user, "id", None)
db.flush()
return document
def reopen_final_document(
db: Session,
*,
document: EngagementDocument,
release_notes: str | None,
user,
) -> EngagementDocument:
document.final_release_status = "draft"
document.status = "active"
document.release_notes = (release_notes or "").strip() or document.release_notes
document.updated_by_user_id = getattr(user, "id", None)
db.flush()
return document
def list_udin_records(
db: Session,
user,
scope: DocumentScope,
*,
financial_year: str | None = None,
status: str | None = None,
partner_user_id: int | None = None,
q: str | None = None,
limit: int = 500,
) -> list[EngagementDocument]:
stmt = (
select(EngagementDocument)
.options(
joinedload(EngagementDocument.client),
joinedload(EngagementDocument.engagement).joinedload(ClientServiceSubscription.assigned_partner),
joinedload(EngagementDocument.versions),
)
.where(EngagementDocument.is_deleted.is_(False))
.order_by(EngagementDocument.udin_date.desc().nullslast(), EngagementDocument.updated_at_utc.desc())
)
if scope.is_firm_admin:
stmt = stmt.where(EngagementDocument.tenant_id == getattr(user, "tenant_id", None))
elif scope.is_partner:
stmt = stmt.join(ClientServiceSubscription, ClientServiceSubscription.id == EngagementDocument.engagement_id).outerjoin(Client, Client.id == EngagementDocument.client_id).where(
EngagementDocument.tenant_id == getattr(user, "tenant_id", None),
or_(ClientServiceSubscription.assigned_partner_user_id == user.id, Client.partner_id == user.id, EngagementDocument.udin_generated_by_user_id == user.id),
)
elif scope.is_branch_manager:
stmt = stmt.where(
EngagementDocument.tenant_id == getattr(user, "tenant_id", None),
or_(EngagementDocument.branch_id == getattr(user, "branch_id", None), EngagementDocument.branch_id.is_(None)),
)
elif scope.is_staff:
stmt = stmt.join(ClientServiceSubscription, ClientServiceSubscription.id == EngagementDocument.engagement_id).where(
EngagementDocument.tenant_id == getattr(user, "tenant_id", None),
ClientServiceSubscription.assigned_staff_user_id == user.id,
)
else:
return []
if financial_year:
stmt = stmt.where(EngagementDocument.financial_year == financial_year.strip())
if status:
stmt = stmt.where(EngagementDocument.udin_status == status.strip())
if partner_user_id:
stmt = stmt.join(ClientServiceSubscription, ClientServiceSubscription.id == EngagementDocument.engagement_id).where(
or_(ClientServiceSubscription.assigned_partner_user_id == int(partner_user_id), EngagementDocument.udin_generated_by_user_id == int(partner_user_id))
)
if q:
pattern = f"%{q.strip()}%"
stmt = stmt.where(or_(EngagementDocument.title.ilike(pattern), EngagementDocument.document_code.ilike(pattern), EngagementDocument.udin_number.ilike(pattern)))
return db.execute(stmt.limit(limit)).unique().scalars().all()
def hash_storage_secret(secret: str) -> str:
return hashlib.sha256((secret or "").encode("utf-8")).hexdigest()
@@ -813,9 +1000,11 @@ def save_uploaded_revision(
remarks: str | None,
user,
existing_document_id: int | None = None,
udin_required: bool | str | None = None,
) -> EngagementDocument:
original_filename = Path(upload_file.filename or "document.bin").name
document_type = document_type if document_type in DOCUMENT_TYPES else "GENERAL"
resolved_udin_required = _normalize_udin_required(document_type, udin_required)
if existing_document_id:
document = db.get(EngagementDocument, existing_document_id)
@@ -826,6 +1015,11 @@ def save_uploaded_revision(
if description is not None:
document.description = description.strip() or None
document.document_type = document_type
document.udin_required = resolved_udin_required
if resolved_udin_required and (document.udin_status or "not_required") == "not_required":
document.udin_status = "pending"
if not resolved_udin_required and not document.udin_number:
document.udin_status = "not_required"
else:
document = EngagementDocument(
tenant_id=engagement.tenant_id,
@@ -836,6 +1030,9 @@ def save_uploaded_revision(
assessment_year=engagement.assessment_year,
document_code="PENDING",
document_type=document_type,
udin_required=resolved_udin_required,
udin_status="pending" if resolved_udin_required else "not_required",
final_release_status="draft",
title=(title.strip()[:255] if title else original_filename[:255]),
description=description.strip() if description else None,
created_by_user_id=user.id,