Add employee engagement pause and dependency workflow phase 3

This commit is contained in:
A R R R Associates
2026-07-20 00:31:29 +05:30
parent dfdc74b534
commit 031c278e8f
6 changed files with 233 additions and 34 deletions
@@ -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)
+113 -2
View File
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session, selectinload
from app.core.security.passwords import hash_password from app.core.security.passwords import hash_password
from app.modules.core.iam.models import User 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.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.deps import get_user_roles
from app.modules.core.rbac.models import Role, UserRole 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} 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} 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) 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( def list_employee_work_kanban(
db: Session, db: Session,
scope: EmployeeScope, scope: EmployeeScope,
@@ -2864,10 +2958,20 @@ def list_employee_work_kanban(
card["progress_percent"] = int(round((completed * 100) / total)) if total else 0 card["progress_percent"] = int(round((completed * 100) / total)) if total else 0
card["open_count"] = max(total - completed, 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: if total and completed == total:
card_status = "completed" card_status = "completed"
card["action_label"] = "View" card["action_label"] = "View"
elif card["blocked_count"]: elif card["workflow_blocker"] or card["blocked_count"]:
card_status = "blocked" card_status = "blocked"
card["action_label"] = "Open / Follow Up" card["action_label"] = "Open / Follow Up"
elif card["in_progress_count"] or completed: elif card["in_progress_count"] or completed:
@@ -3122,10 +3226,17 @@ def get_employee_engagement_work_board(
selected_category = _employee_task_category(selected_task) selected_category = _employee_task_category(selected_task)
next_task = _employee_workflow_next_task(tasks, selected_task.id) next_task = _employee_workflow_next_task(tasks, selected_task.id)
overall_progress = round((summary["completed"] / summary["total"]) * 100) if summary["total"] else 0 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 { return {
"engagement_id": engagement_id, "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), "client": getattr(tasks[0], "client", None),
"label": _subscription_label(getattr(tasks[0], "subscription", None), tasks[0]), "label": _subscription_label(getattr(tasks[0], "subscription", None), tasks[0]),
"summary": summary, "summary": summary,
@@ -90,6 +90,8 @@
<div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-3"> <div class="mt-4 rounded-xl border border-rose-200 bg-rose-50 p-3">
<div class="text-xs font-semibold uppercase text-rose-700">Pending reason</div> <div class="text-xs font-semibold uppercase text-rose-700">Pending reason</div>
<div class="mt-1 text-sm font-medium text-rose-900">{{ card.blocked_reason }}</div> <div class="mt-1 text-sm font-medium text-rose-900">{{ card.blocked_reason }}</div>
{% if card.blocked_notes %}<div class="mt-1 text-xs text-rose-700">{{ card.blocked_notes }}</div>{% endif %}
{% if card.follow_up_date %}<div class="mt-2 text-xs font-semibold text-rose-800">Follow-up: {{ card.follow_up_date }}</div>{% endif %}
{% if card.blocked_task_name %}<div class="mt-1 text-xs text-rose-700">Task: {{ card.blocked_task_name }}</div>{% endif %} {% if card.blocked_task_name %}<div class="mt-1 text-xs text-rose-700">Task: {{ card.blocked_task_name }}</div>{% endif %}
</div> </div>
{% elif card.next_task_name and column.code != 'completed' %} {% elif card.next_task_name and column.code != 'completed' %}
@@ -107,6 +109,7 @@
<a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Continue</a> <a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Continue</a>
{% elif column.code == 'blocked' %} {% elif column.code == 'blocked' %}
<a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-700">Open / Follow Up</a> <a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-700">Open / Follow Up</a>
{% if card.manual_blocker %}<form method="post" action="/employee/work/engagements/{{ card.engagement_id }}/resume" class="mt-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="inline-flex w-full justify-center rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-700 hover:bg-emerald-100">Resume Workflow</button></form>{% endif %}
{% else %} {% else %}
<a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">View</a> <a href="/employee/work/engagements/{{ card.engagement_id }}" class="mt-4 inline-flex w-full justify-center rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">View</a>
{% endif %} {% endif %}
@@ -12,11 +12,44 @@
</p> </p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{% if board.manual_blocker %}
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/resume">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Resume Workflow</button>
</form>
{% elif not (board.subscription and board.subscription.is_locked) %}
<button type="button" onclick="document.getElementById('pause-workflow-panel').classList.toggle('hidden')" class="rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-800 hover:bg-amber-100">Pause / Hold</button>
{% endif %}
<a href="/employee/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Close Workspace</a> <a href="/employee/work" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Close Workspace</a>
<a href="/documents/engagements/{{ board.engagement_id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Engagement Documents</a> <a href="/documents/engagements/{{ board.engagement_id }}" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Engagement Documents</a>
</div> </div>
</div> </div>
{% if request.query_params.get('paused') %}<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">Engagement workflow paused and the assigned Manager was notified.</div>{% endif %}
{% if request.query_params.get('resumed') %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Engagement workflow resumed.</div>{% endif %}
{% if request.query_params.get('pause_error') or request.query_params.get('resume_error') %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">The workflow status could not be changed. Confirm the reason and that the engagement is not locked.</div>{% endif %}
{% if board.workflow_blocker %}
<div class="rounded-2xl border {% if board.workflow_blocker.type == 'manual' %}border-amber-300 bg-amber-50{% else %}border-violet-300 bg-violet-50{% endif %} p-4">
<div class="font-semibold text-slate-900">{{ board.workflow_blocker.label }}</div>
{% if board.workflow_blocker.notes %}<div class="mt-1 text-sm text-slate-700">{{ board.workflow_blocker.notes }}</div>{% endif %}
{% if board.workflow_blocker.follow_up_date %}<div class="mt-2 text-sm font-medium text-amber-900">Follow-up date: {{ board.workflow_blocker.follow_up_date }}</div>{% endif %}
{% if board.workflow_blocker.type == 'automatic' %}<div class="mt-2 text-xs text-violet-700">This blocker is derived automatically from the existing AQMM/review workflow.</div>{% endif %}
</div>
{% endif %}
<div id="pause-workflow-panel" class="hidden rounded-2xl border border-amber-300 bg-white p-5 shadow-soft">
<h3 class="font-semibold text-slate-900">Pause / Hold Engagement</h3>
<form method="post" action="/employee/work/engagements/{{ board.engagement_id }}/pause" class="mt-4 grid gap-4 md:grid-cols-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div><label class="mb-2 block text-sm font-medium text-slate-700">Reason</label><select name="reason" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Select reason</option>{% for code, label in board.hold_reasons.items() %}<option value="{{ code }}">{{ label }}</option>{% endfor %}</select></div>
<div><label class="mb-2 block text-sm font-medium text-slate-700">Follow-up Date</label><input type="date" name="follow_up_date" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"></div>
<div class="md:col-span-3"><label class="mb-2 block text-sm font-medium text-slate-700">Remarks / Dependency Details</label><textarea name="notes" rows="3" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm" placeholder="Documents pending, query details, reviewer dependency or other follow-up information"></textarea></div>
<div class="md:col-span-3 flex justify-end"><button type="submit" class="rounded-xl bg-amber-600 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-700">Pause and Notify Manager</button></div>
</form>
</div>
{% if request.query_params.get('saved') %} {% if request.query_params.get('saved') %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Task response saved successfully.</div> <div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Task response saved successfully.</div>
{% endif %} {% endif %}
+40
View File
@@ -49,6 +49,8 @@ from app.modules.employees.service import (
get_employee_engagement_work_board, get_employee_engagement_work_board,
start_employee_engagement_workflow, start_employee_engagement_workflow,
save_employee_workflow_task, save_employee_workflow_task,
pause_employee_engagement_workflow,
resume_employee_engagement_workflow,
list_employee_work_assignable_users, list_employee_work_assignable_users,
list_visible_work_assignment_dashboard, list_visible_work_assignment_dashboard,
list_engagement_progress_dashboard, list_engagement_progress_dashboard,
@@ -2129,6 +2131,44 @@ def employee_my_work_engagement_board(request: Request, engagement_id: int, task
db.close() 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") @portal_router.post("/work/engagements/{engagement_id}/tasks/{task_id}/save")
def employee_workflow_task_save( def employee_workflow_task_save(
request: Request, request: Request,
+12 -32
View File
@@ -1,9 +1,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime, timezone 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db.common import CommonBase 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) is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, 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 # AQMM task tags. These flags allow the existing service checklist to become
# the engagement quality checklist for assurance engagements, without creating # the engagement quality checklist for assurance engagements, without creating
# a separate duplicate AQMM checklist module. # a separate duplicate AQMM checklist module.
@@ -190,13 +180,6 @@ class FirmServiceTaskTemplate(CommonBase):
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_mandatory: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
requires_review: Mapped[bool] = mapped_column(Boolean, default=False, 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. # AQMM task tags copied into generated ClientServiceTaskInstance rows.
is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) 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) 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_approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
quality_block_reason: Mapped[str | None] = mapped_column(Text, 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_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) 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) 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]) review_partner = relationship("User", foreign_keys=[review_partner_user_id])
locked_by = relationship("User", foreign_keys=[locked_by_user_id]) locked_by = relationship("User", foreign_keys=[locked_by_user_id])
quality_approved_by = relationship("User", foreign_keys=[quality_approved_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): class EngagementQualityDeclaration(CommonBase):
@@ -669,20 +663,6 @@ class ClientServiceTaskInstance(CommonBase):
status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True)
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal") 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 # Copied from FirmServiceTaskTemplate at generation time. Existing task
# completion/evidence upload flow is reused to calculate AQMM quality status. # 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) is_aqmm_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)