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.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>
|
||||||
|
|||||||
Reference in New Issue
Block a user