Add engagement financial year correction workflow

This commit is contained in:
A R R R Associates
2026-09-03 14:23:14 +05:30
parent 5dce7f16f9
commit 9068b523bc
4 changed files with 533 additions and 7 deletions
@@ -0,0 +1,299 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from fastapi import Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.core.audit.models import AuditLog
from app.modules.core.tenancy.year_control import is_financial_year_locked, is_row_financial_year_locked
from app.modules.documents.models import EngagementDocument
from app.modules.services.client_services import assessment_year_from_financial_year, period_choices_for_service
from app.modules.services.due_dates import apply_due_date_rule_to_subscription
from app.modules.services.models import (
ClientServiceSubscription,
ClientServiceTaskInstance,
EngagementClosureChecklist,
)
_FINANCIAL_YEAR_RE = re.compile(r"^(\d{4})-(\d{2})$")
_CLOSED_ENGAGEMENT_STATUSES = {"completed", "cancelled", "inactive"}
@dataclass(frozen=True)
class EngagementFYCorrectionResult:
subscription_id: int
old_financial_year: str
new_financial_year: str
old_period_label: str
new_period_label: str
task_count: int
document_count: int
def validate_financial_year(value: str | None) -> str:
raw = (value or "").strip()
match = _FINANCIAL_YEAR_RE.fullmatch(raw)
if not match:
raise ValueError("Financial year must be in YYYY-YY format, for example 2025-26.")
start_year = int(match.group(1))
expected_suffix = str(start_year + 1)[-2:]
if match.group(2) != expected_suffix:
raise ValueError("Financial year end year does not match the start year.")
return f"{start_year:04d}-{expected_suffix}"
def _remap_period_label(row: ClientServiceSubscription, target_financial_year: str) -> str:
recurrence = (getattr(getattr(row, "catalogue", None), "recurrence_type", None) or "one_time").strip().lower()
current = (getattr(row, "period_label", None) or "").strip()
choices = period_choices_for_service(target_financial_year, recurrence)
valid_codes = [code for code, _label in choices]
if recurrence == "monthly":
# Period codes are YYYY-MM. Keep the same month while moving it to the
# corresponding month inside the corrected financial year.
try:
month = int(current.rsplit("-", 1)[1])
except (ValueError, IndexError):
raise ValueError("The existing monthly period is invalid and cannot be remapped automatically.")
target_start = int(target_financial_year.split("-", 1)[0])
target_year = target_start if month >= 4 else target_start + 1
candidate = f"{target_year:04d}-{month:02d}"
if candidate not in valid_codes:
raise ValueError("The monthly period cannot be mapped to the corrected financial year.")
return candidate
if recurrence == "quarterly":
if current not in valid_codes:
raise ValueError("The existing quarter is invalid for the corrected financial year.")
return current
return ""
def _engagement_has_finalised_documents(db: Session, subscription_id: int) -> bool:
rows = db.execute(
select(EngagementDocument).where(
EngagementDocument.engagement_id == subscription_id,
EngagementDocument.is_deleted.is_(False),
)
).scalars().all()
for document in rows:
if (getattr(document, "final_release_status", None) or "draft").strip().lower() == "released":
return True
if (getattr(document, "udin_number", None) or "").strip():
return True
return False
def validate_engagement_can_change_financial_year(
db: Session,
*,
row: ClientServiceSubscription,
target_financial_year: str,
) -> None:
if getattr(row, "is_locked", False):
raise ValueError("Locked engagements cannot be moved to another financial year.")
if is_row_financial_year_locked(db, row):
raise ValueError(f"Source FY {row.financial_year} is locked and cannot be changed.")
if is_financial_year_locked(db, tenant_id=row.tenant_id, year_code=target_financial_year):
raise ValueError(f"Target FY {target_financial_year} is locked and cannot receive an engagement.")
if not getattr(row, "is_active", True):
raise ValueError("Inactive engagements cannot be moved to another financial year.")
status = (getattr(row, "status", None) or "active").strip().lower()
if status in _CLOSED_ENGAGEMENT_STATUSES:
raise ValueError("Only open engagements can have their financial year corrected.")
closure = db.execute(
select(EngagementClosureChecklist).where(
EngagementClosureChecklist.subscription_id == row.id
)
).scalar_one_or_none()
if closure and (closure.closure_status or "").strip().lower() == "approved":
raise ValueError("The engagement closure is already approved. Reopen it before correcting the financial year.")
if _engagement_has_finalised_documents(db, row.id):
raise ValueError("A released final document or UDIN exists. Reopen/correct the finalisation workflow before changing FY.")
def _duplicate_engagement(
db: Session,
*,
row: ClientServiceSubscription,
target_financial_year: str,
target_period_label: str,
) -> ClientServiceSubscription | None:
return db.execute(
select(ClientServiceSubscription).where(
ClientServiceSubscription.tenant_id == row.tenant_id,
ClientServiceSubscription.service_catalogue_id == row.service_catalogue_id,
ClientServiceSubscription.scope_key == row.scope_key,
ClientServiceSubscription.financial_year == target_financial_year,
ClientServiceSubscription.period_label == target_period_label,
ClientServiceSubscription.id != row.id,
)
).scalar_one_or_none()
def _add_audit_log(
db: Session,
*,
request: Request | None,
row: ClientServiceSubscription,
actor,
old_financial_year: str,
new_financial_year: str,
old_period_label: str,
new_period_label: str,
reason: str,
task_count: int,
document_count: int,
) -> None:
ip_address = request.client.host if request and request.client else None
user_agent = request.headers.get("user-agent") if request else None
client_name = getattr(getattr(row, "client", None), "client_name", None) or f"Client {row.client_id}"
service_name = getattr(getattr(row, "catalogue", None), "service_name", None) or f"Service {row.service_catalogue_id}"
db.add(
AuditLog(
actor_user_id=getattr(actor, "id", None),
actor_email=getattr(actor, "email", None),
actor_tenant_id=getattr(actor, "tenant_id", None),
actor_branch_id=getattr(actor, "branch_id", None),
action="engagement.financial_year_corrected",
entity_type="ClientServiceSubscription",
entity_id=str(row.id),
entity_name=f"{client_name} - {service_name}",
status="success",
target_tenant_id=row.tenant_id,
target_branch_id=row.branch_id,
ip_address=ip_address,
user_agent=user_agent,
details_json=json.dumps(
{
"old_financial_year": old_financial_year,
"new_financial_year": new_financial_year,
"old_assessment_year": assessment_year_from_financial_year(old_financial_year),
"new_assessment_year": assessment_year_from_financial_year(new_financial_year),
"old_period_label": old_period_label,
"new_period_label": new_period_label,
"reason": reason,
"task_instances_updated": task_count,
"engagement_documents_updated": document_count,
"physical_document_paths_moved": False,
},
ensure_ascii=False,
sort_keys=True,
),
)
)
def correct_engagement_financial_year(
db: Session,
*,
row: ClientServiceSubscription,
target_financial_year: str,
actor,
reason: str,
request: Request | None = None,
) -> EngagementFYCorrectionResult:
target_fy = validate_financial_year(target_financial_year)
reason_text = (reason or "").strip()
if not reason_text:
raise ValueError("A correction reason is required for the audit trail.")
old_fy = validate_financial_year(row.financial_year)
if target_fy == old_fy:
raise ValueError("The corrected financial year is the same as the existing financial year.")
validate_engagement_can_change_financial_year(db, row=row, target_financial_year=target_fy)
old_period = (row.period_label or "").strip()
new_period = _remap_period_label(row, target_fy)
duplicate = _duplicate_engagement(
db,
row=row,
target_financial_year=target_fy,
target_period_label=new_period,
)
if duplicate:
raise ValueError(
f"A matching engagement already exists in FY {target_fy}"
+ (f" for period {new_period}" if new_period else "")
+ "."
)
# Pre-flight the task unique key before mutating anything. A partially
# inconsistent legacy subscription must be repaired manually rather than
# losing execution/review history.
target_task = db.execute(
select(ClientServiceTaskInstance.id).where(
ClientServiceTaskInstance.subscription_id == row.id,
ClientServiceTaskInstance.financial_year == target_fy,
).limit(1)
).scalar_one_or_none()
if target_task is not None:
raise ValueError(
f"This engagement already has a task instance tagged to FY {target_fy}. "
"The mixed-year task data must be reviewed before FY correction."
)
new_ay = assessment_year_from_financial_year(target_fy)
tasks = db.execute(
select(ClientServiceTaskInstance).where(
ClientServiceTaskInstance.subscription_id == row.id
)
).scalars().all()
documents = db.execute(
select(EngagementDocument).where(
EngagementDocument.engagement_id == row.id,
EngagementDocument.is_deleted.is_(False),
)
).scalars().all()
row.financial_year = target_fy
row.assessment_year = new_ay
row.period_label = new_period
row.updated_by_user_id = getattr(actor, "id", None)
for task in tasks:
task.financial_year = target_fy
task.assessment_year = new_ay
task.period_label = new_period
task.updated_by_user_id = getattr(actor, "id", None)
# Document metadata follows the corrected engagement. Existing physical
# revision paths are deliberately preserved so no already-uploaded evidence
# is orphaned; future uploads use the corrected engagement FY.
for document in documents:
document.financial_year = target_fy
document.assessment_year = new_ay
document.updated_by_user_id = getattr(actor, "id", None)
apply_due_date_rule_to_subscription(db, row, force=True)
_add_audit_log(
db,
request=request,
row=row,
actor=actor,
old_financial_year=old_fy,
new_financial_year=target_fy,
old_period_label=old_period,
new_period_label=new_period,
reason=reason_text,
task_count=len(tasks),
document_count=len(documents),
)
db.flush()
return EngagementFYCorrectionResult(
subscription_id=row.id,
old_financial_year=old_fy,
new_financial_year=target_fy,
old_period_label=old_period,
new_period_label=new_period,
task_count=len(tasks),
document_count=len(documents),
)
+168
View File
@@ -54,6 +54,10 @@ from app.modules.services.client_services import (
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission from app.modules.core.rbac.permission_guard import require_permission
from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked, is_row_financial_year_locked from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked, is_row_financial_year_locked
from app.modules.services.engagement_fy_correction import (
correct_engagement_financial_year,
validate_financial_year,
)
router = APIRouter(prefix="/services/engagements", tags=["services-engagements-ui"]) router = APIRouter(prefix="/services/engagements", tags=["services-engagements-ui"])
@@ -126,6 +130,25 @@ def _can_lock_engagements(db, user) -> bool:
return bool(roles.intersection({"Firm Admin", "Partner"})) return bool(roles.intersection({"Firm Admin", "Partner"}))
def _can_correct_engagement_financial_year(db, user) -> bool:
roles = set(get_user_roles(db, user.id))
return bool(roles.intersection({"Firm Admin", "Partner"}))
def _user_can_correct_engagement_financial_year(db, user, row: ClientServiceSubscription) -> bool:
roles = set(get_user_roles(db, user.id))
if "Firm Admin" in roles:
return True
if "Partner" in roles:
client = getattr(row, "client", None)
return bool(
getattr(row, "assigned_partner_user_id", None) == user.id
or getattr(row, "performing_partner_user_id", None) == user.id
or getattr(client, "partner_id", None) == user.id
)
return False
def _user_can_lock_subscription(db, user, row: ClientServiceSubscription) -> bool: def _user_can_lock_subscription(db, user, row: ClientServiceSubscription) -> bool:
roles = set(get_user_roles(db, user.id)) roles = set(get_user_roles(db, user.id))
if "Firm Admin" in roles: if "Firm Admin" in roles:
@@ -217,6 +240,9 @@ def subscription_list(
bulk_created: int = 0, bulk_created: int = 0,
bulk_existing: int = 0, bulk_existing: int = 0,
bulk_skipped: int = 0, bulk_skipped: int = 0,
fy_corrected: int = 0,
fy_skipped: int = 0,
fy_error: str = "",
): ):
db = CommonSessionLocal() db = CommonSessionLocal()
try: try:
@@ -263,6 +289,10 @@ def subscription_list(
bulk_skipped_count=bulk_skipped, bulk_skipped_count=bulk_skipped,
can_manage=_can_manage_client_services(db, user), can_manage=_can_manage_client_services(db, user),
can_lock_engagements=_can_lock_engagements(db, user), can_lock_engagements=_can_lock_engagements(db, user),
can_correct_engagement_fy=_can_correct_engagement_financial_year(db, user),
fy_corrected_count=fy_corrected,
fy_skipped_count=fy_skipped,
fy_error=fy_error,
) )
finally: finally:
db.close() db.close()
@@ -773,6 +803,83 @@ def subscription_bulk_lock(
db.close() db.close()
@router.post("/bulk-correct-financial-year")
def subscription_bulk_correct_financial_year(
request: Request,
subscription_ids: list[int] = Form([]),
target_financial_year: str = Form(...),
correction_reason: str = Form(...),
financial_year: str = Form(""),
q: str = Form(""),
include_inactive: str | None = Form(None),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
corrected = 0
skipped = 0
first_error = ""
target_fy = (target_financial_year or "").strip()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
if not _can_correct_engagement_financial_year(db, user):
return _redirect_denied()
tenant_id = _active_tenant_id(request, user)
try:
target_fy = validate_financial_year(target_fy)
except ValueError as exc:
from urllib.parse import quote_plus
source_fy = normalize_financial_year(financial_year or _active_financial_year(request))
return RedirectResponse(
url=f"/services/engagements?financial_year={source_fy}&fy_error={quote_plus(str(exc))}",
status_code=303,
)
for subscription_id in [int(value) for value in subscription_ids if value]:
row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
if not row or not _user_can_correct_engagement_financial_year(db, user, row):
skipped += 1
if not first_error:
first_error = "One or more selected engagements were unavailable or not permitted."
continue
try:
with db.begin_nested():
correct_engagement_financial_year(
db,
row=row,
target_financial_year=target_fy,
actor=user,
reason=correction_reason,
request=request,
)
corrected += 1
except ValueError as exc:
skipped += 1
if not first_error:
first_error = str(exc)
db.commit()
from urllib.parse import quote_plus
source_fy = normalize_financial_year(financial_year or _active_financial_year(request))
params = [
f"financial_year={source_fy}",
f"fy_corrected={corrected}",
f"fy_skipped={skipped}",
]
if q.strip():
params.append(f"q={quote_plus(q.strip())}")
if include_inactive:
params.append("include_inactive=true")
if first_error:
params.append(f"fy_error={quote_plus(first_error)}")
return RedirectResponse(url=f"/services/engagements?{'&'.join(params)}", status_code=303)
finally:
db.close()
@router.get("/{subscription_id}") @router.get("/{subscription_id}")
def subscription_detail(request: Request, subscription_id: int): def subscription_detail(request: Request, subscription_id: int):
db = CommonSessionLocal() db = CommonSessionLocal()
@@ -828,6 +935,10 @@ def subscription_detail(request: Request, subscription_id: int):
closure_checklist=closure_checklist, closure_checklist=closure_checklist,
closure_summary=closure_summary, closure_summary=closure_summary,
can_manage=_can_manage_client_services(db, user), can_manage=_can_manage_client_services(db, user),
can_correct_engagement_fy=(
_can_correct_engagement_financial_year(db, user)
and _user_can_correct_engagement_financial_year(db, user, row)
),
) )
finally: finally:
db.close() db.close()
@@ -1101,6 +1212,63 @@ def subscription_closure_reopen(
db.close() db.close()
@router.post("/{subscription_id}/correct-financial-year")
def subscription_correct_financial_year(
request: Request,
subscription_id: int,
target_financial_year: str = Form(...),
correction_reason: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
if not _can_correct_engagement_financial_year(db, user):
return _redirect_denied()
tenant_id = _active_tenant_id(request, user)
row = get_subscription(db, subscription_id=subscription_id, tenant_id=tenant_id)
if not row:
return RedirectResponse(url="/services/engagements", status_code=303)
if not _user_can_correct_engagement_financial_year(db, user, row):
return _redirect_denied()
old_fy = row.financial_year
try:
result = correct_engagement_financial_year(
db,
row=row,
target_financial_year=target_financial_year,
actor=user,
reason=correction_reason,
request=request,
)
db.commit()
except ValueError as exc:
db.rollback()
from urllib.parse import quote_plus
return RedirectResponse(
url=f"/services/engagements/{subscription_id}?fy_error={quote_plus(str(exc))}",
status_code=303,
)
# The active session FY may still be the old year. Return to the corrected
# FY list rather than silently changing the user's global year context.
from urllib.parse import quote_plus
return RedirectResponse(
url=(
f"/services/engagements?financial_year={result.new_financial_year}"
f"&fy_corrected=1&fy_from={quote_plus(old_fy)}"
),
status_code=303,
)
finally:
db.close()
@router.get("/{subscription_id}/edit") @router.get("/{subscription_id}/edit")
def subscription_edit_page(request: Request, subscription_id: int): def subscription_edit_page(request: Request, subscription_id: int):
db = CommonSessionLocal() db = CommonSessionLocal()
@@ -8,6 +8,37 @@
{% if can_manage and not row.is_locked %}<a href="/services/engagements/{{ row.id }}/edit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Edit</a>{% endif %} {% if can_manage and not row.is_locked %}<a href="/services/engagements/{{ row.id }}/edit" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">Edit</a>{% endif %}
</div> </div>
</div> </div>
{% if request.query_params.get('fy_error') %}
<div class="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm font-medium text-red-800">{{ request.query_params.get('fy_error') }}</div>
{% endif %}
{% if can_correct_engagement_fy and not row.is_locked and row.is_active and row.status not in ['completed', 'cancelled', 'inactive'] %}
<section class="rounded-2xl border border-indigo-200 bg-indigo-50 p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-indigo-950">Correct Financial Year</h3>
<p class="mt-1 text-sm text-indigo-800">Partner/Firm Admin correction for an engagement created under the wrong FY. Existing tasks, evidence, comments and review history are preserved; FY/AY metadata and due-date calculation are synchronised.</p>
</div>
<span class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-indigo-700">Current FY {{ row.financial_year }}</span>
</div>
<form method="post" action="/services/engagements/{{ row.id }}/correct-financial-year" class="mt-4 grid gap-3 md:grid-cols-[180px_1fr_auto]" onsubmit="return confirm('Correct this engagement financial year? Linked task and document FY metadata will be updated while existing execution/review history is preserved.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-indigo-700">Correct FY</label>
<input type="text" name="target_financial_year" required placeholder="2025-26" class="w-full rounded-xl border border-indigo-200 bg-white px-3 py-2 text-sm">
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-indigo-700">Reason</label>
<input type="text" name="correction_reason" required placeholder="Engagement was created under incorrect financial year" class="w-full rounded-xl border border-indigo-200 bg-white px-3 py-2 text-sm">
</div>
<div class="flex items-end">
<button type="submit" class="rounded-xl bg-indigo-700 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-800">Update FY</button>
</div>
</form>
<p class="mt-3 text-xs text-indigo-700">Locked/closed engagements, locked FYs, released final documents/UDINs and duplicate target engagements are blocked from correction.</p>
</section>
{% endif %}
{% 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 %} {% 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"> <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">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">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>
@@ -28,6 +28,14 @@
</div> </div>
{% endif %} {% endif %}
{% if fy_corrected_count or fy_skipped_count or fy_error %}
<div class="rounded-2xl border {% if fy_error and not fy_corrected_count %}border-red-200 bg-red-50{% else %}border-indigo-200 bg-indigo-50{% endif %} p-4 text-sm shadow-soft">
{% if fy_corrected_count %}<span class="font-medium text-emerald-700">{{ fy_corrected_count }} engagement{{ 's' if fy_corrected_count != 1 else '' }} moved to the corrected financial year.</span>{% endif %}
{% if fy_skipped_count %}<span class="ml-2 font-medium text-amber-700">{{ fy_skipped_count }} selected engagement{{ 's' if fy_skipped_count != 1 else '' }} skipped.</span>{% endif %}
{% if fy_error %}<span class="ml-2 text-red-700">{{ fy_error }}</span>{% endif %}
</div>
{% endif %}
{% if locked_count or skipped_count %} {% if locked_count or skipped_count %}
<div class="rounded-2xl border border-slate-200 bg-white p-4 text-sm shadow-soft"> <div class="rounded-2xl border border-slate-200 bg-white p-4 text-sm shadow-soft">
{% if locked_count %}<span class="font-medium text-emerald-700">{{ locked_count }} engagement{{ 's' if locked_count != 1 else '' }} locked.</span>{% endif %} {% if locked_count %}<span class="font-medium text-emerald-700">{{ locked_count }} engagement{{ 's' if locked_count != 1 else '' }} locked.</span>{% endif %}
@@ -51,15 +59,35 @@
<button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Filter</button> <button class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Filter</button>
</form> </form>
<form method="post" action="/services/engagements/bulk-lock" class="space-y-3" onsubmit="return confirm('Lock selected engagements? Locked engagements become read-only history.');"> <form method="post" action="/services/engagements/bulk-lock" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="financial_year" value="{{ financial_year or '' }}"> <input type="hidden" name="financial_year" value="{{ financial_year or '' }}">
<input type="hidden" name="q" value="{{ q or '' }}"> <input type="hidden" name="q" value="{{ q or '' }}">
{% if include_inactive %}<input type="hidden" name="include_inactive" value="true">{% endif %} {% if include_inactive %}<input type="hidden" name="include_inactive" value="true">{% endif %}
{% if can_lock_engagements %} {% if can_lock_engagements or can_correct_engagement_fy %}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-soft"> <div class="space-y-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
<p class="text-sm text-slate-600">Select completed engagements and lock them in bulk. Locked engagements become read-only history.</p> {% if can_lock_engagements %}
<button type="submit" class="rounded-xl bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700">Lock Selected Engagements</button> <div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-slate-600">Select engagements below. Completed engagements can be locked as read-only history.</p>
<button type="submit" class="rounded-xl bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700" onclick="return confirm('Lock selected engagements? Locked engagements become read-only history.');">Lock Selected Engagements</button>
</div>
{% endif %}
{% if can_correct_engagement_fy %}
<div class="border-t border-slate-200 pt-4">
<div class="flex flex-wrap items-end gap-3">
<div class="min-w-[150px]">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-indigo-700">Correct selected to FY</label>
<input type="text" name="target_financial_year" placeholder="2025-26" class="w-40 rounded-xl border border-indigo-200 px-3 py-2 text-sm">
</div>
<div class="min-w-[280px] flex-1">
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-indigo-700">Correction reason</label>
<input type="text" name="correction_reason" placeholder="Engagements created under incorrect financial year" class="w-full rounded-xl border border-indigo-200 px-3 py-2 text-sm">
</div>
<button type="submit" formaction="/services/engagements/bulk-correct-financial-year" class="rounded-xl bg-indigo-700 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-800" onclick="return confirm('Correct FY for the selected open engagements? Existing task/evidence/review history will be preserved.');">Update Selected FY</button>
</div>
<p class="mt-2 text-xs text-slate-500">Partner/Firm Admin only. Only open, active, unlocked engagements are changed. Duplicate target engagements and locked years are skipped.</p>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -67,7 +95,7 @@
<table class="min-w-full divide-y divide-slate-200"> <table class="min-w-full divide-y divide-slate-200">
<thead class="bg-slate-50"> <thead class="bg-slate-50">
<tr> <tr>
{% if can_lock_engagements %}<th class="w-10 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><input type="checkbox" onclick="document.querySelectorAll('.engagement-lock-checkbox').forEach(cb => cb.checked = this.checked)"></th>{% endif %} {% if can_lock_engagements %}<th class="w-10 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><input type="checkbox" onclick="document.querySelectorAll('.engagement-select-checkbox').forEach(cb => cb.checked = this.checked)"></th>{% endif %}
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th> <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Service</th> <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Service</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">FY / AY</th> <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">FY / AY</th>
@@ -81,7 +109,7 @@
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
{% for row in rows %} {% for row in rows %}
<tr> <tr>
{% 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 %} {% if can_lock_engagements %}<td class="px-4 py-3 text-sm">{% if not row.is_locked %}<input type="checkbox" class="engagement-select-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.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-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><div>Period: {{ row.period_label 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>