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
+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.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()