diff --git a/alembic/versions/20260723_phase3_consultant_task_execution.py b/alembic/versions/20260723_phase3_consultant_task_execution.py new file mode 100644 index 0000000..6d2db84 --- /dev/null +++ b/alembic/versions/20260723_phase3_consultant_task_execution.py @@ -0,0 +1,52 @@ +"""Phase 3 consultant task execution. + +Revision ID: 20260723_phase3_consultant_task_execution +Revises: 20260723_phase2_consultant_visibility_document_requests +""" +from alembic import op +import sqlalchemy as sa + +revision = "20260723_phase3_consultant_task_execution" +down_revision = "20260723_phase2_consultant_visibility_document_requests" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("client_service_task_instances", sa.Column("execution_mode", sa.String(30), nullable=False, server_default="internal")) + op.add_column("client_service_task_instances", sa.Column("assigned_consultant_id", sa.Integer(), nullable=True)) + op.add_column("client_service_task_instances", sa.Column("consultant_assignment_status", sa.String(30), nullable=False, server_default="not_applicable")) + op.add_column("client_service_task_instances", sa.Column("consultant_due_date", sa.Date(), nullable=True)) + op.add_column("client_service_task_instances", sa.Column("consultant_offered_at_utc", sa.DateTime(timezone=True), nullable=True)) + op.add_column("client_service_task_instances", sa.Column("consultant_accepted_at_utc", sa.DateTime(timezone=True), nullable=True)) + op.add_column("client_service_task_instances", sa.Column("consultant_declined_at_utc", sa.DateTime(timezone=True), nullable=True)) + op.add_column("client_service_task_instances", sa.Column("consultant_decline_reason", sa.Text(), nullable=True)) + op.add_column("client_service_task_instances", sa.Column("consultant_started_at_utc", sa.DateTime(timezone=True), nullable=True)) + op.add_column("client_service_task_instances", sa.Column("consultant_submitted_at_utc", sa.DateTime(timezone=True), nullable=True)) + op.add_column("client_service_task_instances", sa.Column("consultant_submission_note", sa.Text(), nullable=True)) + op.add_column("client_service_task_instances", sa.Column("consultant_approved_at_utc", sa.DateTime(timezone=True), nullable=True)) + op.add_column("client_service_task_instances", sa.Column("consultant_approved_by_user_id", sa.Integer(), nullable=True)) + op.create_foreign_key("fk_task_instance_assigned_consultant", "client_service_task_instances", "consultant_profiles", ["assigned_consultant_id"], ["id"], ondelete="SET NULL") + op.create_foreign_key("fk_task_instance_consultant_approved_by", "client_service_task_instances", "users", ["consultant_approved_by_user_id"], ["id"], ondelete="SET NULL") + op.create_index("ix_task_instance_execution_mode", "client_service_task_instances", ["execution_mode"]) + op.create_index("ix_task_instance_assigned_consultant", "client_service_task_instances", ["assigned_consultant_id"]) + op.create_index("ix_task_instance_consultant_assignment_status", "client_service_task_instances", ["consultant_assignment_status"]) + op.create_index("ix_task_instance_consultant_due_date", "client_service_task_instances", ["consultant_due_date"]) + op.create_index("ix_task_instance_consultant_approved_by", "client_service_task_instances", ["consultant_approved_by_user_id"]) + + +def downgrade(): + op.drop_index("ix_task_instance_consultant_approved_by", table_name="client_service_task_instances") + op.drop_index("ix_task_instance_consultant_due_date", table_name="client_service_task_instances") + op.drop_index("ix_task_instance_consultant_assignment_status", table_name="client_service_task_instances") + op.drop_index("ix_task_instance_assigned_consultant", table_name="client_service_task_instances") + op.drop_index("ix_task_instance_execution_mode", table_name="client_service_task_instances") + op.drop_constraint("fk_task_instance_consultant_approved_by", "client_service_task_instances", type_="foreignkey") + op.drop_constraint("fk_task_instance_assigned_consultant", "client_service_task_instances", type_="foreignkey") + for column in [ + "consultant_approved_by_user_id", "consultant_approved_at_utc", "consultant_submission_note", + "consultant_submitted_at_utc", "consultant_started_at_utc", "consultant_decline_reason", + "consultant_declined_at_utc", "consultant_accepted_at_utc", "consultant_offered_at_utc", + "consultant_due_date", "consultant_assignment_status", "assigned_consultant_id", "execution_mode", + ]: + op.drop_column("client_service_task_instances", column) diff --git a/app/modules/consultants/portal_service.py b/app/modules/consultants/portal_service.py index 3c6f458..d8c2e94 100644 --- a/app/modules/consultants/portal_service.py +++ b/app/modules/consultants/portal_service.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections import OrderedDict -from datetime import date, timedelta +from datetime import date, datetime, timedelta, timezone from typing import Any from sqlalchemy import or_, select @@ -171,3 +171,55 @@ def get_consultant_document_centre(db: Session, *, consultant: ConsultantProfile term=f"%{q.strip()}%"; stmt=stmt.where(or_(PermanentClientDocument.title.ilike(term),PermanentClientDocument.category.ilike(term),PermanentClientDocument.document_code.ilike(term))) permanent_documents=db.execute(stmt.order_by(PermanentClientDocument.created_at_utc.desc()).limit(200)).scalars().all() return {"engagement_documents":engagement_documents,"permanent_documents":permanent_documents,"total":len(engagement_documents)+len(permanent_documents)} + + +def consultant_can_execute_task(*, consultant: ConsultantProfile, task: ClientServiceTaskInstance) -> bool: + return bool(task.execution_mode == "consultant" and task.assigned_consultant_id == consultant.id and task.is_active and not task.is_locked) + + +def update_consultant_assignment(db: Session, *, consultant: ConsultantProfile, task: ClientServiceTaskInstance, action: str, note: str, user_id: int) -> tuple[bool, str | None]: + if not consultant_can_execute_task(consultant=consultant, task=task): + return False, "This task is not assigned to your consultant profile." + action = (action or "").strip().lower() + note = (note or "").strip() + current = (task.consultant_assignment_status or "not_applicable").lower() + now = datetime.now(timezone.utc) + message = None + if action == "accept" and current == "offered": + task.consultant_assignment_status = "accepted" + task.consultant_accepted_at_utc = now + task.consultant_declined_at_utc = None + task.consultant_decline_reason = None + task.status = "pending" + message = "Consultant accepted the assignment." + elif action == "decline" and current == "offered": + if not note: + return False, "Decline reason is required." + task.consultant_assignment_status = "declined" + task.consultant_declined_at_utc = now + task.consultant_decline_reason = note + task.status = "pending" + message = f"Consultant declined the assignment: {note}" + elif action == "start" and current in {"accepted", "rework_required"}: + task.consultant_assignment_status = "in_progress" + task.consultant_started_at_utc = task.consultant_started_at_utc or now + task.status = "in_progress" + if current == "rework_required": + task.rework_status = "in_progress" + message = "Consultant started work on the assignment." + elif action == "submit" and current in {"accepted", "in_progress", "rework_required"}: + if not note: + return False, "Submission note is required." + task.consultant_assignment_status = "submitted" + task.consultant_submitted_at_utc = now + task.consultant_submission_note = note + task.status = "pending_review" + if task.rework_status in {"requested", "in_progress"}: + task.rework_status = "resolved" + task.rework_resolved_at_utc = now + message = f"Consultant submitted the assignment for firm review: {note}" + else: + return False, "This action is not allowed for the current assignment status." + task.updated_by_user_id = user_id + db.add(ServiceTaskComment(tenant_id=task.tenant_id, branch_id=task.branch_id, subscription_id=task.subscription_id, task_instance_id=task.id, comment_type="consultant_execution", visibility="consultant", message=message, created_by_user_id=user_id)) + return True, None diff --git a/app/modules/consultants/templates/consultants/assignment_detail.html b/app/modules/consultants/templates/consultants/assignment_detail.html index a0faefb..f0fdc1e 100644 --- a/app/modules/consultants/templates/consultants/assignment_detail.html +++ b/app/modules/consultants/templates/consultants/assignment_detail.html @@ -22,6 +22,24 @@ {% if task.description %}
Status: {{ task.consultant_assignment_status.replace('_',' ').title() }}{% if task.consultant_due_date %} • Due {{ task.consultant_due_date.strftime('%d-%m-%Y') }}{% endif %}