diff --git a/alembic/versions/20260719_task_checklist_fields.py b/alembic/versions/20260719_task_checklist_fields.py new file mode 100644 index 0000000..5154d49 --- /dev/null +++ b/alembic/versions/20260719_task_checklist_fields.py @@ -0,0 +1,47 @@ +"""add structured checklist fields to service tasks + +Revision ID: 20260719_task_checklist_fields +Revises: 20260713_bank_stmt_queue +""" +from alembic import op +import sqlalchemy as sa + +revision = "20260719_task_checklist_fields" +down_revision = "20260713_bank_stmt_queue" +branch_labels = None +depends_on = None + +_TEMPLATE_TABLES = ("service_default_task_templates", "firm_service_task_templates") + +def upgrade(): + for table in _TEMPLATE_TABLES: + op.add_column(table, sa.Column("task_category", sa.String(length=100), nullable=True)) + op.add_column(table, sa.Column("response_required", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column(table, sa.Column("response_type", sa.String(length=20), nullable=False, server_default="NONE")) + op.add_column(table, sa.Column("evidence_required", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column(table, sa.Column("remarks_required_if_no", sa.Boolean(), nullable=False, server_default=sa.false())) + op.create_index(f"ix_{table}_task_category", table, ["task_category"], unique=False) + + table = "client_service_task_instances" + op.add_column(table, sa.Column("task_category", sa.String(length=100), nullable=True)) + op.add_column(table, sa.Column("response_required", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column(table, sa.Column("response_type", sa.String(length=20), nullable=False, server_default="NONE")) + op.add_column(table, sa.Column("evidence_required", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column(table, sa.Column("remarks_required_if_no", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column(table, sa.Column("checklist_response", sa.String(length=10), nullable=True)) + op.add_column(table, sa.Column("checklist_text_response", sa.Text(), nullable=True)) + op.add_column(table, sa.Column("checklist_number_response", sa.Numeric(18, 2), nullable=True)) + op.add_column(table, sa.Column("checklist_date_response", sa.Date(), nullable=True)) + op.add_column(table, sa.Column("checklist_remarks", sa.Text(), nullable=True)) + op.create_index("ix_client_service_task_instances_task_category", table, ["task_category"], unique=False) + +def downgrade(): + table = "client_service_task_instances" + op.drop_index("ix_client_service_task_instances_task_category", table_name=table) + for column in ("checklist_remarks", "checklist_date_response", "checklist_number_response", "checklist_text_response", "checklist_response", "remarks_required_if_no", "evidence_required", "response_type", "response_required", "task_category"): + op.drop_column(table, column) + + for table in reversed(_TEMPLATE_TABLES): + op.drop_index(f"ix_{table}_task_category", table_name=table) + for column in ("remarks_required_if_no", "evidence_required", "response_type", "response_required", "task_category"): + op.drop_column(table, column) diff --git a/app/modules/services/bulk_imports.py b/app/modules/services/bulk_imports.py index 8d4faa8..b4e84f4 100644 --- a/app/modules/services/bulk_imports.py +++ b/app/modules/services/bulk_imports.py @@ -1026,6 +1026,13 @@ def import_system_default_tasks(db: Session, *, current_user, file_bytes: bytes, task.default_role_name = _clean(_cell(row, headers, "default_role_name")) or None task.is_mandatory = _bool(_cell(row, headers, "is_mandatory"), True) task.requires_review = _bool(_cell(row, headers, "requires_review"), False) + task.task_category = _clean(_cell(row, headers, "task_category")) or None + task.response_required = _bool(_cell(row, headers, "response_required"), False) + task.response_type = (_clean(_cell(row, headers, "response_type")) or "NONE").upper() + if task.response_type not in {"NONE", "YES_NO_NA", "YES_NO", "TEXT", "NUMBER", "DATE"}: + raise ValueError("response_type must be NONE, YES_NO_NA, YES_NO, TEXT, NUMBER or DATE.") + task.evidence_required = _bool(_cell(row, headers, "evidence_required"), False) + task.remarks_required_if_no = _bool(_cell(row, headers, "remarks_required_if_no"), False) task.is_aqmm_task = _bool(_cell(row, headers, "is_aqmm_task"), False) task.aqmm_mandatory = _bool(_cell(row, headers, "aqmm_mandatory"), False) task.aqmm_evidence_required = _bool(_cell(row, headers, "aqmm_evidence_required"), False) @@ -1091,6 +1098,13 @@ def import_firm_task_templates(db: Session, *, current_user, tenant_id: int, fil task.default_role_name = _clean(_cell(row, headers, "default_role_name")) or None task.is_mandatory = _bool(_cell(row, headers, "is_mandatory"), True) task.requires_review = _bool(_cell(row, headers, "requires_review"), False) + task.task_category = _clean(_cell(row, headers, "task_category")) or None + task.response_required = _bool(_cell(row, headers, "response_required"), False) + task.response_type = (_clean(_cell(row, headers, "response_type")) or "NONE").upper() + if task.response_type not in {"NONE", "YES_NO_NA", "YES_NO", "TEXT", "NUMBER", "DATE"}: + raise ValueError("response_type must be NONE, YES_NO_NA, YES_NO, TEXT, NUMBER or DATE.") + task.evidence_required = _bool(_cell(row, headers, "evidence_required"), False) + task.remarks_required_if_no = _bool(_cell(row, headers, "remarks_required_if_no"), False) task.is_aqmm_task = _bool(_cell(row, headers, "is_aqmm_task"), False) task.aqmm_mandatory = _bool(_cell(row, headers, "aqmm_mandatory"), False) task.aqmm_evidence_required = _bool(_cell(row, headers, "aqmm_evidence_required"), False) diff --git a/app/modules/services/default_tasks_export.py b/app/modules/services/default_tasks_export.py index 0fa3b59..dff97c3 100644 --- a/app/modules/services/default_tasks_export.py +++ b/app/modules/services/default_tasks_export.py @@ -21,6 +21,11 @@ _HEADERS = [ "default_role_name", "is_mandatory", "requires_review", + "task_category", + "response_required", + "response_type", + "evidence_required", + "remarks_required_if_no", "is_aqmm_task", "aqmm_mandatory", "aqmm_evidence_required", @@ -40,6 +45,11 @@ _IMPORT_HEADERS = [ "default_role_name", "is_mandatory", "requires_review", + "task_category", + "response_required", + "response_type", + "evidence_required", + "remarks_required_if_no", "is_aqmm_task", "aqmm_mandatory", "aqmm_evidence_required", @@ -154,6 +164,11 @@ def build_default_tasks_export(db: Session) -> bytes: task.default_role_name or "", _yes_no(task.is_mandatory), _yes_no(task.requires_review), + task.task_category or "", + _yes_no(task.response_required), + task.response_type or "NONE", + _yes_no(task.evidence_required), + _yes_no(task.remarks_required_if_no), _yes_no(task.is_aqmm_task), _yes_no(task.aqmm_mandatory), _yes_no(task.aqmm_evidence_required), @@ -172,6 +187,11 @@ def build_default_tasks_export(db: Session) -> bytes: task.default_role_name or "", _yes_no(task.is_mandatory), _yes_no(task.requires_review), + task.task_category or "", + _yes_no(task.response_required), + task.response_type or "NONE", + _yes_no(task.evidence_required), + _yes_no(task.remarks_required_if_no), _yes_no(task.is_aqmm_task), _yes_no(task.aqmm_mandatory), _yes_no(task.aqmm_evidence_required), @@ -225,13 +245,14 @@ def build_default_tasks_export(db: Session) -> bytes: _style_sheet( all_tasks_ws, - {1: 32, 2: 42, 3: 18, 4: 26, 5: 12, 6: 44, 7: 20, 8: 14, 9: 14, 10: 14, - 11: 16, 12: 20, 13: 22, 14: 22, 15: 23, 16: 22, 17: 30, 18: 12, 19: 72}, + {1: 32, 2: 42, 3: 18, 4: 26, 5: 12, 6: 44, 7: 20, 8: 14, 9: 14, 10: 24, + 11: 18, 12: 18, 13: 18, 14: 20, 15: 14, 16: 16, 17: 20, 18: 22, 19: 22, 20: 23, + 21: 22, 22: 30, 23: 12, 24: 72}, ) _style_sheet( import_ws, - {1: 32, 2: 12, 3: 44, 4: 20, 5: 14, 6: 14, 7: 14, 8: 16, 9: 20, 10: 22, - 11: 22, 12: 23, 13: 22, 14: 30, 15: 12, 16: 72}, + {1: 32, 2: 12, 3: 44, 4: 20, 5: 14, 6: 14, 7: 24, 8: 18, 9: 18, 10: 18, + 11: 20, 12: 14, 13: 16, 14: 20, 15: 22, 16: 22, 17: 23, 18: 22, 19: 30, 20: 12, 21: 72}, ) _style_sheet( summary_ws, diff --git a/app/modules/services/execution.py b/app/modules/services/execution.py index 3714cfa..5172243 100644 --- a/app/modules/services/execution.py +++ b/app/modules/services/execution.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import date, datetime, timezone +from decimal import Decimal, InvalidOperation from sqlalchemy import func, or_, select from sqlalchemy.orm import Session, selectinload @@ -243,6 +244,11 @@ def generate_tasks_for_subscription(db: Session, *, subscription: ClientServiceS internal_target_date=_default_internal_target_date(subscription, template), status="pending", priority="normal", + task_category=getattr(template, "task_category", None), + response_required=getattr(template, "response_required", False), + response_type=(getattr(template, "response_type", "NONE") or "NONE").upper(), + evidence_required=getattr(template, "evidence_required", False), + remarks_required_if_no=getattr(template, "remarks_required_if_no", False), is_aqmm_task=getattr(template, "is_aqmm_task", False), aqmm_mandatory=getattr(template, "aqmm_mandatory", False), aqmm_evidence_required=getattr(template, "aqmm_evidence_required", False), @@ -834,6 +840,84 @@ def list_assignees_for_execution(db: Session, *, tenant_id: int, branch_id: int return db.execute(query.order_by(User.full_name.asc(), User.email.asc())).scalars().all() + +_CHECKLIST_RESPONSE_TYPES = {"NONE", "YES_NO_NA", "YES_NO", "TEXT", "NUMBER", "DATE"} +_CHECKLIST_YES_NO_NA_VALUES = {"YES", "NO", "NA"} +_CHECKLIST_YES_NO_VALUES = {"YES", "NO"} + + +def _normalise_checklist_response_type(value: str | None) -> str: + response_type = (value or "NONE").strip().upper() + return response_type if response_type in _CHECKLIST_RESPONSE_TYPES else "NONE" + + +def apply_task_checklist_response( + db: Session, + task: ClientServiceTaskInstance, + *, + checklist_response: str, + checklist_text_response: str, + checklist_number_response: str, + checklist_date_response: str, + checklist_remarks: str, + requested_status: str, +) -> None: + """Validate and persist the structured response for an execution task. + + Validation is enforced when a task is marked completed. Draft/in-progress + tasks may save partial responses so existing work-tracker behaviour remains + unchanged. + """ + response_type = _normalise_checklist_response_type(getattr(task, "response_type", "NONE")) + response_value = (checklist_response or "").strip().upper() + text_value = (checklist_text_response or "").strip() + number_text = (checklist_number_response or "").strip().replace(",", "") + date_text = (checklist_date_response or "").strip() + remarks_value = (checklist_remarks or "").strip() + + if response_type == "YES_NO_NA" and response_value and response_value not in _CHECKLIST_YES_NO_NA_VALUES: + raise ValueError("Checklist response must be Yes, No or NA.") + if response_type == "YES_NO" and response_value and response_value not in _CHECKLIST_YES_NO_VALUES: + raise ValueError("Checklist response must be Yes or No.") + + number_value = None + if number_text: + try: + number_value = Decimal(number_text) + except (InvalidOperation, ValueError): + raise ValueError("Checklist number response is invalid.") + + date_value = None + if date_text: + try: + date_value = date.fromisoformat(date_text) + except ValueError: + raise ValueError("Checklist date response is invalid.") + + completing = _normalise_status(requested_status) == "completed" + if completing and getattr(task, "response_required", False): + missing = ( + (response_type in {"YES_NO_NA", "YES_NO"} and not response_value) + or (response_type == "TEXT" and not text_value) + or (response_type == "NUMBER" and number_value is None) + or (response_type == "DATE" and date_value is None) + or (response_type == "NONE") + ) + if missing: + raise ValueError("Complete the required checklist response before marking the task completed.") + + if completing and getattr(task, "remarks_required_if_no", False) and response_value == "NO" and not remarks_value: + raise ValueError("Checklist remarks are required when the response is No.") + + if completing and getattr(task, "evidence_required", False) and not _task_has_evidence(db, task.id): + raise ValueError("Upload task evidence before marking the task completed.") + + task.checklist_response = response_value or None + task.checklist_text_response = text_value or None + task.checklist_number_response = number_value + task.checklist_date_response = date_value + task.checklist_remarks = remarks_value or None + def apply_task_update( task: ClientServiceTaskInstance, *, @@ -866,6 +950,7 @@ def apply_task_update( def apply_bulk_task_update( + db: Session, tasks: list[ClientServiceTaskInstance], *, status: str | None, @@ -882,8 +967,30 @@ def apply_bulk_task_update( skipped += 1 continue previous_status = task.status - if status: - task.status = _normalise_status(status) + requested_status = _normalise_status(status) if status else None + if requested_status == "completed": + response_type = _normalise_checklist_response_type(getattr(task, "response_type", "NONE")) + missing_response = ( + getattr(task, "response_required", False) + and ( + (response_type in {"YES_NO_NA", "YES_NO"} and not getattr(task, "checklist_response", None)) + or (response_type == "TEXT" and not getattr(task, "checklist_text_response", None)) + or (response_type == "NUMBER" and getattr(task, "checklist_number_response", None) is None) + or (response_type == "DATE" and getattr(task, "checklist_date_response", None) is None) + or response_type == "NONE" + ) + ) + missing_remarks = ( + getattr(task, "remarks_required_if_no", False) + and getattr(task, "checklist_response", None) == "NO" + and not getattr(task, "checklist_remarks", None) + ) + missing_evidence = getattr(task, "evidence_required", False) and not _task_has_evidence(db, task.id) + if missing_response or missing_remarks or missing_evidence: + skipped += 1 + continue + if requested_status: + task.status = requested_status if update_assignee: task.assigned_to_user_id = assigned_to_user_id if update_internal_target_date: diff --git a/app/modules/services/models.py b/app/modules/services/models.py index eeb542f..8d64400 100644 --- a/app/modules/services/models.py +++ b/app/modules/services/models.py @@ -1,8 +1,9 @@ from __future__ import annotations from datetime import date, datetime, timezone +from decimal import Decimal -from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.db.common import CommonBase @@ -112,6 +113,15 @@ 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. @@ -180,6 +190,13 @@ 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) @@ -652,6 +669,20 @@ 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) diff --git a/app/modules/services/templates/services/default_task_form.html b/app/modules/services/templates/services/default_task_form.html index ab8783c..53bf130 100644 --- a/app/modules/services/templates/services/default_task_form.html +++ b/app/modules/services/templates/services/default_task_form.html @@ -47,6 +47,30 @@ +
Configure this task as an auditable checklist item. Normal tasks can keep Response Type as None.
+Use these only for tasks that should become part of the assurance engagement quality checklist. Non-assurance engagements will continue using the normal task flow.
diff --git a/app/modules/services/templates/services/firm_task_form.html b/app/modules/services/templates/services/firm_task_form.html index a2b522e..ba0a18c 100644 --- a/app/modules/services/templates/services/firm_task_form.html +++ b/app/modules/services/templates/services/firm_task_form.html @@ -47,6 +47,30 @@Configure this task as an auditable checklist item. Normal tasks can keep Response Type as None.
+Use these only for tasks that should become part of the assurance engagement quality checklist. Non-assurance engagements will continue using the normal task flow.
diff --git a/app/modules/services/templates/services/work_tracker/task_form.html b/app/modules/services/templates/services/work_tracker/task_form.html index 4af9809..b45d5ec 100644 --- a/app/modules/services/templates/services/work_tracker/task_form.html +++ b/app/modules/services/templates/services/work_tracker/task_form.html @@ -54,6 +54,10 @@