From c35c89982e9b2be309f4437da18194cfccc06f0c Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Wed, 5 Aug 2026 12:31:39 +0530 Subject: [PATCH] Add periodic engagements and statutory due dates --- .../versions/20260805_periodic_engagements.py | 95 +++++++ app/modules/services/bulk_imports.py | 20 +- app/modules/services/client_services.py | 44 ++++ app/modules/services/engagements_ui.py | 241 ++++++++++++------ app/modules/services/models.py | 5 +- .../services/engagements/bulk_form.html | 37 ++- .../services/engagements/detail.html | 2 +- .../templates/services/engagements/form.html | 63 ++++- .../templates/services/engagements/list.html | 2 +- 9 files changed, 415 insertions(+), 94 deletions(-) create mode 100644 alembic/versions/20260805_periodic_engagements.py diff --git a/alembic/versions/20260805_periodic_engagements.py b/alembic/versions/20260805_periodic_engagements.py new file mode 100644 index 0000000..03e3f6b --- /dev/null +++ b/alembic/versions/20260805_periodic_engagements.py @@ -0,0 +1,95 @@ +"""add recurring engagement period support + +Revision ID: 20260805_periodic_engagements +Revises: 20260805_performing_partner +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +revision = "20260805_periodic_engagements" +down_revision = "20260805_performing_partner" +branch_labels = None +depends_on = None + +TABLE = "client_service_subscriptions" +OLD_UQ = "uq_client_service_subscription_tenant_client_service_year" +NEW_UQ = "uq_client_service_subscription_tenant_client_service_year_period" +INDEX = "ix_client_service_subscriptions_period_label" + + +def _inspector(): + return inspect(op.get_bind()) + + +def _columns() -> set[str]: + return {row["name"] for row in _inspector().get_columns(TABLE)} + + +def _unique_constraints() -> set[str]: + return {row.get("name") for row in _inspector().get_unique_constraints(TABLE) if row.get("name")} + + +def _indexes() -> set[str]: + return {row.get("name") for row in _inspector().get_indexes(TABLE) if row.get("name")} + + +def upgrade(): + if "period_label" not in _columns(): + op.add_column(TABLE, sa.Column("period_label", sa.String(length=30), nullable=False, server_default="")) + + op.execute("UPDATE client_service_subscriptions SET period_label = '' WHERE period_label IS NULL") + + uniques = _unique_constraints() + dialect = op.get_bind().dialect.name + if dialect == "sqlite": + with op.batch_alter_table(TABLE, recreate="always") as batch: + if OLD_UQ in uniques: + batch.drop_constraint(OLD_UQ, type_="unique") + if NEW_UQ not in uniques: + batch.create_unique_constraint( + NEW_UQ, + ["tenant_id", "client_id", "service_catalogue_id", "financial_year", "period_label"], + ) + else: + if OLD_UQ in uniques: + op.drop_constraint(OLD_UQ, TABLE, type_="unique") + if NEW_UQ not in _unique_constraints(): + op.create_unique_constraint( + NEW_UQ, + TABLE, + ["tenant_id", "client_id", "service_catalogue_id", "financial_year", "period_label"], + ) + + if INDEX not in _indexes(): + op.create_index(INDEX, TABLE, ["period_label"], unique=False) + + +def downgrade(): + if INDEX in _indexes(): + op.drop_index(INDEX, table_name=TABLE) + + uniques = _unique_constraints() + dialect = op.get_bind().dialect.name + if dialect == "sqlite": + with op.batch_alter_table(TABLE, recreate="always") as batch: + if NEW_UQ in uniques: + batch.drop_constraint(NEW_UQ, type_="unique") + if OLD_UQ not in uniques: + batch.create_unique_constraint( + OLD_UQ, + ["tenant_id", "client_id", "service_catalogue_id", "financial_year"], + ) + if "period_label" in _columns(): + batch.drop_column("period_label") + else: + if NEW_UQ in uniques: + op.drop_constraint(NEW_UQ, TABLE, type_="unique") + if OLD_UQ not in _unique_constraints(): + op.create_unique_constraint( + OLD_UQ, + TABLE, + ["tenant_id", "client_id", "service_catalogue_id", "financial_year"], + ) + if "period_label" in _columns(): + op.drop_column(TABLE, "period_label") diff --git a/app/modules/services/bulk_imports.py b/app/modules/services/bulk_imports.py index b4e84f4..34b7730 100644 --- a/app/modules/services/bulk_imports.py +++ b/app/modules/services/bulk_imports.py @@ -23,7 +23,14 @@ from app.modules.services.models import ( ServiceDueDateRule, ) from app.modules.services.services import normalize_code, normalize_engagement_type -from app.modules.services.client_services import assessment_year_from_financial_year, normalize_financial_year, review_partner_required_for_engagement, ensure_engagement_quality_workflow, enforce_quality_gate_on_subscription +from app.modules.services.client_services import ( + assessment_year_from_financial_year, + normalize_financial_year, + normalize_period_label, + review_partner_required_for_engagement, + ensure_engagement_quality_workflow, + enforce_quality_gate_on_subscription, +) from app.modules.services.due_dates import apply_due_date_rule_to_subscription, create_due_date_extension TRUE_VALUES = {"1", "true", "yes", "y", "on"} @@ -33,6 +40,7 @@ CLIENT_ASSIGNMENT_COLUMNS = [ "client_code", "service_code", "financial_year", + "period_label", "engagement_type", "assigned_partner_email", "assigned_manager_email", @@ -759,6 +767,11 @@ def import_client_service_assignments( if not enabled: raise ValueError("Service is not enabled for this firm.") firm_selection, catalogue = enabled + period_label = normalize_period_label( + _clean(_cell(row, headers, "period_label")), + financial_year=financial_year, + recurrence_type=getattr(catalogue, "recurrence_type", None), + ) partner_email = _clean(_cell(row, headers, "assigned_partner_email")) manager_email = _clean(_cell(row, headers, "assigned_manager_email")) @@ -786,6 +799,7 @@ def import_client_service_assignments( ClientServiceSubscription.client_id == client.id, ClientServiceSubscription.service_catalogue_id == catalogue.id, ClientServiceSubscription.financial_year == financial_year, + ClientServiceSubscription.period_label == period_label, ) ).scalar_one_or_none() @@ -801,6 +815,7 @@ def import_client_service_assignments( client_id=client.id, service_catalogue_id=catalogue.id, financial_year=financial_year, + period_label=period_label, assessment_year=assessment_year_from_financial_year(financial_year), created_by_user_id=current_user.id, ) @@ -810,6 +825,7 @@ def import_client_service_assignments( if getattr(sub, "is_locked", False): raise ValueError("Existing engagement for this client/service/year is locked and cannot be updated.") sub.financial_year = financial_year + sub.period_label = period_label sub.assessment_year = assessment_year_from_financial_year(financial_year) imported_engagement_type = _clean(_cell(row, headers, "engagement_type")) sub.engagement_type = normalize_engagement_type(imported_engagement_type) if imported_engagement_type else (getattr(catalogue, "engagement_type", "non_assurance") or "non_assurance") @@ -826,7 +842,7 @@ def import_client_service_assignments( sub.is_active = is_active sub.remarks = _clean(_cell(row, headers, "remarks")) or None sub.updated_by_user_id = current_user.id - apply_due_date_rule_to_subscription(db, sub) + apply_due_date_rule_to_subscription(db, sub, force=True) ensure_engagement_quality_workflow(db, subscription=sub, actor_user_id=current_user.id, create_declarations=False) enforce_quality_gate_on_subscription(sub) except Exception as exc: diff --git a/app/modules/services/client_services.py b/app/modules/services/client_services.py index 72f567f..1d1106e 100644 --- a/app/modules/services/client_services.py +++ b/app/modules/services/client_services.py @@ -24,6 +24,48 @@ SUBSCRIPTION_STATUSES = [ ASSIGNMENT_ROLE_NAMES = ("Partner", "Branch Manager", "Staff") +MONTHLY_PERIODS = ( + (4, "Apr"), (5, "May"), (6, "Jun"), (7, "Jul"), (8, "Aug"), (9, "Sep"), + (10, "Oct"), (11, "Nov"), (12, "Dec"), (1, "Jan"), (2, "Feb"), (3, "Mar"), +) +QUARTERLY_PERIODS = ("Q1", "Q2", "Q3", "Q4") + + +def normalized_recurrence_type(value: str | None) -> str: + return (value or "").strip().lower().replace("-", "_").replace(" ", "_") + + +def recurrence_requires_period(value: str | None) -> bool: + return normalized_recurrence_type(value) in {"monthly", "quarterly"} + + +def period_choices_for_service(financial_year: str | None, recurrence_type: str | None) -> list[tuple[str, str]]: + recurrence = normalized_recurrence_type(recurrence_type) + fy = normalize_financial_year(financial_year) + start_year = int(fy.split("-", 1)[0]) + end_year = start_year + 1 + if recurrence == "monthly": + result = [] + for month, label in MONTHLY_PERIODS: + year = start_year if month >= 4 else end_year + result.append((f"{year:04d}-{month:02d}", f"{label} {year}")) + return result + if recurrence == "quarterly": + return [(value, f"{value} {fy}") for value in QUARTERLY_PERIODS] + return [("", "Not applicable")] + + +def normalize_period_label(value: str | None, *, financial_year: str | None, recurrence_type: str | None) -> str: + recurrence = normalized_recurrence_type(recurrence_type) + raw = (value or "").strip() + if not recurrence_requires_period(recurrence): + return "" + valid = {code for code, _ in period_choices_for_service(financial_year, recurrence)} + if raw not in valid: + raise ValueError("A valid month or quarter is required for this recurring service.") + return raw + + def current_financial_year(today: date | None = None) -> str: today = today or date.today() if today.month >= 4: @@ -139,6 +181,7 @@ def get_existing_subscription( client_id: int, service_catalogue_id: int, financial_year: str | None = None, + period_label: str | None = None, ) -> ClientServiceSubscription | None: return db.execute( select(ClientServiceSubscription).where( @@ -146,6 +189,7 @@ def get_existing_subscription( ClientServiceSubscription.client_id == client_id, ClientServiceSubscription.service_catalogue_id == service_catalogue_id, ClientServiceSubscription.financial_year == normalize_financial_year(financial_year), + ClientServiceSubscription.period_label == (period_label or "").strip(), ) ).scalar_one_or_none() diff --git a/app/modules/services/engagements_ui.py b/app/modules/services/engagements_ui.py index 4fd7299..5f31c96 100644 --- a/app/modules/services/engagements_ui.py +++ b/app/modules/services/engagements_ui.py @@ -27,6 +27,9 @@ from app.modules.services.client_services import ( get_existing_subscription, get_subscription, normalize_financial_year, + normalize_period_label, + period_choices_for_service, + recurrence_requires_period, list_assignable_users, list_clients_for_assignment, list_enabled_services_for_assignment, @@ -241,6 +244,7 @@ def subscription_create_page(request: Request, client_id: int | None = None): review_partners=review_partners, selected_client_id=client_id, financial_year=_active_financial_year(request), + period_choices=[], ) finally: db.close() @@ -257,6 +261,8 @@ def subscription_create_submit( performing_partner_user_id: str = Form(""), review_partner_user_id: str = Form(""), financial_year: str = Form(""), + period_label: str = Form(""), + generate_all_periods: str | None = Form(None), start_date: str = Form(""), end_date: str = Form(""), expiry_date: str = Form(""), @@ -285,62 +291,89 @@ def subscription_create_submit( if not firm_selection: return RedirectResponse(url="/services/engagements/new", status_code=303) - existing = get_existing_subscription( - db, - tenant_id=tenant_id, - client_id=client_id, - service_catalogue_id=service_catalogue_id, - financial_year=selected_financial_year, - ) + recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None) + if recurrence_requires_period(recurrence_type): + available_periods = period_choices_for_service(selected_financial_year, recurrence_type) + if generate_all_periods is not None: + requested_periods = [code for code, _label in available_periods] + else: + try: + requested_periods = [normalize_period_label(period_label, financial_year=selected_financial_year, recurrence_type=recurrence_type)] + except ValueError: + return RedirectResponse(url="/services/engagements/new?error=period", status_code=303) + else: + requested_periods = [""] + client = db.get(Client, client_id) if not client or client.tenant_id != tenant_id: return RedirectResponse(url="/services/engagements/new", status_code=303) - if existing: - row = existing - else: + created_rows = [] + existing_rows = [] + for requested_period in requested_periods: + existing = get_existing_subscription( + db, + tenant_id=tenant_id, + client_id=client_id, + service_catalogue_id=service_catalogue_id, + financial_year=selected_financial_year, + period_label=requested_period, + ) + if existing: + existing_rows.append(existing) + continue row = ClientServiceSubscription( tenant_id=tenant_id, client_id=client_id, service_catalogue_id=service_catalogue_id, financial_year=selected_financial_year, + period_label=requested_period, assessment_year=assessment_year_from_financial_year(selected_financial_year), engagement_type=getattr(firm_selection.catalogue, "engagement_type", "non_assurance") or "non_assurance", created_by_user_id=user.id, ) db.add(row) + created_rows.append(row) - if getattr(row, "is_locked", False) or is_row_financial_year_locked(db, row): - return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303) - - row.financial_year = selected_financial_year - row.assessment_year = assessment_year_from_financial_year(selected_financial_year) - if not getattr(row, "engagement_type", None): - row.engagement_type = getattr(firm_selection.catalogue, "engagement_type", "non_assurance") or "non_assurance" - row.branch_id = firm_selection.default_branch_id or getattr(user, "branch_id", None) - row.firm_service_selection_id = firm_selection.id - row.assigned_partner_user_id = int(assigned_partner_user_id) if assigned_partner_user_id.strip() else None - requested_performing_partner_id = int(performing_partner_user_id) if performing_partner_user_id.strip() else None - row.performing_partner_user_id = requested_performing_partner_id or getattr(client, "default_performing_partner_user_id", None) or row.assigned_partner_user_id - row.assigned_manager_user_id = int(assigned_manager_user_id) if assigned_manager_user_id.strip() else None - row.assigned_staff_user_id = int(assigned_staff_user_id) if assigned_staff_user_id.strip() else None - if review_partner_required_for_engagement(db, tenant_id=tenant_id, engagement_type=row.engagement_type): - row.review_partner_user_id = int(review_partner_user_id) if review_partner_user_id.strip() else getattr(client, "default_review_partner_user_id", None) + if not created_rows: + row = existing_rows[0] else: - row.review_partner_user_id = None - row.start_date = parse_date(start_date) - row.end_date = parse_date(end_date) - row.expiry_date = parse_date(expiry_date) - row.status = status or "active" - row.remarks = remarks.strip() or None - row.is_active = is_active is not None - row.updated_by_user_id = user.id - apply_due_date_rule_to_subscription(db, row) - ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False) - enforce_quality_gate_on_subscription(row) + row = created_rows[0] + rows_to_update = created_rows or [row] + for row in rows_to_update: + if getattr(row, "is_locked", False) or is_row_financial_year_locked(db, row): + return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303) + + row.financial_year = selected_financial_year + row.assessment_year = assessment_year_from_financial_year(selected_financial_year) + if not getattr(row, "engagement_type", None): + row.engagement_type = getattr(firm_selection.catalogue, "engagement_type", "non_assurance") or "non_assurance" + row.branch_id = firm_selection.default_branch_id or getattr(user, "branch_id", None) + row.firm_service_selection_id = firm_selection.id + row.assigned_partner_user_id = int(assigned_partner_user_id) if assigned_partner_user_id.strip() else None + requested_performing_partner_id = int(performing_partner_user_id) if performing_partner_user_id.strip() else None + row.performing_partner_user_id = requested_performing_partner_id or getattr(client, "default_performing_partner_user_id", None) or row.assigned_partner_user_id + row.assigned_manager_user_id = int(assigned_manager_user_id) if assigned_manager_user_id.strip() else None + row.assigned_staff_user_id = int(assigned_staff_user_id) if assigned_staff_user_id.strip() else None + if review_partner_required_for_engagement(db, tenant_id=tenant_id, engagement_type=row.engagement_type): + row.review_partner_user_id = int(review_partner_user_id) if review_partner_user_id.strip() else getattr(client, "default_review_partner_user_id", None) + else: + row.review_partner_user_id = None + row.start_date = parse_date(start_date) + row.end_date = parse_date(end_date) + row.expiry_date = parse_date(expiry_date) + row.status = status or "active" + row.remarks = remarks.strip() or None + row.is_active = is_active is not None + row.updated_by_user_id = user.id + apply_due_date_rule_to_subscription(db, row, force=True) + ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False) + enforce_quality_gate_on_subscription(row) db.commit() db.refresh(row) + if len(created_rows) > 1: + return RedirectResponse(url=f"/services/engagements?financial_year={selected_financial_year}&bulk_created={len(created_rows)}", status_code=303) return RedirectResponse(url=f"/services/engagements/{row.id}", status_code=303) finally: db.close() @@ -398,6 +431,7 @@ def subscription_bulk_create_page(request: Request, error: str = ""): "review_partner": "The selected Review Partner is not available for this firm.", "review_partner_required": "Review Partner is mandatory for an assurance engagement.", "clients": "Select at least one permitted client.", + "period": "Select a valid month or quarter for the recurring service.", }.get(error), ) finally: @@ -415,6 +449,8 @@ def subscription_bulk_create_submit( performing_partner_user_id: str = Form(""), review_partner_user_id: str = Form(""), financial_year: str = Form(""), + period_label: str = Form(""), + generate_all_periods: str | None = Form(None), remarks: str = Form(""), csrf_token: str = Form(...), ): @@ -451,6 +487,19 @@ def subscription_bulk_create_submit( if not firm_selection: return RedirectResponse(url="/services/engagements/bulk-new?error=service", status_code=303) + recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None) + if recurrence_requires_period(recurrence_type): + available_periods = period_choices_for_service(selected_financial_year, recurrence_type) + if generate_all_periods is not None: + requested_periods = [code for code, _label in available_periods] + else: + try: + requested_periods = [normalize_period_label(period_label, financial_year=selected_financial_year, recurrence_type=recurrence_type)] + except ValueError: + return RedirectResponse(url="/services/engagements/bulk-new?error=period", status_code=303) + else: + requested_periods = [""] + partners = list_assignable_users(db, tenant_id=tenant_id, role_names=("Partner",)) managers = list_assignable_users(db, tenant_id=tenant_id, role_names=("Branch Manager",)) staff_users = list_assignable_users(db, tenant_id=tenant_id, role_names=("Staff",)) @@ -521,56 +570,59 @@ def subscription_bulk_create_submit( ) for client_id in selected_client_ids: - existing = get_existing_subscription( - db, - tenant_id=tenant_id, - client_id=client_id, - service_catalogue_id=service_catalogue_id, - financial_year=selected_financial_year, - ) - if existing: - existing_count += 1 - continue - client = db.get(Client, client_id) if not client or client.tenant_id != tenant_id: - skipped_count += 1 + skipped_count += len(requested_periods) continue - row = ClientServiceSubscription( - tenant_id=tenant_id, - branch_id=engagement_branch_id, - client_id=client_id, - service_catalogue_id=service_catalogue_id, - firm_service_selection_id=firm_selection.id, - assigned_partner_user_id=assigned_partner_user_id, - performing_partner_user_id=performing_partner_id or getattr(client, "default_performing_partner_user_id", None) or assigned_partner_user_id, - assigned_manager_user_id=manager_id, - assigned_staff_user_id=staff_id, - review_partner_user_id=( - review_partner_id - if review_partner_id is not None - else ( - getattr(client, "default_review_partner_user_id", None) - if requires_review_partner - else None - ) - ), - financial_year=selected_financial_year, - assessment_year=assessment_year_from_financial_year(selected_financial_year), - engagement_type=engagement_type, - status="active", - remarks=remarks.strip() or None, - is_active=True, - created_by_user_id=user.id, - updated_by_user_id=user.id, - ) - db.add(row) - db.flush() - apply_due_date_rule_to_subscription(db, row) - ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False) - enforce_quality_gate_on_subscription(row) - created_count += 1 + for requested_period in requested_periods: + existing = get_existing_subscription( + db, + tenant_id=tenant_id, + client_id=client_id, + service_catalogue_id=service_catalogue_id, + financial_year=selected_financial_year, + period_label=requested_period, + ) + if existing: + existing_count += 1 + continue + + row = ClientServiceSubscription( + tenant_id=tenant_id, + branch_id=engagement_branch_id, + client_id=client_id, + service_catalogue_id=service_catalogue_id, + firm_service_selection_id=firm_selection.id, + assigned_partner_user_id=assigned_partner_user_id, + performing_partner_user_id=performing_partner_id or getattr(client, "default_performing_partner_user_id", None) or assigned_partner_user_id, + assigned_manager_user_id=manager_id, + assigned_staff_user_id=staff_id, + review_partner_user_id=( + review_partner_id + if review_partner_id is not None + else ( + getattr(client, "default_review_partner_user_id", None) + if requires_review_partner + else None + ) + ), + financial_year=selected_financial_year, + period_label=requested_period, + assessment_year=assessment_year_from_financial_year(selected_financial_year), + engagement_type=engagement_type, + status="active", + remarks=remarks.strip() or None, + is_active=True, + created_by_user_id=user.id, + updated_by_user_id=user.id, + ) + db.add(row) + db.flush() + apply_due_date_rule_to_subscription(db, row, force=True) + ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False) + enforce_quality_gate_on_subscription(row) + created_count += 1 db.commit() return RedirectResponse( @@ -1011,6 +1063,7 @@ def subscription_edit_page(request: Request, subscription_id: int): review_partners=review_partners, selected_client_id=row.client_id, financial_year=row.financial_year, + period_choices=period_choices_for_service(row.financial_year, getattr(row.catalogue, "recurrence_type", None)), ) finally: db.close() @@ -1025,6 +1078,7 @@ def subscription_edit_submit( assigned_staff_user_id: str = Form(""), performing_partner_user_id: str = Form(""), review_partner_user_id: str = Form(""), + period_label: str = Form(""), start_date: str = Form(""), end_date: str = Form(""), expiry_date: str = Form(""), @@ -1054,6 +1108,25 @@ def subscription_edit_submit( if getattr(row, "is_locked", False) or is_row_financial_year_locked(db, row): return RedirectResponse(url=f"/services/engagements/{row.id}?year_locked=1", status_code=303) + try: + new_period_label = normalize_period_label( + period_label, + financial_year=row.financial_year, + recurrence_type=getattr(row.catalogue, "recurrence_type", None), + ) + except ValueError: + return RedirectResponse(url=f"/services/engagements/{row.id}/edit?error=period", status_code=303) + duplicate = get_existing_subscription( + db, + tenant_id=tenant_id, + client_id=row.client_id, + service_catalogue_id=row.service_catalogue_id, + financial_year=row.financial_year, + period_label=new_period_label, + ) + if duplicate and duplicate.id != row.id: + return RedirectResponse(url=f"/services/engagements/{row.id}/edit?error=duplicate_period", status_code=303) + row.period_label = new_period_label row.assigned_partner_user_id = int(assigned_partner_user_id) if assigned_partner_user_id.strip() else None requested_performing_partner_id = int(performing_partner_user_id) if performing_partner_user_id.strip() else None row.performing_partner_user_id = requested_performing_partner_id or getattr(row.client, "default_performing_partner_user_id", None) or row.assigned_partner_user_id @@ -1070,7 +1143,7 @@ def subscription_edit_submit( row.remarks = remarks.strip() or None row.is_active = is_active is not None row.updated_by_user_id = user.id - apply_due_date_rule_to_subscription(db, row) + apply_due_date_rule_to_subscription(db, row, force=True) ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False) enforce_quality_gate_on_subscription(row) db.commit() diff --git a/app/modules/services/models.py b/app/modules/services/models.py index f81631e..b7787f4 100644 --- a/app/modules/services/models.py +++ b/app/modules/services/models.py @@ -381,7 +381,8 @@ class ClientServiceSubscription(CommonBase): "client_id", "service_catalogue_id", "financial_year", - name="uq_client_service_subscription_tenant_client_service_year", + "period_label", + name="uq_client_service_subscription_tenant_client_service_year_period", ), ) @@ -400,6 +401,7 @@ class ClientServiceSubscription(CommonBase): review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) financial_year: Mapped[str] = mapped_column(String(9), nullable=False, default="2025-26", index=True) + period_label: Mapped[str] = mapped_column(String(30), nullable=False, default="", server_default="", index=True) assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True) engagement_type: Mapped[str] = mapped_column(String(20), nullable=False, default="non_assurance", index=True) due_date_rule_id: Mapped[int | None] = mapped_column(ForeignKey("service_due_date_rules.id", ondelete="SET NULL"), nullable=True, index=True) @@ -654,6 +656,7 @@ class ClientServiceTaskInstance(CommonBase): firm_task_template_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_task_templates.id", ondelete="SET NULL"), nullable=True, index=True) financial_year: Mapped[str] = mapped_column(String(9), nullable=False, default="2025-26", index=True) + period_label: Mapped[str] = mapped_column(String(30), nullable=False, default="", server_default="", index=True) assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True) task_name: Mapped[str] = mapped_column(String(200), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/app/modules/services/templates/services/engagements/bulk_form.html b/app/modules/services/templates/services/engagements/bulk_form.html index 0265705..674d7c1 100644 --- a/app/modules/services/templates/services/engagements/bulk_form.html +++ b/app/modules/services/templates/services/engagements/bulk_form.html @@ -19,7 +19,7 @@
- +
@@ -35,6 +35,18 @@

Due-date rules, recurrence, assurance type, and task workflow are taken from the selected service setup.

+
+ + + +

For monthly or quarterly services, due dates are calculated separately for each generated period.

+
+
+ {% endif %}
@@ -40,15 +40,33 @@ {{ subscription.catalogue.service_name if subscription and subscription.catalogue else '-' }} · {{ 'Assurance' if subscription and subscription.engagement_type == 'assurance' else 'Non-Assurance' }}
{% else %} - {% for selection in enabled_services %} - + {% endfor %} {% endif %} +
+ + {% if mode == 'edit' %} + + {% else %} + + + {% endif %} +

Monthly and quarterly services require a period so the statutory due date can be calculated.

+
+

The Partner who directly performs or supervises this engagement.

@@ -64,4 +82,43 @@
Cancel
+ +{% if mode != 'edit' %} + +{% endif %} {% endblock %} diff --git a/app/modules/services/templates/services/engagements/list.html b/app/modules/services/templates/services/engagements/list.html index 4a4294c..00134ea 100644 --- a/app/modules/services/templates/services/engagements/list.html +++ b/app/modules/services/templates/services/engagements/list.html @@ -84,7 +84,7 @@ {% if can_lock_engagements %}{% if not row.is_locked %}{% endif %}{% endif %}
{{ row.client.client_name if row.client else '-' }}
{{ row.client.client_code if row.client else '' }}
{{ row.catalogue.service_name if row.catalogue else '-' }}
{{ row.catalogue.service_code if row.catalogue else '' }}
-
FY: {{ row.financial_year or '-' }}
AY: {{ row.assessment_year or '-' }}
+
FY: {{ row.financial_year or '-' }}
AY: {{ row.assessment_year or '-' }}
Period: {{ row.period_label or '-' }}
Current: {{ row.current_due_date or '-' }}
Original: {{ row.original_due_date or '-' }}
{% if row.expiry_date %}
Expiry: {{ row.expiry_date }}
{% endif %}{% if row.due_date_source %}
{{ row.due_date_source|replace('_',' ')|title }}
{% endif %} {{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}
Partner: {{ row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-') }}
Performing: {{ row.performing_partner.full_name if row.performing_partner and row.performing_partner.full_name else (row.performing_partner.email if row.performing_partner else (row.assigned_partner.full_name if row.assigned_partner and row.assigned_partner.full_name else (row.assigned_partner.email if row.assigned_partner else '-'))) }}
Manager: {{ row.assigned_manager.full_name if row.assigned_manager and row.assigned_manager.full_name else (row.assigned_manager.email if row.assigned_manager else '-') }}
Staff: {{ row.assigned_staff.full_name if row.assigned_staff and row.assigned_staff.full_name else (row.assigned_staff.email if row.assigned_staff else '-') }}
Review: {{ row.review_partner.full_name if row.review_partner and row.review_partner.full_name else (row.review_partner.email if row.review_partner else '-') }}