Files
arrr-erp/app/modules/services/client_services.py
T
2026-08-05 12:31:39 +05:30

643 lines
27 KiB
Python

from __future__ import annotations
from datetime import date
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from app.modules.clients.models import Client
from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Tenant
from app.modules.services.models import ClientServiceSubscription, FirmServiceSelection, ServiceCatalogue
SUBSCRIPTION_STATUSES = [
("draft", "Draft"),
("pending_acceptance", "Pending AQMM Acceptance"),
("active", "Active"),
("on_hold", "On Hold"),
("completed", "Completed"),
("cancelled", "Cancelled"),
("inactive", "Inactive"),
]
ASSIGNMENT_ROLE_NAMES = ("Partner", "Branch Manager", "Staff")
MONTHLY_PERIODS = (
(4, "Apr"), (5, "May"), (6, "Jun"), (7, "Jul"), (8, "Aug"), (9, "Sep"),
(10, "Oct"), (11, "Nov"), (12, "Dec"), (1, "Jan"), (2, "Feb"), (3, "Mar"),
)
QUARTERLY_PERIODS = ("Q1", "Q2", "Q3", "Q4")
def normalized_recurrence_type(value: str | None) -> str:
return (value or "").strip().lower().replace("-", "_").replace(" ", "_")
def recurrence_requires_period(value: str | None) -> bool:
return normalized_recurrence_type(value) in {"monthly", "quarterly"}
def period_choices_for_service(financial_year: str | None, recurrence_type: str | None) -> list[tuple[str, str]]:
recurrence = normalized_recurrence_type(recurrence_type)
fy = normalize_financial_year(financial_year)
start_year = int(fy.split("-", 1)[0])
end_year = start_year + 1
if recurrence == "monthly":
result = []
for month, label in MONTHLY_PERIODS:
year = start_year if month >= 4 else end_year
result.append((f"{year:04d}-{month:02d}", f"{label} {year}"))
return result
if recurrence == "quarterly":
return [(value, f"{value} {fy}") for value in QUARTERLY_PERIODS]
return [("", "Not applicable")]
def normalize_period_label(value: str | None, *, financial_year: str | None, recurrence_type: str | None) -> str:
recurrence = normalized_recurrence_type(recurrence_type)
raw = (value or "").strip()
if not recurrence_requires_period(recurrence):
return ""
valid = {code for code, _ in period_choices_for_service(financial_year, recurrence)}
if raw not in valid:
raise ValueError("A valid month or quarter is required for this recurring service.")
return raw
def current_financial_year(today: date | None = None) -> str:
today = today or date.today()
if today.month >= 4:
start = today.year
else:
start = today.year - 1
return f"{start}-{str(start + 1)[-2:]}"
def assessment_year_from_financial_year(financial_year: str | None) -> str | None:
if not financial_year or "-" not in financial_year:
return None
start = int(str(financial_year).split("-")[0])
return f"{start + 1}-{str(start + 2)[-2:]}"
def normalize_financial_year(value: str | None) -> str:
value = (value or "").strip()
return value or current_financial_year()
def parse_date(value: str | None) -> date | None:
if not value:
return None
value = value.strip()
if not value:
return None
return date.fromisoformat(value)
def list_subscription_payload(
db: Session,
*,
tenant_id: int,
branch_id: int | None = None,
financial_year: str | None = None,
q: str = "",
include_inactive: bool = True,
):
fy = normalize_financial_year(financial_year)
query = (
select(ClientServiceSubscription)
.options(
selectinload(ClientServiceSubscription.client),
selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceSubscription.performing_partner),
selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceSubscription.review_partner),
selectinload(ClientServiceSubscription.due_date_rule),
)
.where(
ClientServiceSubscription.tenant_id == tenant_id,
ClientServiceSubscription.financial_year == fy,
)
)
if branch_id:
query = query.where(ClientServiceSubscription.branch_id == branch_id)
if not include_inactive:
query = query.where(ClientServiceSubscription.is_active.is_(True))
if q.strip():
term = f"%{q.strip()}%"
query = (
query.join(Client, Client.id == ClientServiceSubscription.client_id)
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceSubscription.service_catalogue_id)
.where(
or_(
Client.client_name.ilike(term),
Client.client_code.ilike(term),
ServiceCatalogue.service_code.ilike(term),
ServiceCatalogue.service_name.ilike(term),
)
)
)
return db.execute(
query.order_by(
ClientServiceSubscription.is_locked.asc(),
ClientServiceSubscription.is_active.desc(),
ClientServiceSubscription.id.desc(),
)
).scalars().all()
def get_subscription(db: Session, *, subscription_id: int, tenant_id: int) -> ClientServiceSubscription | None:
return db.execute(
select(ClientServiceSubscription)
.options(
selectinload(ClientServiceSubscription.client),
selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceSubscription.performing_partner),
selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceSubscription.assigned_staff),
selectinload(ClientServiceSubscription.review_partner),
selectinload(ClientServiceSubscription.due_date_rule),
)
.where(
ClientServiceSubscription.id == subscription_id,
ClientServiceSubscription.tenant_id == tenant_id,
)
).scalar_one_or_none()
def get_existing_subscription(
db: Session,
*,
tenant_id: int,
client_id: int,
service_catalogue_id: int,
financial_year: str | None = None,
period_label: str | None = None,
) -> ClientServiceSubscription | None:
return db.execute(
select(ClientServiceSubscription).where(
ClientServiceSubscription.tenant_id == tenant_id,
ClientServiceSubscription.client_id == client_id,
ClientServiceSubscription.service_catalogue_id == service_catalogue_id,
ClientServiceSubscription.financial_year == normalize_financial_year(financial_year),
ClientServiceSubscription.period_label == (period_label or "").strip(),
)
).scalar_one_or_none()
def list_clients_for_assignment(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None):
query = select(Client).where(Client.tenant_id == tenant_id)
if branch_id:
query = query.where(Client.branch_id == branch_id)
if partner_id:
query = query.where(Client.partner_id == partner_id)
return db.execute(query.order_by(Client.client_name.asc())).scalars().all()
def list_enabled_services_for_assignment(db: Session, *, tenant_id: int):
return db.execute(
select(FirmServiceSelection)
.join(ServiceCatalogue, ServiceCatalogue.id == FirmServiceSelection.service_catalogue_id)
.options(selectinload(FirmServiceSelection.catalogue))
.where(
FirmServiceSelection.tenant_id == tenant_id,
FirmServiceSelection.is_enabled.is_(True),
ServiceCatalogue.is_active.is_(True),
)
.order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc())
).scalars().all()
def get_enabled_firm_service(db: Session, *, tenant_id: int, service_catalogue_id: int) -> FirmServiceSelection | None:
return db.execute(
select(FirmServiceSelection).where(
FirmServiceSelection.tenant_id == tenant_id,
FirmServiceSelection.service_catalogue_id == service_catalogue_id,
FirmServiceSelection.is_enabled.is_(True),
)
).scalar_one_or_none()
def list_assignable_users(db: Session, *, tenant_id: int, branch_id: int | None = None, role_names: tuple[str, ...] = ASSIGNMENT_ROLE_NAMES):
query = (
select(User)
.join(UserRole, UserRole.user_id == User.id)
.join(Role, Role.id == UserRole.role_id)
.where(User.tenant_id == tenant_id, User.is_active.is_(True), Role.name.in_(role_names))
)
if branch_id:
query = query.where(or_(User.branch_id == branch_id, User.branch_id.is_(None)))
return db.execute(query.order_by(User.full_name.asc(), User.email.asc()).distinct()).scalars().all()
def tenant_requires_review_partner(db: Session, *, tenant_id: int) -> bool:
tenant = db.get(Tenant, tenant_id)
firm_type = (getattr(tenant, "firm_type", None) or "partnership").strip().lower() if tenant else "partnership"
return firm_type == "partnership"
def review_partner_required_for_engagement(db: Session, *, tenant_id: int, engagement_type: str | None) -> bool:
return tenant_requires_review_partner(db, tenant_id=tenant_id) and (engagement_type or "").strip().lower() == "assurance"
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."