Add AQMM task evidence and review notes workflow

This commit is contained in:
A R R R Associates
2026-07-07 12:03:07 +05:30
parent 05690a220c
commit 91b1811eb8
10 changed files with 816 additions and 11 deletions
+240 -6
View File
@@ -32,9 +32,28 @@ CLOSED_TASK_STATUSES = {"completed", "not_applicable", "cancelled"}
TASK_COMMENT_TYPES = [
("internal_note", "Internal Note"),
("staff_work_note", "Staff Work Note"),
("client_clarification", "Client Clarification"),
("consultant_clarification", "Consultant Clarification"),
("manager_review_note", "Manager Review Note"),
("partner_review_note", "Partner Review Note"),
("review_partner_review_note", "Review Partner Review Note"),
("rework_note", "Rework Note"),
]
AQMM_REVIEW_STATUSES = [
("not_required", "Not Required"),
("pending", "Pending"),
("reviewed", "Reviewed"),
("rework_required", "Rework Required"),
]
AQMM_EVIDENCE_STATUSES = [
("not_required", "Not Required"),
("pending", "Pending"),
("uploaded", "Uploaded"),
("accepted", "Accepted"),
("rejected", "Rejected / Insufficient"),
]
TASK_COMMENT_VISIBILITIES = [
@@ -243,34 +262,114 @@ def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceS
def _latest_task_documents(db: Session, task_id: int) -> list[EngagementDocument]:
return db.execute(
select(EngagementDocument).where(
EngagementDocument.task_instance_id == int(task_id),
EngagementDocument.is_deleted.is_(False),
)
).scalars().all()
def _task_documents_for_aqmm(db: Session, task: ClientServiceTaskInstance) -> list[EngagementDocument]:
docs = _latest_task_documents(db, task.id)
if getattr(task, "is_aqmm_task", False):
return [doc for doc in docs if getattr(doc, "evidence_status", "not_required") != "rejected"]
return docs
def _task_has_evidence(db: Session, task_id: int) -> bool:
return bool(
db.execute(
select(EngagementDocument.id).where(
EngagementDocument.task_instance_id == int(task_id),
EngagementDocument.is_deleted.is_(False),
EngagementDocument.evidence_status != "rejected",
)
).first()
)
def _evidence_status_for_task(db: Session, task: ClientServiceTaskInstance) -> str:
if not getattr(task, "is_aqmm_task", False) or not getattr(task, "aqmm_evidence_required", False):
return "not_required"
docs = _latest_task_documents(db, task.id)
if not docs:
return "pending"
if any(getattr(doc, "evidence_status", "uploaded") == "accepted" for doc in docs):
return "accepted"
if any(getattr(doc, "evidence_status", "uploaded") in {"uploaded", "pending_review"} for doc in docs):
return "uploaded"
return "pending"
def _required_review_issue(task: ClientServiceTaskInstance) -> str | None:
if getattr(task, "aqmm_manager_review_required", False) and task.manager_review_status != "reviewed":
return "Manager review pending" if task.manager_review_status != "rework_required" else "Manager requested rework"
if getattr(task, "aqmm_partner_review_required", False) and task.partner_review_status != "reviewed":
return "Partner review pending" if task.partner_review_status != "rework_required" else "Partner requested rework"
if getattr(task, "aqmm_review_partner_required", False) and task.review_partner_review_status != "reviewed":
return "Review partner review pending" if task.review_partner_review_status != "rework_required" else "Review partner requested rework"
return None
def recalculate_task_aqmm_status(db: Session, task: ClientServiceTaskInstance) -> None:
if not getattr(task, "is_aqmm_task", False):
task.evidence_status = "not_required"
task.aqmm_status = "not_required"
task.aqmm_review_status = "not_required"
task.aqmm_completed_at_utc = None
return
task.evidence_status = _evidence_status_for_task(db, task)
review_issue = _required_review_issue(task)
if review_issue:
task.aqmm_review_status = "rework_required" if "rework" in review_issue.lower() else "pending_review"
else:
task.aqmm_review_status = "reviewed" if (
task.aqmm_manager_review_required or task.aqmm_partner_review_required or task.aqmm_review_partner_required
) else "not_required"
issue = _aqmm_task_issue(db, task)
if issue:
if "rework" in issue.lower():
task.aqmm_status = "rework_required"
elif "evidence" in issue.lower():
task.aqmm_status = "pending_evidence"
elif "review" in issue.lower():
task.aqmm_status = "pending_review"
else:
task.aqmm_status = "pending"
task.aqmm_completed_at_utc = None
else:
task.aqmm_status = "completed"
if not task.aqmm_completed_at_utc:
task.aqmm_completed_at_utc = datetime.now(timezone.utc)
def _aqmm_task_issue(db: Session, task: ClientServiceTaskInstance) -> str | None:
"""Return blocking reason for one AQMM-tagged task, or None if it passes.
This deliberately reuses the existing task status and task document upload
workflow. Review flags are captured for reporting and future review-specific
workflow; until dedicated manager/partner review statuses are added, the
task must at least be completed and evidence uploaded wherever marked.
The task itself is the AQMM evidence container. For AQMM tasks, task
documents, staff notes and manager/partner/review-partner reviews attached
to this task are the quality evidence trail.
"""
if not getattr(task, "is_aqmm_task", False):
return None
if getattr(task, "rework_status", "none") == "open":
return "Rework is open"
if getattr(task, "aqmm_mandatory", False) and task.status != "completed":
return "Task not completed"
if getattr(task, "aqmm_evidence_required", False) and not _task_has_evidence(db, task.id):
return "Evidence not uploaded"
review_issue = _required_review_issue(task)
if review_issue:
return review_issue
return None
def list_aqmm_quality_tasks(db: Session, *, subscription_id: int) -> list[dict]:
tasks = db.execute(
select(ClientServiceTaskInstance)
@@ -285,7 +384,15 @@ def list_aqmm_quality_tasks(db: Session, *, subscription_id: int) -> list[dict]:
for task in tasks:
has_evidence = _task_has_evidence(db, task.id)
issue = _aqmm_task_issue(db, task)
rows.append({"task": task, "has_evidence": has_evidence, "issue": issue, "passes": issue is None})
recalculate_task_aqmm_status(db, task)
rows.append({
"task": task,
"has_evidence": has_evidence,
"evidence_status": getattr(task, "evidence_status", "not_required"),
"review_status": getattr(task, "aqmm_review_status", "not_required"),
"issue": issue,
"passes": issue is None,
})
return rows
@@ -297,10 +404,14 @@ def aqmm_task_summary_for_subscription(db: Session, *, subscription_id: int) ->
mandatory_done = sum(1 for r in rows if getattr(r["task"], "aqmm_mandatory", False) and r["passes"])
evidence_required = sum(1 for r in rows if getattr(r["task"], "aqmm_evidence_required", False))
evidence_missing = sum(1 for r in rows if getattr(r["task"], "aqmm_evidence_required", False) and not r["has_evidence"])
manager_review_pending = sum(1 for r in rows if getattr(r["task"], "aqmm_manager_review_required", False) and r["task"].manager_review_status != "reviewed")
partner_review_pending = sum(1 for r in rows if getattr(r["task"], "aqmm_partner_review_required", False) and r["task"].partner_review_status != "reviewed")
review_partner_review_pending = sum(1 for r in rows if getattr(r["task"], "aqmm_review_partner_required", False) and r["task"].review_partner_review_status != "reviewed")
rework_open = sum(1 for r in rows if getattr(r["task"], "rework_status", "none") == "open")
blockers = [r for r in rows if getattr(r["task"], "aqmm_blocks_final_release", False) and not r["passes"]]
status = "not_required"
if total:
status = "completed" if mandatory_done == mandatory and evidence_missing == 0 else "in_progress"
status = "completed" if mandatory_done == mandatory and evidence_missing == 0 and manager_review_pending == 0 and partner_review_pending == 0 and review_partner_review_pending == 0 and rework_open == 0 else "in_progress"
return {
"rows": rows,
"total": total,
@@ -309,11 +420,16 @@ def aqmm_task_summary_for_subscription(db: Session, *, subscription_id: int) ->
"mandatory_done": mandatory_done,
"evidence_required": evidence_required,
"evidence_missing": evidence_missing,
"manager_review_pending": manager_review_pending,
"partner_review_pending": partner_review_pending,
"review_partner_review_pending": review_partner_review_pending,
"rework_open": rework_open,
"blockers": blockers,
"status": status,
}
def assert_aqmm_quality_tasks_complete(db: Session, *, subscription_id: int) -> None:
summary = aqmm_task_summary_for_subscription(db, subscription_id=subscription_id)
if summary["blockers"]:
@@ -324,6 +440,15 @@ def assert_aqmm_quality_tasks_complete(db: Session, *, subscription_id: int) ->
raise ValueError("AQMM mandatory quality checklist tasks are pending.")
if summary["evidence_missing"]:
raise ValueError("AQMM evidence upload is pending for one or more quality checklist tasks.")
if summary["manager_review_pending"]:
raise ValueError("AQMM manager review is pending for one or more quality checklist tasks.")
if summary["partner_review_pending"]:
raise ValueError("AQMM partner review is pending for one or more quality checklist tasks.")
if summary["review_partner_review_pending"]:
raise ValueError("AQMM review partner review is pending for one or more quality checklist tasks.")
if summary["rework_open"]:
raise ValueError("AQMM rework is open for one or more quality checklist tasks.")
def _decorate_task_for_tracker(task: ClientServiceTaskInstance, *, today: date) -> ClientServiceTaskInstance:
target_date = getattr(task, "internal_target_date", None)
@@ -567,6 +692,115 @@ def get_tasks_for_bulk_update(
def _append_system_task_comment(
db: Session,
*,
task: ClientServiceTaskInstance,
comment_type: str,
message: str,
user_id: int,
) -> None:
if not (message or "").strip():
return
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=_normalise_comment_type(comment_type),
visibility="internal",
message=message.strip(),
created_by_user_id=user_id,
)
)
def submit_task_for_review(db: Session, *, task: ClientServiceTaskInstance, note: str, user_id: int) -> None:
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
return
if getattr(task, "is_aqmm_task", False) and getattr(task, "aqmm_evidence_required", False) and not _task_has_evidence(db, task.id):
raise ValueError("Evidence is required before submitting this AQMM task for review.")
now = datetime.now(timezone.utc)
task.submitted_for_review_by_user_id = user_id
task.submitted_for_review_at_utc = now
if getattr(task, "aqmm_manager_review_required", False) and task.manager_review_status != "reviewed":
task.manager_review_status = "pending"
if getattr(task, "aqmm_partner_review_required", False) and task.partner_review_status != "reviewed":
task.partner_review_status = "pending"
if getattr(task, "aqmm_review_partner_required", False) and task.review_partner_review_status != "reviewed":
task.review_partner_review_status = "pending"
if task.status == "pending":
task.status = "in_progress"
if getattr(task, "rework_status", "none") == "open":
task.rework_status = "resolved"
task.rework_resolved_at_utc = now
task.updated_by_user_id = user_id
_append_system_task_comment(db, task=task, comment_type="staff_work_note", message=note or "Submitted for review.", user_id=user_id)
recalculate_task_aqmm_status(db, task)
def apply_task_review(
db: Session,
*,
task: ClientServiceTaskInstance,
review_level: str,
decision: str,
note: str,
user_id: int,
) -> None:
if getattr(task, "is_locked", False) or getattr(getattr(task, "subscription", None), "is_locked", False):
return
clean_decision = (decision or "reviewed").strip().lower()
if clean_decision not in {"reviewed", "rework_required"}:
clean_decision = "reviewed"
now = datetime.now(timezone.utc)
clean_note = (note or "").strip()
if clean_decision == "rework_required" and not clean_note:
raise ValueError("Rework reason is required.")
if review_level == "manager":
task.manager_review_status = clean_decision
task.manager_review_note = clean_note or task.manager_review_note
task.manager_reviewed_by_user_id = user_id
task.manager_reviewed_at_utc = now
comment_type = "manager_review_note"
elif review_level == "partner":
task.partner_review_status = clean_decision
task.partner_review_note = clean_note or task.partner_review_note
task.partner_reviewed_by_user_id = user_id
task.partner_reviewed_at_utc = now
comment_type = "partner_review_note"
elif review_level == "review_partner":
task.review_partner_review_status = clean_decision
task.review_partner_review_note = clean_note or task.review_partner_review_note
task.review_partner_reviewed_by_user_id = user_id
task.review_partner_reviewed_at_utc = now
comment_type = "review_partner_review_note"
else:
raise ValueError("Invalid review level.")
if clean_decision == "rework_required":
task.rework_status = "open"
task.rework_reason = clean_note
task.rework_requested_by_user_id = user_id
task.rework_requested_at_utc = now
task.rework_resolved_at_utc = None
task.status = "blocked"
comment_type = "rework_note"
else:
if getattr(task, "rework_status", "none") == "open":
task.rework_status = "resolved"
task.rework_resolved_at_utc = now
if getattr(task, "is_aqmm_task", False) and _required_review_issue(task) is None and (not getattr(task, "aqmm_evidence_required", False) or _task_has_evidence(db, task.id)):
task.status = "completed"
if not task.completed_at_utc:
task.completed_at_utc = now
task.updated_by_user_id = user_id
_append_system_task_comment(db, task=task, comment_type=comment_type, message=clean_note or clean_decision.replace("_", " ").title(), user_id=user_id)
recalculate_task_aqmm_status(db, task)
def list_task_comments(db: Session, *, tenant_id: int, task_id: int) -> list[ServiceTaskComment]:
return db.execute(
select(ServiceTaskComment)