Add periodic engagements and statutory due dates
This commit is contained in:
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,31 +291,56 @@ def subscription_create_submit(
|
||||
if not firm_selection:
|
||||
return RedirectResponse(url="/services/engagements/new", 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/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)
|
||||
|
||||
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,
|
||||
)
|
||||
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:
|
||||
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 not created_rows:
|
||||
row = existing_rows[0]
|
||||
else:
|
||||
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)
|
||||
|
||||
@@ -335,12 +366,14 @@ def subscription_create_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()
|
||||
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,22 +570,24 @@ def subscription_bulk_create_submit(
|
||||
)
|
||||
|
||||
for client_id in selected_client_ids:
|
||||
client = db.get(Client, client_id)
|
||||
if not client or client.tenant_id != tenant_id:
|
||||
skipped_count += len(requested_periods)
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
client = db.get(Client, client_id)
|
||||
if not client or client.tenant_id != tenant_id:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
row = ClientServiceSubscription(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=engagement_branch_id,
|
||||
@@ -557,6 +608,7 @@ def subscription_bulk_create_submit(
|
||||
)
|
||||
),
|
||||
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",
|
||||
@@ -567,7 +619,7 @@ def subscription_bulk_create_submit(
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
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)
|
||||
created_count += 1
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="grid gap-4 rounded-2xl bg-white p-5 shadow-soft md:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Financial Year</label>
|
||||
<input type="text" name="financial_year" value="{{ financial_year or '2025-26' }}" required placeholder="2025-26" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<input type="text" id="bulk-financial-year" name="financial_year" value="{{ financial_year or '2025-26' }}" required placeholder="2025-26" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-1 xl:col-span-2">
|
||||
@@ -35,6 +35,18 @@
|
||||
<p id="service-derived-info" class="mt-1 text-xs text-slate-500">Due-date rules, recurrence, assurance type, and task workflow are taken from the selected service setup.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Return / Engagement Period</label>
|
||||
<select name="period_label" id="bulk-period" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Not applicable</option>
|
||||
</select>
|
||||
<label id="bulk-generate-all-wrap" class="mt-2 hidden items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="generate_all_periods" id="bulk-generate-all" value="1" class="rounded border-slate-300">
|
||||
Generate all periods for the financial year
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-slate-500">For monthly or quarterly services, due dates are calculated separately for each generated period.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Engagement Partner</label>
|
||||
<select name="assigned_partner_user_id" id="bulk-partner" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
@@ -131,7 +143,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<p class="text-sm text-slate-600">Existing engagements for the same client, service and financial year are skipped automatically. No existing engagement is overwritten.</p>
|
||||
<p class="text-sm text-slate-600">Existing engagements for the same client, service, financial year and period are skipped automatically. No existing engagement is overwritten.</p>
|
||||
<div class="flex gap-3">
|
||||
<a href="/services/engagements" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button type="submit" id="create-engagements-button" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Create Engagements</button>
|
||||
@@ -144,6 +156,10 @@
|
||||
(function () {
|
||||
const form = document.getElementById('bulk-engagement-form');
|
||||
const service = document.getElementById('bulk-service');
|
||||
const fy = document.getElementById('bulk-financial-year');
|
||||
const period = document.getElementById('bulk-period');
|
||||
const generateAll = document.getElementById('bulk-generate-all');
|
||||
const generateWrap = document.getElementById('bulk-generate-all-wrap');
|
||||
const info = document.getElementById('service-derived-info');
|
||||
const reviewPartner = document.getElementById('bulk-review-partner');
|
||||
const reviewRequirement = document.getElementById('review-partner-requirement');
|
||||
@@ -169,6 +185,21 @@
|
||||
reviewHelp.textContent = assurance
|
||||
? 'Mandatory because the selected enabled firm service is an assurance engagement.'
|
||||
: 'Optional for non-assurance engagements. A selected Review Partner is saved for every engagement created in this batch.';
|
||||
const fyMatch = (fy.value || '').trim().match(/^(\d{4})-(\d{2}|\d{4})$/);
|
||||
const startYear = fyMatch ? Number(fyMatch[1]) : null;
|
||||
period.innerHTML = '';
|
||||
generateAll.checked = false;
|
||||
period.disabled = false;
|
||||
function addPeriod(value, label) { const o = document.createElement('option'); o.value = value; o.textContent = label; period.appendChild(o); }
|
||||
if (recurrence === 'monthly' && startYear) {
|
||||
[['04','Apr',startYear],['05','May',startYear],['06','Jun',startYear],['07','Jul',startYear],['08','Aug',startYear],['09','Sep',startYear],['10','Oct',startYear],['11','Nov',startYear],['12','Dec',startYear],['01','Jan',startYear+1],['02','Feb',startYear+1],['03','Mar',startYear+1]].forEach(row => addPeriod(`${row[2]}-${row[0]}`, `${row[1]} ${row[2]}`));
|
||||
period.required = true; generateWrap.classList.remove('hidden'); generateWrap.classList.add('flex');
|
||||
} else if (recurrence === 'quarterly' && startYear) {
|
||||
['Q1','Q2','Q3','Q4'].forEach(q => addPeriod(q, `${q} ${fy.value}`));
|
||||
period.required = true; generateWrap.classList.remove('hidden'); generateWrap.classList.add('flex');
|
||||
} else {
|
||||
addPeriod('', 'Not applicable'); period.required = false; generateWrap.classList.add('hidden'); generateWrap.classList.remove('flex');
|
||||
}
|
||||
info.textContent = option && option.value
|
||||
? `Type: ${assurance ? 'Assurance' : 'Non-Assurance'}${recurrence ? ' · Recurrence: ' + recurrence.replaceAll('_', ' ') : ''} · Due-date rule and workflow are taken from the service setup.`
|
||||
: 'Due-date rules, recurrence, assurance type, and task workflow are taken from the selected service setup.';
|
||||
@@ -199,6 +230,8 @@
|
||||
}
|
||||
|
||||
service.addEventListener('change', updateServiceInfo);
|
||||
fy.addEventListener('input', updateServiceInfo);
|
||||
generateAll.addEventListener('change', () => { period.disabled = generateAll.checked; });
|
||||
search.addEventListener('input', updateVisibleRows);
|
||||
clear.addEventListener('click', () => { search.value = ''; updateVisibleRows(); search.focus(); });
|
||||
selectAll.addEventListener('change', () => { visibleBoxes().forEach(box => { box.checked = selectAll.checked; }); updateCount(); });
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
{% if row.is_locked %}<div class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">This engagement is locked as historical record. It cannot be edited.</div>{% endif %}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Client & Service</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Client</dt><dd class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</dd></div><div><dt class="text-slate-500">Service</dt><dd class="font-medium text-slate-900">{{ row.catalogue.service_name if row.catalogue else '-' }}</dd></div><div><dt class="text-slate-500">Financial Year</dt><dd>{{ row.financial_year or '-' }}</dd></div><div><dt class="text-slate-500">Assessment Year</dt><dd>{{ row.assessment_year or '-' }}</dd></div><div><dt class="text-slate-500">Engagement Type</dt><dd>{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}</dd></div><div><dt class="text-slate-500">Original Due Date</dt><dd>{{ row.original_due_date or '-' }}</dd></div><div><dt class="text-slate-500">Expiry Date</dt><dd>{{ row.expiry_date or '-' }}</dd></div><div><dt class="text-slate-500">Current Due Date</dt><dd class="font-medium text-slate-900">{{ row.current_due_date or '-' }}{% if row.due_date_source %}<span class="ml-2 rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{ row.due_date_source|replace('_',' ')|title }}</span>{% endif %}</dd></div><div><dt class="text-slate-500">Status</dt><dd>{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}</dd></div><div><dt class="text-slate-500">Period</dt><dd>{{ row.start_date or '-' }} to {{ row.end_date or '-' }}</dd></div></dl></section>
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Client & Service</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Client</dt><dd class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</dd></div><div><dt class="text-slate-500">Service</dt><dd class="font-medium text-slate-900">{{ row.catalogue.service_name if row.catalogue else '-' }}</dd></div><div><dt class="text-slate-500">Financial Year</dt><dd>{{ row.financial_year or '-' }}</dd></div><div><dt class="text-slate-500">Assessment Year</dt><dd>{{ row.assessment_year or '-' }}</dd></div><div><dt class="text-slate-500">Return / Engagement Period</dt><dd>{{ row.period_label or 'Not applicable' }}</dd></div><div><dt class="text-slate-500">Engagement Type</dt><dd>{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}</dd></div><div><dt class="text-slate-500">Original Due Date</dt><dd>{{ row.original_due_date or '-' }}</dd></div><div><dt class="text-slate-500">Expiry Date</dt><dd>{{ row.expiry_date or '-' }}</dd></div><div><dt class="text-slate-500">Current Due Date</dt><dd class="font-medium text-slate-900">{{ row.current_due_date or '-' }}{% if row.due_date_source %}<span class="ml-2 rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">{{ row.due_date_source|replace('_',' ')|title }}</span>{% endif %}</dd></div><div><dt class="text-slate-500">Status</dt><dd>{{ 'Locked' if row.is_locked else row.status|replace('_',' ')|title }}{% if not row.is_active %} / Inactive{% endif %}</dd></div><div><dt class="text-slate-500">Engagement Dates</dt><dd>{{ row.start_date or '-' }} to {{ row.end_date or '-' }}</dd></div></dl></section>
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft"><h3 class="text-sm font-semibold text-slate-900">Assignment</h3><dl class="mt-4 space-y-3 text-sm"><div><dt class="text-slate-500">Partner</dt><dd>{{ 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 '-') }}</dd></div><div><dt class="text-slate-500">Performing Partner</dt><dd>{{ 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 '-'))) }}</dd></div><div><dt class="text-slate-500">Manager</dt><dd>{{ 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 '-') }}</dd></div><div><dt class="text-slate-500">Staff</dt><dd>{{ 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 '-') }}</dd></div><div><dt class="text-slate-500">Review Partner</dt><dd>{{ 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 '-') }}</dd></div></dl></section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{% if mode == 'edit' %}
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-2 text-sm text-slate-700">{{ subscription.financial_year }}</div>
|
||||
{% else %}
|
||||
<input type="text" name="financial_year" value="{{ financial_year or '2025-26' }}" required placeholder="2025-26" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<input type="text" id="engagement-financial-year" name="financial_year" value="{{ financial_year or '2025-26' }}" required placeholder="2025-26" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -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' }}
|
||||
</div>
|
||||
{% else %}
|
||||
<select name="service_catalogue_id" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<select name="service_catalogue_id" id="engagement-service" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Select service</option>
|
||||
{% for selection in enabled_services %}
|
||||
<option value="{{ selection.catalogue.id }}">{{ selection.catalogue.service_name }} ({{ selection.catalogue.service_code }}) - {{ 'Assurance' if selection.catalogue.engagement_type == 'assurance' else 'Non-Assurance' }}</option>
|
||||
<option value="{{ selection.catalogue.id }}" data-recurrence="{{ selection.catalogue.recurrence_type or '' }}">{{ selection.catalogue.service_name }} ({{ selection.catalogue.service_code }}) - {{ 'Assurance' if selection.catalogue.engagement_type == 'assurance' else 'Non-Assurance' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="engagement-period-field">
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Return / Engagement Period</label>
|
||||
{% if mode == 'edit' %}
|
||||
<select name="period_label" required class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
{% for value, label in period_choices %}<option value="{{ value }}" {% if subscription and subscription.period_label == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<select name="period_label" id="engagement-period" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm">
|
||||
<option value="">Not applicable</option>
|
||||
</select>
|
||||
<label id="generate-all-periods-wrap" class="mt-2 hidden items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="generate_all_periods" id="generate-all-periods" value="1" class="rounded border-slate-300">
|
||||
Generate all periods for this financial year
|
||||
</label>
|
||||
{% endif %}
|
||||
<p class="mt-1 text-xs text-slate-500">Monthly and quarterly services require a period so the statutory due date can be calculated.</p>
|
||||
</div>
|
||||
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Assigned Partner</label><select name="assigned_partner_user_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in assignable_users %}<option value="{{ u.id }}" {% if subscription and subscription.assigned_partner_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Performing Partner</label><select name="performing_partner_user_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Use client default / Engagement Partner</option>{% for u in performing_partners or [] %}<option value="{{ u.id }}" {% if subscription and subscription.performing_partner_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }}</option>{% endfor %}</select><p class="mt-1 text-xs text-slate-500">The Partner who directly performs or supervises this engagement.</p></div>
|
||||
<div><label class="mb-2 block text-sm font-medium text-slate-700">Assigned Manager</label><select name="assigned_manager_user_id" class="w-full rounded-xl border border-slate-300 px-4 py-2 text-sm"><option value="">Not assigned</option>{% for u in assignable_users %}<option value="{{ u.id }}" {% if subscription and subscription.assigned_manager_user_id == u.id %}selected{% endif %}>{{ u.full_name or u.email }}</option>{% endfor %}</select></div>
|
||||
@@ -64,4 +82,43 @@
|
||||
<div class="md:col-span-2 flex justify-end gap-3"><a href="/services/engagements" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Save</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if mode != 'edit' %}
|
||||
<script>
|
||||
(function () {
|
||||
const service = document.getElementById('engagement-service');
|
||||
const fy = document.getElementById('engagement-financial-year');
|
||||
const period = document.getElementById('engagement-period');
|
||||
const generateWrap = document.getElementById('generate-all-periods-wrap');
|
||||
const generateAll = document.getElementById('generate-all-periods');
|
||||
if (!service || !fy || !period) return;
|
||||
|
||||
function fyStart() {
|
||||
const match = (fy.value || '').trim().match(/^(\d{4})-(\d{2}|\d{4})$/);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
function add(value, label) {
|
||||
const option = document.createElement('option'); option.value = value; option.textContent = label; period.appendChild(option);
|
||||
}
|
||||
function rebuild() {
|
||||
const selected = service.options[service.selectedIndex];
|
||||
const recurrence = ((selected && selected.dataset.recurrence) || '').toLowerCase().replaceAll('-', '_').replaceAll(' ', '_');
|
||||
const start = fyStart();
|
||||
period.innerHTML = '';
|
||||
generateAll.checked = false;
|
||||
if (recurrence === 'monthly' && start) {
|
||||
[['04','Apr',start],['05','May',start],['06','Jun',start],['07','Jul',start],['08','Aug',start],['09','Sep',start],['10','Oct',start],['11','Nov',start],['12','Dec',start],['01','Jan',start+1],['02','Feb',start+1],['03','Mar',start+1]].forEach(row => add(`${row[2]}-${row[0]}`, `${row[1]} ${row[2]}`));
|
||||
period.required = true; generateWrap.classList.remove('hidden'); generateWrap.classList.add('flex');
|
||||
} else if (recurrence === 'quarterly' && start) {
|
||||
['Q1','Q2','Q3','Q4'].forEach(q => add(q, `${q} ${fy.value}`));
|
||||
period.required = true; generateWrap.classList.remove('hidden'); generateWrap.classList.add('flex');
|
||||
} else {
|
||||
add('', 'Not applicable'); period.required = false; generateWrap.classList.add('hidden'); generateWrap.classList.remove('flex');
|
||||
}
|
||||
}
|
||||
generateAll.addEventListener('change', () => { period.disabled = generateAll.checked; });
|
||||
service.addEventListener('change', rebuild); fy.addEventListener('input', rebuild); rebuild();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
{% if can_lock_engagements %}<td class="px-4 py-3 text-sm">{% if not row.is_locked %}<input type="checkbox" class="engagement-lock-checkbox rounded border-slate-300" name="subscription_ids" value="{{ row.id }}">{% endif %}</td>{% endif %}
|
||||
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ row.client.client_name if row.client else '-' }}</div><div class="text-xs text-slate-500">{{ row.client.client_code if row.client else '' }}</div></td>
|
||||
<td class="px-4 py-3 text-sm"><div class="font-medium text-slate-900">{{ row.catalogue.service_name if row.catalogue else '-' }}</div><div class="text-xs text-slate-500">{{ row.catalogue.service_code if row.catalogue else '' }}</div></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600"><div>FY: {{ row.financial_year or '-' }}</div><div>AY: {{ row.assessment_year or '-' }}</div></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600"><div>FY: {{ row.financial_year or '-' }}</div><div>AY: {{ row.assessment_year or '-' }}</div><div>Period: {{ row.period_label or '-' }}</div></td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600"><div>Current: <span class="font-medium text-slate-900">{{ row.current_due_date or '-' }}</span></div><div>Original: {{ row.original_due_date or '-' }}</div>{% if row.expiry_date %}<div>Expiry: {{ row.expiry_date }}</div>{% endif %}{% if row.due_date_source %}<div class="text-slate-500">{{ row.due_date_source|replace('_',' ')|title }}</div>{% endif %}</td>
|
||||
<td class="px-4 py-3 text-sm text-slate-700">{{ 'Assurance' if row.engagement_type == 'assurance' else 'Non-Assurance' }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600"><div>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 '-') }}</div><div>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 '-'))) }}</div><div>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 '-') }}</div><div>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 '-') }}</div><div>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 '-') }}</div></td>
|
||||
|
||||
Reference in New Issue
Block a user