Add engagement financial year correction workflow
This commit is contained in:
@@ -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),
|
||||
)
|
||||
@@ -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.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.services.engagement_fy_correction import (
|
||||
correct_engagement_financial_year,
|
||||
validate_financial_year,
|
||||
)
|
||||
|
||||
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"}))
|
||||
|
||||
|
||||
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:
|
||||
roles = set(get_user_roles(db, user.id))
|
||||
if "Firm Admin" in roles:
|
||||
@@ -217,6 +240,9 @@ def subscription_list(
|
||||
bulk_created: int = 0,
|
||||
bulk_existing: int = 0,
|
||||
bulk_skipped: int = 0,
|
||||
fy_corrected: int = 0,
|
||||
fy_skipped: int = 0,
|
||||
fy_error: str = "",
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
@@ -263,6 +289,10 @@ def subscription_list(
|
||||
bulk_skipped_count=bulk_skipped,
|
||||
can_manage=_can_manage_client_services(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:
|
||||
db.close()
|
||||
@@ -773,6 +803,83 @@ def subscription_bulk_lock(
|
||||
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}")
|
||||
def subscription_detail(request: Request, subscription_id: int):
|
||||
db = CommonSessionLocal()
|
||||
@@ -828,6 +935,10 @@ def subscription_detail(request: Request, subscription_id: int):
|
||||
closure_checklist=closure_checklist,
|
||||
closure_summary=closure_summary,
|
||||
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:
|
||||
db.close()
|
||||
@@ -1101,6 +1212,63 @@ def subscription_closure_reopen(
|
||||
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")
|
||||
def subscription_edit_page(request: Request, subscription_id: int):
|
||||
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 %}
|
||||
</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 %}
|
||||
<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>
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
</div>
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
@@ -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>
|
||||
</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="financial_year" value="{{ financial_year or '' }}">
|
||||
<input type="hidden" name="q" value="{{ q or '' }}">
|
||||
{% if include_inactive %}<input type="hidden" name="include_inactive" value="true">{% endif %}
|
||||
{% if can_lock_engagements %}
|
||||
<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">Select completed engagements and lock them in bulk. Locked engagements become 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">Lock Selected Engagements</button>
|
||||
{% if can_lock_engagements or can_correct_engagement_fy %}
|
||||
<div class="space-y-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
{% if can_lock_engagements %}
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
@@ -67,7 +95,7 @@
|
||||
<table class="min-w-full divide-y divide-slate-200">
|
||||
<thead class="bg-slate-50">
|
||||
<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">Service</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">
|
||||
{% for row in rows %}
|
||||
<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.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>
|
||||
|
||||
Reference in New Issue
Block a user