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, ServiceTaskCategory, 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): # Keep catalogue paging predictable and prevent invalid/very large requests. page = max(1, int(page or 1)) allowed_page_sizes = {20, 50, 100} per_page = int(per_page or 20) if per_page not in allowed_page_sizes: per_page = 20 query = select(ServiceCatalogue).where( ServiceCatalogue.is_active.is_(True) ).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() pages = max(1, (total + per_page - 1) // per_page) page = min(page, pages) rows = db.execute( query.order_by(ServiceCatalogue.sort_order.asc(), ServiceCatalogue.service_name.asc()) .offset((page - 1) * per_page) .limit(per_page) ).scalars().all() start_item = ((page - 1) * per_page + 1) if total else 0 end_item = min(page * per_page, total) 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": pages, "start_item": start_item, "end_item": end_item, } 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), ServiceCatalogue.is_active.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.is_active.is_(True), ~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() def list_task_categories(db: Session, *, catalogue_id: int, tenant_id: int | None, include_inactive: bool = False): query = select(ServiceTaskCategory).where( ServiceTaskCategory.service_catalogue_id == catalogue_id, ServiceTaskCategory.tenant_id.is_(None) if tenant_id is None else ServiceTaskCategory.tenant_id == tenant_id, ) if not include_inactive: query = query.where(ServiceTaskCategory.is_active.is_(True)) return db.execute(query.order_by(ServiceTaskCategory.sort_order.asc(), ServiceTaskCategory.name.asc(), ServiceTaskCategory.id.asc())).scalars().all() def get_task_category(db: Session, *, category_id: int, catalogue_id: int, tenant_id: int | None) -> ServiceTaskCategory | None: query = select(ServiceTaskCategory).where( ServiceTaskCategory.id == category_id, ServiceTaskCategory.service_catalogue_id == catalogue_id, ServiceTaskCategory.tenant_id.is_(None) if tenant_id is None else ServiceTaskCategory.tenant_id == tenant_id, ) return db.execute(query).scalar_one_or_none() def ensure_task_category(db: Session, *, catalogue_id: int, tenant_id: int | None, name: str | None, user_id: int | None = None) -> ServiceTaskCategory | None: clean_name = (name or "").strip() if not clean_name: return None scope_filter = ServiceTaskCategory.tenant_id.is_(None) if tenant_id is None else ServiceTaskCategory.tenant_id == tenant_id existing = db.execute(select(ServiceTaskCategory).where( ServiceTaskCategory.service_catalogue_id == catalogue_id, scope_filter, func.lower(ServiceTaskCategory.name) == clean_name.lower(), )).scalar_one_or_none() if existing: return existing base_code = normalize_code(clean_name)[:50] or "CATEGORY" code = base_code suffix = 2 while db.execute(select(ServiceTaskCategory.id).where( ServiceTaskCategory.service_catalogue_id == catalogue_id, scope_filter, ServiceTaskCategory.code == code, )).scalar_one_or_none() is not None: tail = f"-{suffix}" code = f"{base_code[:50-len(tail)]}{tail}" suffix += 1 row = ServiceTaskCategory( tenant_id=tenant_id, service_catalogue_id=catalogue_id, code=code, name=clean_name, sort_order=100, is_active=True, created_by_user_id=user_id, updated_by_user_id=user_id, ) db.add(row) db.flush() return row