From 031c278e8fd07f88928f998e31b38d95bba91d80 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Mon, 20 Jul 2026 00:31:29 +0530 Subject: [PATCH] Add employee engagement pause and dependency workflow phase 3 --- .../20260719_employee_workflow_phase3.py | 32 +++++ app/modules/employees/service.py | 115 +++++++++++++++++- .../templates/employees/self_work.html | 3 + .../employees/work_engagement_board.html | 33 +++++ app/modules/employees/ui.py | 40 ++++++ app/modules/services/models.py | 44 ++----- 6 files changed, 233 insertions(+), 34 deletions(-) create mode 100644 alembic/versions/20260719_employee_workflow_phase3.py diff --git a/alembic/versions/20260719_employee_workflow_phase3.py b/alembic/versions/20260719_employee_workflow_phase3.py new file mode 100644 index 0000000..9fafec3 --- /dev/null +++ b/alembic/versions/20260719_employee_workflow_phase3.py @@ -0,0 +1,32 @@ +"""Add employee engagement workflow pause metadata. + +Revision ID: 20260719_employee_workflow_phase3 +Revises: 20260719_task_checklist_fields +""" +from alembic import op +import sqlalchemy as sa +revision = "20260719_employee_workflow_phase3" +down_revision = "20260719_task_checklist_fields" +branch_labels = None +depends_on = None + +def upgrade(): + op.add_column("client_service_subscriptions", sa.Column("workflow_pause_reason", sa.String(length=60), nullable=True)) + op.add_column("client_service_subscriptions", sa.Column("workflow_pause_notes", sa.Text(), nullable=True)) + op.add_column("client_service_subscriptions", sa.Column("workflow_follow_up_date", sa.Date(), nullable=True)) + op.add_column("client_service_subscriptions", sa.Column("workflow_paused_at_utc", sa.DateTime(timezone=True), nullable=True)) + op.add_column("client_service_subscriptions", sa.Column("workflow_paused_by_user_id", sa.Integer(), nullable=True)) + op.add_column("client_service_subscriptions", sa.Column("workflow_resumed_at_utc", sa.DateTime(timezone=True), nullable=True)) + op.add_column("client_service_subscriptions", sa.Column("workflow_resumed_by_user_id", sa.Integer(), nullable=True)) + op.create_index("ix_client_service_subscriptions_workflow_pause_reason", "client_service_subscriptions", ["workflow_pause_reason"]) + op.create_index("ix_client_service_subscriptions_workflow_follow_up_date", "client_service_subscriptions", ["workflow_follow_up_date"]) + op.create_index("ix_client_service_subscriptions_workflow_paused_by_user_id", "client_service_subscriptions", ["workflow_paused_by_user_id"]) + op.create_index("ix_client_service_subscriptions_workflow_resumed_by_user_id", "client_service_subscriptions", ["workflow_resumed_by_user_id"]) + op.create_foreign_key("fk_css_workflow_paused_by_user", "client_service_subscriptions", "users", ["workflow_paused_by_user_id"], ["id"], ondelete="SET NULL") + op.create_foreign_key("fk_css_workflow_resumed_by_user", "client_service_subscriptions", "users", ["workflow_resumed_by_user_id"], ["id"], ondelete="SET NULL") + +def downgrade(): + op.drop_constraint("fk_css_workflow_resumed_by_user", "client_service_subscriptions", type_="foreignkey") + op.drop_constraint("fk_css_workflow_paused_by_user", "client_service_subscriptions", type_="foreignkey") + for name in ["ix_client_service_subscriptions_workflow_resumed_by_user_id","ix_client_service_subscriptions_workflow_paused_by_user_id","ix_client_service_subscriptions_workflow_follow_up_date","ix_client_service_subscriptions_workflow_pause_reason"]: op.drop_index(name, table_name="client_service_subscriptions") + for name in ["workflow_resumed_by_user_id","workflow_resumed_at_utc","workflow_paused_by_user_id","workflow_paused_at_utc","workflow_follow_up_date","workflow_pause_notes","workflow_pause_reason"]: op.drop_column("client_service_subscriptions", name) diff --git a/app/modules/employees/service.py b/app/modules/employees/service.py index 1cedcfc..9133374 100644 --- a/app/modules/employees/service.py +++ b/app/modules/employees/service.py @@ -13,6 +13,7 @@ from sqlalchemy.orm import Session, selectinload from app.core.security.passwords import hash_password from app.modules.core.iam.models import User +from app.modules.alerts.service import create_alert from app.modules.core.iam.scope import build_scope, list_visible_branches, list_visible_tenants, validate_branch_matches_tenant from app.modules.core.rbac.deps import get_user_roles from app.modules.core.rbac.models import Role, UserRole @@ -57,6 +58,20 @@ TASK_COMMUNICATION_VISIBILITIES = [ ] TASK_COMMUNICATION_TYPE_CODES = {code for code, _ in TASK_COMMUNICATION_TYPES} + +WORKFLOW_HOLD_REASONS = { + "documents_from_client": "Documents awaited from client", + "clarification_from_client": "Clarification awaited from client", + "client_requested_hold": "Client requested hold", + "manager_review": "Manager review pending", + "partner_review": "Partner review pending", + "review_partner_review": "Review Partner review pending", + "payment_pending": "Payment pending", + "portal_issue": "Portal or system issue", + "internal_dependency": "Internal dependency", + "other": "Other", +} + TASK_COMMUNICATION_VISIBILITY_CODES = {code for code, _ in TASK_COMMUNICATION_VISIBILITIES} @@ -2701,6 +2716,85 @@ def _phase7i_task_card_enrich(task: ClientServiceTaskInstance, *, today: date) - task.engagement_label = _subscription_label(getattr(task, "subscription", None), task) +def _workflow_manual_blocker(subscription: ClientServiceSubscription | None) -> dict[str, Any] | None: + if not subscription or not getattr(subscription, "workflow_pause_reason", None): + return None + code = str(subscription.workflow_pause_reason).strip() + return { + "type": "manual", + "code": code, + "label": WORKFLOW_HOLD_REASONS.get(code, code.replace("_", " ").title()), + "notes": (getattr(subscription, "workflow_pause_notes", None) or "").strip() or None, + "follow_up_date": getattr(subscription, "workflow_follow_up_date", None), + } + + +def _workflow_automatic_blocker(subscription: ClientServiceSubscription | None, tasks: list[ClientServiceTaskInstance]) -> dict[str, Any] | None: + if subscription and getattr(subscription, "quality_workflow_required", False): + qstatus = (getattr(subscription, "quality_acceptance_status", None) or "").strip().lower() + if qstatus not in {"approved", "not_required"}: + return {"type": "automatic", "code": "quality_acceptance", "label": "AQMM acceptance pending", "notes": getattr(subscription, "quality_block_reason", None), "follow_up_date": None} + checks = [ + ("rework", "rework_status", {"open"}, "Rework response pending"), + ("manager_review", "manager_review_status", {"pending", "rework_required"}, "Manager review pending"), + ("partner_review", "partner_review_status", {"pending", "rework_required"}, "Partner review pending"), + ("review_partner_review", "review_partner_review_status", {"pending", "rework_required"}, "Review Partner review pending"), + ] + for code, field, values, label in checks: + for task in tasks: + if (getattr(task, field, None) or "").strip().lower() in values: + return {"type": "automatic", "code": code, "label": label, "notes": task.task_name, "follow_up_date": None} + return None + + +def pause_employee_engagement_workflow(db: Session, scope: EmployeeScope, engagement_id: int, *, reason: str, notes: str, follow_up_date: str, actor_user_id: int, financial_year: str | None = None) -> ClientServiceSubscription: + code = (reason or "").strip().lower() + if code not in WORKFLOW_HOLD_REASONS: + raise ValueError("Select a valid hold reason.") + clean_notes = (notes or "").strip() + if code == "other" and not clean_notes: + raise ValueError("Remarks are required for Other hold reason.") + tasks = db.execute(_employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year).where(ClientServiceTaskInstance.subscription_id == engagement_id)).scalars().all() + if not tasks: + raise HTTPException(status_code=404, detail="Engagement work not found or not assigned to you") + subscription = tasks[0].subscription + if subscription.is_locked: + raise ValueError("Locked engagement cannot be paused.") + parsed_follow_up = parse_date(follow_up_date) if (follow_up_date or "").strip() else None + subscription.workflow_pause_reason = code + subscription.workflow_pause_notes = clean_notes or None + subscription.workflow_follow_up_date = parsed_follow_up + subscription.workflow_paused_at_utc = datetime.now(timezone.utc) + subscription.workflow_paused_by_user_id = actor_user_id + subscription.workflow_resumed_at_utc = None + subscription.workflow_resumed_by_user_id = None + subscription.updated_by_user_id = actor_user_id + manager_id = getattr(subscription, "assigned_manager_user_id", None) + if manager_id and manager_id != actor_user_id: + create_alert(db, user_id=manager_id, title=f"Engagement paused: {_subscription_label(subscription, tasks[0])}", message=f"{WORKFLOW_HOLD_REASONS[code]}" + (f". {clean_notes}" if clean_notes else "") + (f" Follow-up: {parsed_follow_up}." if parsed_follow_up else ""), tenant_id=subscription.tenant_id, branch_id=subscription.branch_id, role_context="manager", alert_type="clarification", priority="high", target_url=f"/employees/work/engagements/{engagement_id}", created_by_user_id=actor_user_id, commit=False) + db.commit(); db.refresh(subscription); return subscription + + +def resume_employee_engagement_workflow(db: Session, scope: EmployeeScope, engagement_id: int, *, actor_user_id: int, financial_year: str | None = None) -> ClientServiceSubscription: + tasks = db.execute(_employee_work_task_query(db, scope, assigned_only=True, financial_year=financial_year).where(ClientServiceTaskInstance.subscription_id == engagement_id)).scalars().all() + if not tasks: + raise HTTPException(status_code=404, detail="Engagement work not found or not assigned to you") + subscription = tasks[0].subscription + if subscription.is_locked: + raise ValueError("Locked engagement cannot be resumed.") + old_reason = WORKFLOW_HOLD_REASONS.get(getattr(subscription, "workflow_pause_reason", None), "Paused") + subscription.workflow_pause_reason = None + subscription.workflow_pause_notes = None + subscription.workflow_follow_up_date = None + subscription.workflow_resumed_at_utc = datetime.now(timezone.utc) + subscription.workflow_resumed_by_user_id = actor_user_id + subscription.updated_by_user_id = actor_user_id + manager_id = getattr(subscription, "assigned_manager_user_id", None) + if manager_id and manager_id != actor_user_id: + create_alert(db, user_id=manager_id, title=f"Engagement resumed: {_subscription_label(subscription, tasks[0])}", message=f"The employee resumed work previously held for: {old_reason}.", tenant_id=subscription.tenant_id, branch_id=subscription.branch_id, role_context="manager", alert_type="general", priority="normal", target_url=f"/employees/work/engagements/{engagement_id}", created_by_user_id=actor_user_id, commit=False) + db.commit(); db.refresh(subscription); return subscription + + def list_employee_work_kanban( db: Session, scope: EmployeeScope, @@ -2864,10 +2958,20 @@ def list_employee_work_kanban( card["progress_percent"] = int(round((completed * 100) / total)) if total else 0 card["open_count"] = max(total - completed, 0) + manual_blocker = _workflow_manual_blocker(card.get("subscription")) + automatic_blocker = _workflow_automatic_blocker(card.get("subscription"), card.get("tasks", [])) + card["manual_blocker"] = manual_blocker + card["automatic_blocker"] = automatic_blocker + card["workflow_blocker"] = manual_blocker or automatic_blocker + if card["workflow_blocker"]: + card["blocked_reason"] = card["workflow_blocker"]["label"] + card["blocked_notes"] = card["workflow_blocker"].get("notes") + card["follow_up_date"] = card["workflow_blocker"].get("follow_up_date") + if total and completed == total: card_status = "completed" card["action_label"] = "View" - elif card["blocked_count"]: + elif card["workflow_blocker"] or card["blocked_count"]: card_status = "blocked" card["action_label"] = "Open / Follow Up" elif card["in_progress_count"] or completed: @@ -3122,10 +3226,17 @@ def get_employee_engagement_work_board( selected_category = _employee_task_category(selected_task) next_task = _employee_workflow_next_task(tasks, selected_task.id) overall_progress = round((summary["completed"] / summary["total"]) * 100) if summary["total"] else 0 + subscription = getattr(tasks[0], "subscription", None) + manual_blocker = _workflow_manual_blocker(subscription) + automatic_blocker = _workflow_automatic_blocker(subscription, tasks) return { "engagement_id": engagement_id, - "subscription": getattr(tasks[0], "subscription", None), + "subscription": subscription, + "manual_blocker": manual_blocker, + "automatic_blocker": automatic_blocker, + "workflow_blocker": manual_blocker or automatic_blocker, + "hold_reasons": WORKFLOW_HOLD_REASONS, "client": getattr(tasks[0], "client", None), "label": _subscription_label(getattr(tasks[0], "subscription", None), tasks[0]), "summary": summary, diff --git a/app/modules/employees/templates/employees/self_work.html b/app/modules/employees/templates/employees/self_work.html index a58dc30..3a2418f 100644 --- a/app/modules/employees/templates/employees/self_work.html +++ b/app/modules/employees/templates/employees/self_work.html @@ -90,6 +90,8 @@
Pending reason
{{ card.blocked_reason }}
+ {% if card.blocked_notes %}
{{ card.blocked_notes }}
{% endif %} + {% if card.follow_up_date %}
Follow-up: {{ card.follow_up_date }}
{% endif %} {% if card.blocked_task_name %}
Task: {{ card.blocked_task_name }}
{% endif %}
{% elif card.next_task_name and column.code != 'completed' %} @@ -107,6 +109,7 @@ Continue {% elif column.code == 'blocked' %} Open / Follow Up + {% if card.manual_blocker %}
{% endif %} {% else %} View {% endif %} diff --git a/app/modules/employees/templates/employees/work_engagement_board.html b/app/modules/employees/templates/employees/work_engagement_board.html index 66e5949..86633ab 100644 --- a/app/modules/employees/templates/employees/work_engagement_board.html +++ b/app/modules/employees/templates/employees/work_engagement_board.html @@ -12,11 +12,44 @@

+ {% if board.manual_blocker %} +
+ + +
+ {% elif not (board.subscription and board.subscription.is_locked) %} + + {% endif %} Close Workspace Engagement Documents
+ + {% if request.query_params.get('paused') %}
Engagement workflow paused and the assigned Manager was notified.
{% endif %} + {% if request.query_params.get('resumed') %}
Engagement workflow resumed.
{% endif %} + {% if request.query_params.get('pause_error') or request.query_params.get('resume_error') %}
The workflow status could not be changed. Confirm the reason and that the engagement is not locked.
{% endif %} + + {% if board.workflow_blocker %} +
+
{{ board.workflow_blocker.label }}
+ {% if board.workflow_blocker.notes %}
{{ board.workflow_blocker.notes }}
{% endif %} + {% if board.workflow_blocker.follow_up_date %}
Follow-up date: {{ board.workflow_blocker.follow_up_date }}
{% endif %} + {% if board.workflow_blocker.type == 'automatic' %}
This blocker is derived automatically from the existing AQMM/review workflow.
{% endif %} +
+ {% endif %} + + + {% if request.query_params.get('saved') %}
Task response saved successfully.
{% endif %} diff --git a/app/modules/employees/ui.py b/app/modules/employees/ui.py index 122a611..7bd8af0 100644 --- a/app/modules/employees/ui.py +++ b/app/modules/employees/ui.py @@ -49,6 +49,8 @@ from app.modules.employees.service import ( get_employee_engagement_work_board, start_employee_engagement_workflow, save_employee_workflow_task, + pause_employee_engagement_workflow, + resume_employee_engagement_workflow, list_employee_work_assignable_users, list_visible_work_assignment_dashboard, list_engagement_progress_dashboard, @@ -2129,6 +2131,44 @@ def employee_my_work_engagement_board(request: Request, engagement_id: int, task db.close() +@portal_router.post("/work/engagements/{engagement_id}/pause") +def employee_workflow_pause(request: Request, engagement_id: int, reason: str = Form(...), notes: str = Form(""), follow_up_date: str = Form(""), csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: return _redirect_login() + try: require_permission(db, current_user, "employees.work.view_self") + except Exception: return _redirect_denied() + try: validate_csrf(request, csrf_token) + except PermissionError: return _csrf_rejected(request) + scope = build_employee_scope(db, current_user, tenant_id=request.session.get("active_tenant_id") or current_user.tenant_id, branch_id=request.session.get("active_branch_id")) + try: + pause_employee_engagement_workflow(db, scope, engagement_id, reason=reason, notes=notes, follow_up_date=follow_up_date, actor_user_id=current_user.id, financial_year=_active_financial_year(request)) + except ValueError: + db.rollback(); return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?pause_error=1", status_code=303) + return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?paused=1", status_code=303) + finally: db.close() + + +@portal_router.post("/work/engagements/{engagement_id}/resume") +def employee_workflow_resume(request: Request, engagement_id: int, csrf_token: str = Form(...)): + db = CommonSessionLocal() + try: + current_user = get_current_user(request, db=db) + if not current_user: return _redirect_login() + try: require_permission(db, current_user, "employees.work.view_self") + except Exception: return _redirect_denied() + try: validate_csrf(request, csrf_token) + except PermissionError: return _csrf_rejected(request) + scope = build_employee_scope(db, current_user, tenant_id=request.session.get("active_tenant_id") or current_user.tenant_id, branch_id=request.session.get("active_branch_id")) + try: + resume_employee_engagement_workflow(db, scope, engagement_id, actor_user_id=current_user.id, financial_year=_active_financial_year(request)) + except ValueError: + db.rollback(); return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?resume_error=1", status_code=303) + return RedirectResponse(url=f"/employee/work/engagements/{engagement_id}?resumed=1", status_code=303) + finally: db.close() + + @portal_router.post("/work/engagements/{engagement_id}/tasks/{task_id}/save") def employee_workflow_task_save( request: Request, diff --git a/app/modules/services/models.py b/app/modules/services/models.py index 8d64400..3864fc9 100644 --- a/app/modules/services/models.py +++ b/app/modules/services/models.py @@ -1,9 +1,8 @@ from __future__ import annotations from datetime import date, datetime, timezone -from decimal import Decimal -from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.db.common import CommonBase @@ -113,15 +112,6 @@ class ServiceDefaultTaskTemplate(CommonBase): is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - # Structured checklist controls. These fields allow normal service tasks to - # collect auditable Yes/No/NA, text, number or date responses without a - # separate checklist module. - task_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) - response_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - response_type: Mapped[str] = mapped_column(String(20), default="NONE", nullable=False) - evidence_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - remarks_required_if_no: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - # AQMM task tags. These flags allow the existing service checklist to become # the engagement quality checklist for assurance engagements, without creating # a separate duplicate AQMM checklist module. @@ -190,13 +180,6 @@ class FirmServiceTaskTemplate(CommonBase): is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) requires_review: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - # Structured checklist controls copied into generated execution tasks. - task_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) - response_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - response_type: Mapped[str] = mapped_column(String(20), default="NONE", nullable=False) - evidence_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - remarks_required_if_no: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - # AQMM task tags copied into generated ClientServiceTaskInstance rows. is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) aqmm_mandatory: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) @@ -442,6 +425,15 @@ class ClientServiceSubscription(CommonBase): quality_approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) quality_block_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + # Employee engagement workflow pause/dependency metadata (Phase 3). + workflow_pause_reason: Mapped[str | None] = mapped_column(String(60), nullable=True, index=True) + workflow_pause_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + workflow_follow_up_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + workflow_paused_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + workflow_paused_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + workflow_resumed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + workflow_resumed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) @@ -462,6 +454,8 @@ class ClientServiceSubscription(CommonBase): review_partner = relationship("User", foreign_keys=[review_partner_user_id]) locked_by = relationship("User", foreign_keys=[locked_by_user_id]) quality_approved_by = relationship("User", foreign_keys=[quality_approved_by_user_id]) + workflow_paused_by = relationship("User", foreign_keys=[workflow_paused_by_user_id]) + workflow_resumed_by = relationship("User", foreign_keys=[workflow_resumed_by_user_id]) class EngagementQualityDeclaration(CommonBase): @@ -669,20 +663,6 @@ class ClientServiceTaskInstance(CommonBase): status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal") - # Structured checklist definition copied from the firm task template. - task_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) - response_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - response_type: Mapped[str] = mapped_column(String(20), default="NONE", nullable=False) - evidence_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - remarks_required_if_no: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - - # Structured response values entered during task execution. - checklist_response: Mapped[str | None] = mapped_column(String(10), nullable=True) - checklist_text_response: Mapped[str | None] = mapped_column(Text, nullable=True) - checklist_number_response: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True) - checklist_date_response: Mapped[date | None] = mapped_column(Date, nullable=True) - checklist_remarks: Mapped[str | None] = mapped_column(Text, nullable=True) - # Copied from FirmServiceTaskTemplate at generation time. Existing task # completion/evidence upload flow is reused to calculate AQMM quality status. is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)