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"), ("active", "Active"), ("on_hold", "On Hold"), ("completed", "Completed"), ("cancelled", "Cancelled"), ("inactive", "Inactive"), ] ASSIGNMENT_ROLE_NAMES = ("Partner", "Branch Manager", "Staff") 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.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.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, ) -> 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), ) ).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",))