720 lines
30 KiB
Python
720 lines
30 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 ClientServicePlan, 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."
|
|
|
|
|
|
def get_or_create_client_service_plan(
|
|
db: Session,
|
|
*,
|
|
tenant_id: int,
|
|
client: Client,
|
|
catalogue: ServiceCatalogue,
|
|
firm_selection: FirmServiceSelection | None,
|
|
branch_id: int | None,
|
|
partner_user_id: int | None,
|
|
performing_partner_user_id: int | None,
|
|
manager_user_id: int | None,
|
|
staff_user_id: int | None,
|
|
review_partner_user_id: int | None,
|
|
actor_user_id: int | None,
|
|
remarks: str | None = None,
|
|
) -> ClientServicePlan:
|
|
plan = db.execute(
|
|
select(ClientServicePlan).where(
|
|
ClientServicePlan.tenant_id == tenant_id,
|
|
ClientServicePlan.client_id == client.id,
|
|
ClientServicePlan.service_catalogue_id == catalogue.id,
|
|
)
|
|
).scalar_one_or_none()
|
|
if plan is None:
|
|
plan = ClientServicePlan(
|
|
tenant_id=tenant_id,
|
|
client_id=client.id,
|
|
service_catalogue_id=catalogue.id,
|
|
created_by_user_id=actor_user_id,
|
|
)
|
|
db.add(plan)
|
|
plan.branch_id = branch_id or getattr(client, "branch_id", None)
|
|
plan.firm_service_selection_id = getattr(firm_selection, "id", None)
|
|
plan.default_partner_user_id = partner_user_id or getattr(client, "partner_id", None)
|
|
plan.default_performing_partner_user_id = performing_partner_user_id or getattr(client, "default_performing_partner_user_id", None) or plan.default_partner_user_id
|
|
plan.default_manager_user_id = manager_user_id
|
|
plan.default_staff_user_id = staff_user_id
|
|
plan.default_review_partner_user_id = review_partner_user_id or getattr(client, "default_review_partner_user_id", None)
|
|
plan.recurrence_type = normalized_recurrence_type(getattr(catalogue, "recurrence_type", None)) or "one_time"
|
|
plan.auto_generate_periods = recurrence_requires_period(plan.recurrence_type)
|
|
plan.status = "active"
|
|
plan.is_active = True
|
|
if remarks and not plan.remarks:
|
|
plan.remarks = remarks
|
|
plan.updated_by_user_id = actor_user_id
|
|
db.flush()
|
|
return plan
|
|
|
|
|
|
def attach_engagement_to_plan(
|
|
db: Session,
|
|
*,
|
|
engagement: ClientServiceSubscription,
|
|
client: Client,
|
|
catalogue: ServiceCatalogue,
|
|
firm_selection: FirmServiceSelection | None,
|
|
actor_user_id: int | None,
|
|
) -> ClientServicePlan:
|
|
plan = get_or_create_client_service_plan(
|
|
db,
|
|
tenant_id=engagement.tenant_id,
|
|
client=client,
|
|
catalogue=catalogue,
|
|
firm_selection=firm_selection,
|
|
branch_id=engagement.branch_id,
|
|
partner_user_id=engagement.assigned_partner_user_id,
|
|
performing_partner_user_id=getattr(engagement, "performing_partner_user_id", None),
|
|
manager_user_id=engagement.assigned_manager_user_id,
|
|
staff_user_id=engagement.assigned_staff_user_id,
|
|
review_partner_user_id=engagement.review_partner_user_id,
|
|
actor_user_id=actor_user_id,
|
|
remarks=engagement.remarks,
|
|
)
|
|
engagement.service_plan_id = plan.id
|
|
return plan
|