Integrate UDIN controls into final document workflow
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user