from __future__ import annotations import re from sqlalchemy import func, or_, select from sqlalchemy.orm import Session, selectinload from app.modules.core.tenancy.models import Branch from app.modules.services.models import ( FirmServiceSelection, FirmServiceTaskTemplate, ServiceCatalogue, ServiceCategory, ServiceDefaultTaskTemplate, ServiceDueDateRule, ) RECURRENCE_CHOICES = [ ("one_time", "One Time"), ("monthly", "Monthly"), ("quarterly", "Quarterly"), ("yearly", "Yearly"), ("event_based", "Event Based"), ("custom", "Custom"), ] ENGAGEMENT_TYPE_CHOICES = [ ("assurance", "Assurance"), ("non_assurance", "Non-Assurance"), ] VALID_ENGAGEMENT_TYPES = {value for value, _label in ENGAGEMENT_TYPE_CHOICES} def normalize_engagement_type(value: str | None) -> str: value = (value or "").strip().lower().replace("-", "_").replace(" ", "_") if value in {"assurance", "audit", "aud", "certification", "certificate", "attestation"}: return "assurance" if value in {"non_assurance", "nonassurance", "non_audit", "nonaudit", "non", "compliance", "consulting", "consultancy"}: return "non_assurance" return "non_assurance" def engagement_type_label(value: str | None) -> str: normalized = normalize_engagement_type(value) return "Assurance" if normalized == "assurance" else "Non-Assurance" def normalize_code(value: str) -> str: value = (value or "").strip().upper() value = re.sub(r"[^A-Z0-9]+", "-", value) value = re.sub(r"-+", "-", value).strip("-") return value def list_categories(db: Session, *, q: str = ""): query = select(ServiceCategory) if q.strip(): term = f"%{q.strip()}%" query = query.where(or_(ServiceCategory.code.ilike(term), ServiceCategory.name.ilike(term))) return db.execute(query.order_by(ServiceCategory.sort_order.asc(), ServiceCategory.name.asc())).scalars().all() def get_category(db: Session, category_id: int) -> ServiceCategory | None: return db.execute(select(ServiceCategory).where(ServiceCategory.id == category_id)).scalar_one_or_none() def list_catalogue_payload(db: Session, *, q: str = "", category_id: int | None = None, recurrence_type: str = "", engagement_type: str = "", page: int = 1, per_page: int = 20): query = select(ServiceCatalogue).options( selectinload(ServiceCatalogue.service_category), selectinload(ServiceCatalogue.default_task_templates), selectinload(ServiceCatalogue.due_date_rules), ) if q.strip(): term = f"%{q.strip()}%" query = query.outerjoin(ServiceCategory, ServiceCategory.id == ServiceCatalogue.category_id).where( or_( ServiceCatalogue.service_code.ilike(term), ServiceCatalogue.service_name.ilike(term), ServiceCatalogue.category.ilike(term), ServiceCategory.name.ilike(term), ) ) if category_id: query = query.where(ServiceCatalogue.category_id == category_id) if recurrence_type.strip(): query = query.where(ServiceCatalogue.recurrence_type == recurrence_type.strip()) if engagement_type.strip(): query = query.where(ServiceCatalogue.engagement_type == normalize_engagement_type(engagement_type)) total = db.execute(select(func.count()).select_from(query.subquery())).scalar_one() rows = db.execute( query.order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc()) .offset((page - 1) * per_page) .limit(per_page) ).scalars().all() return { "rows": rows, "q": q, "category_id": category_id, "recurrence_type": recurrence_type, "engagement_type": engagement_type, "page": page, "per_page": per_page, "total": total, "pages": max(1, (total + per_page - 1) // per_page), } def list_firm_services_payload(db: Session, *, tenant_id: int, q: str = ""): query = ( select(FirmServiceSelection, ServiceCatalogue, Branch) .join(ServiceCatalogue, ServiceCatalogue.id == FirmServiceSelection.service_catalogue_id) .outerjoin(Branch, Branch.id == FirmServiceSelection.default_branch_id) .where( FirmServiceSelection.tenant_id == tenant_id, FirmServiceSelection.is_enabled.is_(True), ) ) if q.strip(): term = f"%{q.strip()}%" query = query.outerjoin(ServiceCategory, ServiceCategory.id == ServiceCatalogue.category_id).where( or_( ServiceCatalogue.service_code.ilike(term), ServiceCatalogue.service_name.ilike(term), ServiceCatalogue.category.ilike(term), ServiceCategory.name.ilike(term), ) ) rows = db.execute(query.order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc())).all() return [ {"selection": selection, "catalogue": catalogue, "branch": branch} for selection, catalogue, branch in rows ] def list_disabled_catalogues(db: Session, *, tenant_id: int, q: str = ""): enabled_subq = ( select(FirmServiceSelection.service_catalogue_id) .where( FirmServiceSelection.tenant_id == tenant_id, FirmServiceSelection.is_enabled.is_(True), ) ) query = select(ServiceCatalogue).where(~ServiceCatalogue.id.in_(enabled_subq)).options( selectinload(ServiceCatalogue.service_category), selectinload(ServiceCatalogue.default_task_templates), selectinload(ServiceCatalogue.due_date_rules), ) if q.strip(): term = f"%{q.strip()}%" query = query.outerjoin(ServiceCategory, ServiceCategory.id == ServiceCatalogue.category_id).where( or_( ServiceCatalogue.service_code.ilike(term), ServiceCatalogue.service_name.ilike(term), ServiceCatalogue.category.ilike(term), ServiceCategory.name.ilike(term), ) ) return db.execute(query.order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc())).scalars().all() def get_catalogue(db: Session, catalogue_id: int) -> ServiceCatalogue | None: return db.execute( select(ServiceCatalogue) .options( selectinload(ServiceCatalogue.service_category), selectinload(ServiceCatalogue.default_task_templates), ) .where(ServiceCatalogue.id == catalogue_id) ).scalar_one_or_none() def get_firm_selection(db: Session, *, tenant_id: int, catalogue_id: int) -> FirmServiceSelection | None: return db.execute( select(FirmServiceSelection).where( FirmServiceSelection.tenant_id == tenant_id, FirmServiceSelection.service_catalogue_id == catalogue_id, ) ).scalar_one_or_none() def get_firm_task_templates(db: Session, *, tenant_id: int, catalogue_id: int): return db.execute( select(FirmServiceTaskTemplate) .where( FirmServiceTaskTemplate.tenant_id == tenant_id, FirmServiceTaskTemplate.service_catalogue_id == catalogue_id, ) .order_by(FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc()) ).scalars().all() def get_default_task_templates(db: Session, *, catalogue_id: int): return db.execute( select(ServiceDefaultTaskTemplate) .where(ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id) .order_by(ServiceDefaultTaskTemplate.sequence_no.asc(), ServiceDefaultTaskTemplate.id.asc()) ).scalars().all() def next_task_sequence(db: Session, *, tenant_id: int, catalogue_id: int) -> int: max_seq = db.execute( select(func.max(FirmServiceTaskTemplate.sequence_no)).where( FirmServiceTaskTemplate.tenant_id == tenant_id, FirmServiceTaskTemplate.service_catalogue_id == catalogue_id, ) ).scalar_one() return int(max_seq or 0) + 1 def next_default_task_sequence(db: Session, *, catalogue_id: int) -> int: max_seq = db.execute( select(func.max(ServiceDefaultTaskTemplate.sequence_no)).where( ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id, ) ).scalar_one() return int(max_seq or 0) + 1 def get_firm_task_template( db: Session, *, tenant_id: int, catalogue_id: int, task_id: int, ) -> FirmServiceTaskTemplate | None: return db.execute( select(FirmServiceTaskTemplate).where( FirmServiceTaskTemplate.id == task_id, FirmServiceTaskTemplate.tenant_id == tenant_id, FirmServiceTaskTemplate.service_catalogue_id == catalogue_id, ) ).scalar_one_or_none() def get_default_task_template( db: Session, *, catalogue_id: int, task_id: int, ) -> ServiceDefaultTaskTemplate | None: return db.execute( select(ServiceDefaultTaskTemplate).where( ServiceDefaultTaskTemplate.id == task_id, ServiceDefaultTaskTemplate.service_catalogue_id == catalogue_id, ) ).scalar_one_or_none()