Add AQMM task evidence and review notes workflow
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user