Files
arrr-erp/app/modules/services/execution.py
T
2026-06-20 15:01:44 +05:30

610 lines
24 KiB
Python

from __future__ import annotations
from datetime import date, datetime, timezone
from sqlalchemy import func, 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.services.models import (
ClientServiceSubscription,
ClientServiceTaskInstance,
ServiceTaskComment,
FirmServiceTaskTemplate,
ServiceCatalogue,
)
TASK_STATUSES = [
("pending", "Pending"),
("in_progress", "In Progress"),
("completed", "Completed"),
("blocked", "Blocked"),
("not_applicable", "Not Applicable"),
("cancelled", "Cancelled"),
]
OPEN_TASK_STATUSES = {"pending", "in_progress", "blocked"}
CLOSED_TASK_STATUSES = {"completed", "not_applicable", "cancelled"}
TASK_COMMENT_TYPES = [
("internal_note", "Internal Note"),
("client_clarification", "Client Clarification"),
("consultant_clarification", "Consultant Clarification"),
("partner_review_note", "Partner Review Note"),
]
TASK_COMMENT_VISIBILITIES = [
("internal", "Internal"),
("client", "Client"),
("consultant", "Consultant"),
]
TASK_PRIORITIES = [
("low", "Low"),
("normal", "Normal"),
("high", "High"),
("urgent", "Urgent"),
]
def _normalise_status(status: str | None) -> str:
allowed = {code for code, _label in TASK_STATUSES}
value = (status or "pending").strip().lower()
return value if value in allowed else "pending"
def _normalise_priority(priority: str | None) -> str:
allowed = {code for code, _label in TASK_PRIORITIES}
value = (priority or "normal").strip().lower()
return value if value in allowed else "normal"
def _normalise_comment_type(comment_type: str | None) -> str:
allowed = {code for code, _label in TASK_COMMENT_TYPES}
value = (comment_type or "internal_note").strip().lower()
return value if value in allowed else "internal_note"
def _normalise_visibility(visibility: str | None) -> str:
allowed = {code for code, _label in TASK_COMMENT_VISIBILITIES}
value = (visibility or "internal").strip().lower()
return value if value in allowed else "internal"
def parse_date_value(value: str | None) -> date | None:
text = (value or "").strip()
if not text:
return None
return date.fromisoformat(text)
def _default_assignee_for_template(subscription: ClientServiceSubscription, template: FirmServiceTaskTemplate) -> int | None:
role = (template.default_role_name or "").strip().lower()
if "partner" in role:
return subscription.assigned_partner_user_id
if "manager" in role:
return subscription.assigned_manager_user_id
if "staff" in role or "employee" in role:
return subscription.assigned_staff_user_id
return subscription.assigned_staff_user_id or subscription.assigned_manager_user_id or subscription.assigned_partner_user_id
def get_subscription_for_execution(db: Session, *, tenant_id: int, subscription_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),
)
.where(
ClientServiceSubscription.id == subscription_id,
ClientServiceSubscription.tenant_id == tenant_id,
)
).scalar_one_or_none()
def list_subscription_execution_payload(db: Session, *, tenant_id: int, branch_id: int | None = None, financial_year: str | None = None, q: str = ""):
query = (
select(ClientServiceSubscription)
.options(
selectinload(ClientServiceSubscription.client),
selectinload(ClientServiceSubscription.catalogue),
selectinload(ClientServiceSubscription.assigned_partner),
selectinload(ClientServiceSubscription.assigned_manager),
selectinload(ClientServiceSubscription.assigned_staff),
)
.where(
ClientServiceSubscription.tenant_id == tenant_id,
ClientServiceSubscription.is_active.is_(True),
ClientServiceSubscription.status == "active",
ClientServiceSubscription.is_locked.is_(False),
)
)
if financial_year:
query = query.where(ClientServiceSubscription.financial_year == financial_year.strip())
if branch_id:
query = query.where(ClientServiceSubscription.branch_id == branch_id)
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),
)
)
)
rows = db.execute(query.order_by(ClientServiceSubscription.id.desc())).scalars().all()
payload = []
for sub in rows:
total = db.execute(
select(func.count(ClientServiceTaskInstance.id)).where(
ClientServiceTaskInstance.subscription_id == sub.id,
ClientServiceTaskInstance.is_active.is_(True),
)
).scalar_one()
completed = db.execute(
select(func.count(ClientServiceTaskInstance.id)).where(
ClientServiceTaskInstance.subscription_id == sub.id,
ClientServiceTaskInstance.is_active.is_(True),
ClientServiceTaskInstance.status == "completed",
)
).scalar_one()
open_tasks = db.execute(
select(func.count(ClientServiceTaskInstance.id)).where(
ClientServiceTaskInstance.subscription_id == sub.id,
ClientServiceTaskInstance.is_active.is_(True),
ClientServiceTaskInstance.status.in_(list(OPEN_TASK_STATUSES)),
)
).scalar_one()
payload.append({"subscription": sub, "total_tasks": total, "completed_tasks": completed, "open_tasks": open_tasks})
return payload
def _default_internal_target_date(subscription: ClientServiceSubscription, template: FirmServiceTaskTemplate) -> date | None:
# Phase 4B keeps task target dates internal. Existing task templates do not yet have
# an offset field, so new generated tasks start blank and can be assigned through the tracker.
return None
def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceSubscription, user_id: int) -> int:
templates = db.execute(
select(FirmServiceTaskTemplate)
.where(
FirmServiceTaskTemplate.tenant_id == subscription.tenant_id,
FirmServiceTaskTemplate.service_catalogue_id == subscription.service_catalogue_id,
FirmServiceTaskTemplate.is_active.is_(True),
)
.order_by(FirmServiceTaskTemplate.sequence_no.asc(), FirmServiceTaskTemplate.id.asc())
).scalars().all()
created = 0
for template in templates:
existing = db.execute(
select(ClientServiceTaskInstance.id).where(
ClientServiceTaskInstance.subscription_id == subscription.id,
ClientServiceTaskInstance.firm_task_template_id == template.id,
ClientServiceTaskInstance.financial_year == subscription.financial_year,
)
).first()
if existing:
continue
db.add(
ClientServiceTaskInstance(
tenant_id=subscription.tenant_id,
branch_id=subscription.branch_id,
subscription_id=subscription.id,
client_id=subscription.client_id,
service_catalogue_id=subscription.service_catalogue_id,
firm_task_template_id=template.id,
financial_year=subscription.financial_year,
assessment_year=subscription.assessment_year,
task_name=template.task_name,
description=template.description,
sequence_no=template.sequence_no,
default_role_name=template.default_role_name,
assigned_to_user_id=_default_assignee_for_template(subscription, template),
internal_target_date=_default_internal_target_date(subscription, template),
status="pending",
priority="normal",
is_active=True,
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
)
created += 1
return created
def _decorate_task_for_tracker(task: ClientServiceTaskInstance, *, today: date) -> ClientServiceTaskInstance:
target_date = getattr(task, "internal_target_date", None)
subscription = getattr(task, "subscription", None)
engagement_due_date = getattr(subscription, "current_due_date", None) if subscription else None
task.is_task_overdue = bool(target_date and target_date < today and task.status not in CLOSED_TASK_STATUSES)
task.is_due_today = bool(target_date and target_date == today and task.status not in CLOSED_TASK_STATUSES)
task.is_engagement_due_overdue = bool(
engagement_due_date and engagement_due_date < today and task.status not in CLOSED_TASK_STATUSES
)
task.tracker_status_label = dict(TASK_STATUSES).get(task.status, task.status)
task.priority_label = dict(TASK_PRIORITIES).get(task.priority, task.priority)
return task
def _apply_partner_visibility_filter(query, partner_user_id: int | None):
if not partner_user_id:
return query
return query.where(
or_(
ClientServiceTaskInstance.subscription.has(
ClientServiceSubscription.assigned_partner_user_id == partner_user_id
),
ClientServiceTaskInstance.client.has(Client.partner_id == partner_user_id),
)
)
def list_tasks_payload(
db: Session,
*,
tenant_id: int,
branch_id: int | None = None,
assigned_to_user_id: int | None = None,
partner_user_id: int | None = None,
status: str = "",
q: str = "",
include_inactive: bool = False,
financial_year: str | None = None,
):
today = date.today()
special_filter = (status or "").strip().lower()
query = (
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.client),
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.subscription),
)
.where(ClientServiceTaskInstance.tenant_id == tenant_id)
)
if financial_year:
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
if branch_id:
query = query.where(ClientServiceTaskInstance.branch_id == branch_id)
if assigned_to_user_id:
query = query.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id)
query = _apply_partner_visibility_filter(query, partner_user_id)
if special_filter and special_filter not in {"overdue", "due_today", "unassigned"}:
query = query.where(ClientServiceTaskInstance.status == special_filter)
if special_filter == "overdue":
query = query.where(
ClientServiceTaskInstance.internal_target_date.is_not(None),
ClientServiceTaskInstance.internal_target_date < today,
ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)),
)
elif special_filter == "due_today":
query = query.where(
ClientServiceTaskInstance.internal_target_date == today,
ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)),
)
elif special_filter == "unassigned":
query = query.where(ClientServiceTaskInstance.assigned_to_user_id.is_(None))
if not include_inactive:
query = query.where(ClientServiceTaskInstance.is_active.is_(True))
if q.strip():
term = f"%{q.strip()}%"
query = (
query.join(Client, Client.id == ClientServiceTaskInstance.client_id)
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServiceTaskInstance.service_catalogue_id)
.where(
or_(
ClientServiceTaskInstance.task_name.ilike(term),
Client.client_name.ilike(term),
Client.client_code.ilike(term),
ServiceCatalogue.service_code.ilike(term),
ServiceCatalogue.service_name.ilike(term),
)
)
)
rows = db.execute(
query.order_by(
ClientServiceTaskInstance.internal_target_date.is_(None),
ClientServiceTaskInstance.internal_target_date.asc(),
ClientServiceTaskInstance.status.asc(),
ClientServiceTaskInstance.sequence_no.asc(),
ClientServiceTaskInstance.id.desc(),
)
).scalars().all()
return [_decorate_task_for_tracker(task, today=today) for task in rows]
def get_task(
db: Session,
*,
tenant_id: int,
task_id: int,
branch_id: int | None = None,
assigned_to_user_id: int | None = None,
partner_user_id: int | None = None,
financial_year: str | None = None,
) -> ClientServiceTaskInstance | None:
query = (
select(ClientServiceTaskInstance)
.options(
selectinload(ClientServiceTaskInstance.client),
selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ClientServiceTaskInstance.assigned_to),
selectinload(ClientServiceTaskInstance.subscription),
)
.where(
ClientServiceTaskInstance.id == task_id,
ClientServiceTaskInstance.tenant_id == tenant_id,
)
)
if branch_id:
query = query.where(ClientServiceTaskInstance.branch_id == branch_id)
if financial_year:
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
if assigned_to_user_id:
query = query.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id)
query = _apply_partner_visibility_filter(query, partner_user_id)
task = db.execute(query).scalar_one_or_none()
return _decorate_task_for_tracker(task, today=date.today()) if task else None
def list_assignees_for_execution(db: Session, *, tenant_id: int, branch_id: int | None = None):
query = select(User).where(User.tenant_id == tenant_id, User.is_active.is_(True))
if branch_id:
query = query.where((User.branch_id == branch_id) | (User.branch_id.is_(None)))
return db.execute(query.order_by(User.full_name.asc(), User.email.asc())).scalars().all()
def apply_task_update(
task: ClientServiceTaskInstance,
*,
status: str,
priority: str,
assigned_to_user_id: int | None,
internal_target_date: date | None,
remarks: str,
is_active: bool,
user_id: int,
):
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
return
previous_status = task.status
task.status = _normalise_status(status)
task.priority = _normalise_priority(priority)
task.assigned_to_user_id = assigned_to_user_id
task.internal_target_date = internal_target_date
task.remarks = remarks.strip() or None
task.is_active = is_active
task.updated_by_user_id = user_id
now = datetime.now(timezone.utc)
if previous_status != "in_progress" and task.status == "in_progress" and not task.started_at_utc:
task.started_at_utc = now
if task.status == "completed" and not task.completed_at_utc:
task.completed_at_utc = now
if task.status != "completed":
task.completed_at_utc = None
def apply_bulk_task_update(
tasks: list[ClientServiceTaskInstance],
*,
status: str | None,
assigned_to_user_id: int | None,
update_assignee: bool,
internal_target_date: date | None,
update_internal_target_date: bool,
user_id: int,
) -> tuple[int, int]:
updated = 0
skipped = 0
for task in tasks:
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
skipped += 1
continue
previous_status = task.status
if status:
task.status = _normalise_status(status)
if update_assignee:
task.assigned_to_user_id = assigned_to_user_id
if update_internal_target_date:
task.internal_target_date = internal_target_date
task.updated_by_user_id = user_id
now = datetime.now(timezone.utc)
if previous_status != "in_progress" and task.status == "in_progress" and not task.started_at_utc:
task.started_at_utc = now
if task.status == "completed" and not task.completed_at_utc:
task.completed_at_utc = now
if task.status != "completed":
task.completed_at_utc = None
updated += 1
return updated, skipped
def get_tasks_for_bulk_update(
db: Session,
*,
tenant_id: int,
task_ids: list[int],
branch_id: int | None = None,
assigned_to_user_id: int | None = None,
partner_user_id: int | None = None,
financial_year: str | None = None,
) -> list[ClientServiceTaskInstance]:
if not task_ids:
return []
query = (
select(ClientServiceTaskInstance)
.options(selectinload(ClientServiceTaskInstance.subscription))
.where(
ClientServiceTaskInstance.tenant_id == tenant_id,
ClientServiceTaskInstance.id.in_(task_ids),
ClientServiceTaskInstance.is_active.is_(True),
)
)
if branch_id:
query = query.where(ClientServiceTaskInstance.branch_id == branch_id)
if financial_year:
query = query.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
if assigned_to_user_id:
query = query.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id)
query = _apply_partner_visibility_filter(query, partner_user_id)
return db.execute(query).scalars().all()
def list_task_comments(db: Session, *, tenant_id: int, task_id: int) -> list[ServiceTaskComment]:
return db.execute(
select(ServiceTaskComment)
.options(selectinload(ServiceTaskComment.created_by))
.where(
ServiceTaskComment.tenant_id == tenant_id,
ServiceTaskComment.task_instance_id == task_id,
ServiceTaskComment.is_deleted.is_(False),
)
.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
).scalars().all()
def add_task_comment(
db: Session,
*,
task: ClientServiceTaskInstance,
comment_type: str,
visibility: str,
message: str,
user_id: int,
) -> ServiceTaskComment | None:
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
return None
clean_message = (message or "").strip()
if not clean_message:
return None
row = ServiceTaskComment(
tenant_id=task.tenant_id,
branch_id=task.branch_id,
subscription_id=task.subscription_id,
task_instance_id=task.id,
comment_type=_normalise_comment_type(comment_type),
visibility=_normalise_visibility(visibility),
message=clean_message,
created_by_user_id=user_id,
)
db.add(row)
task.updated_by_user_id = user_id
return row
def list_client_visible_task_comments(
db: Session,
*,
tenant_id: int,
client_id: int,
limit: int = 20,
) -> list[ServiceTaskComment]:
"""Return client-visible task communication for one client dashboard.
This is intentionally read-only and scoped by tenant + client. Internal and
consultant-only notes are never returned to the client portal.
"""
safe_limit = max(1, min(int(limit or 20), 100))
rows = db.execute(
select(ServiceTaskComment)
.options(
selectinload(ServiceTaskComment.created_by),
selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.catalogue),
selectinload(ServiceTaskComment.task).selectinload(ClientServiceTaskInstance.subscription),
)
.join(ClientServiceTaskInstance, ClientServiceTaskInstance.id == ServiceTaskComment.task_instance_id)
.where(
ServiceTaskComment.tenant_id == tenant_id,
ServiceTaskComment.visibility == "client",
ServiceTaskComment.is_deleted.is_(False),
ClientServiceTaskInstance.client_id == client_id,
ClientServiceTaskInstance.tenant_id == tenant_id,
ClientServiceTaskInstance.is_active.is_(True),
)
.order_by(ServiceTaskComment.created_at_utc.desc(), ServiceTaskComment.id.desc())
.limit(safe_limit)
).scalars().all()
type_labels = dict(TASK_COMMENT_TYPES)
visibility_labels = dict(TASK_COMMENT_VISIBILITIES)
for row in rows:
row.comment_type_label = type_labels.get(row.comment_type, row.comment_type)
row.visibility_label = visibility_labels.get(row.visibility, row.visibility)
return rows
def dashboard_stats(
db: Session,
*,
tenant_id: int,
branch_id: int | None = None,
assigned_to_user_id: int | None = None,
partner_user_id: int | None = None,
financial_year: str | None = None,
):
today = date.today()
base = select(ClientServiceTaskInstance).where(
ClientServiceTaskInstance.tenant_id == tenant_id,
ClientServiceTaskInstance.is_active.is_(True),
)
if branch_id:
base = base.where(ClientServiceTaskInstance.branch_id == branch_id)
if financial_year:
base = base.where(ClientServiceTaskInstance.financial_year == financial_year.strip())
if assigned_to_user_id:
base = base.where(ClientServiceTaskInstance.assigned_to_user_id == assigned_to_user_id)
base = _apply_partner_visibility_filter(base, partner_user_id)
subq = base.subquery()
total = db.execute(select(func.count()).select_from(subq)).scalar_one()
pending = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "pending")).scalar_one()
progress = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "in_progress")).scalar_one()
blocked = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "blocked")).scalar_one()
completed = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "completed")).scalar_one()
not_applicable = db.execute(select(func.count()).select_from(subq).where(subq.c.status == "not_applicable")).scalar_one()
unassigned = db.execute(select(func.count()).select_from(subq).where(subq.c.assigned_to_user_id.is_(None))).scalar_one()
overdue = db.execute(
select(func.count()).select_from(subq).where(
subq.c.internal_target_date.is_not(None),
subq.c.internal_target_date < today,
subq.c.status.notin_(list(CLOSED_TASK_STATUSES)),
)
).scalar_one()
due_today = db.execute(
select(func.count()).select_from(subq).where(
subq.c.internal_target_date == today,
subq.c.status.notin_(list(CLOSED_TASK_STATUSES)),
)
).scalar_one()
return {
"total": total,
"pending": pending,
"in_progress": progress,
"blocked": blocked,
"completed": completed,
"not_applicable": not_applicable,
"unassigned": unassigned,
"overdue": overdue,
"due_today": due_today,
}