Auto-create and repair subscription engagements
This commit is contained in:
@@ -224,8 +224,169 @@ def _engagement_is_effective(row: ClientServiceSubscription, effective_from: dat
|
||||
return comparison_date is None or comparison_date >= effective_from
|
||||
|
||||
|
||||
|
||||
def _current_period_for_financial_year(financial_year: str, recurrence_type: str | None) -> str:
|
||||
"""Return the current period inside the selected FY for recurring services.
|
||||
|
||||
For the active FY this means the current calendar month / FY quarter. The
|
||||
caller still passes the value through normalize_period_label, so the same
|
||||
validation used by the existing engagement creation flow remains in force.
|
||||
"""
|
||||
recurrence = (recurrence_type or "").strip().lower().replace("-", "_").replace(" ", "_")
|
||||
today = date.today()
|
||||
fy = normalize_financial_year(financial_year)
|
||||
start_year = int(fy.split("-", 1)[0])
|
||||
end_year = start_year + 1
|
||||
|
||||
if recurrence == "monthly":
|
||||
if today.year < start_year or (today.year == start_year and today.month < 4):
|
||||
year, month = start_year, 4
|
||||
elif today.year > end_year or (today.year == end_year and today.month > 3):
|
||||
year, month = end_year, 3
|
||||
else:
|
||||
year, month = today.year, today.month
|
||||
return normalize_period_label(
|
||||
f"{year:04d}-{month:02d}",
|
||||
financial_year=fy,
|
||||
recurrence_type=recurrence,
|
||||
)
|
||||
|
||||
if recurrence == "quarterly":
|
||||
if today.year < start_year or (today.year == start_year and today.month < 4):
|
||||
quarter = "Q1"
|
||||
elif today.year > end_year or (today.year == end_year and today.month > 3):
|
||||
quarter = "Q4"
|
||||
elif today.month in (4, 5, 6):
|
||||
quarter = "Q1"
|
||||
elif today.month in (7, 8, 9):
|
||||
quarter = "Q2"
|
||||
elif today.month in (10, 11, 12):
|
||||
quarter = "Q3"
|
||||
else:
|
||||
quarter = "Q4"
|
||||
return normalize_period_label(
|
||||
quarter,
|
||||
financial_year=fy,
|
||||
recurrence_type=recurrence,
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _ensure_current_engagement_for_plan(
|
||||
db,
|
||||
*,
|
||||
plan: ClientServicePlan,
|
||||
tenant_id: int,
|
||||
financial_year: str,
|
||||
actor_user_id: int,
|
||||
) -> tuple[ClientServiceSubscription, bool]:
|
||||
"""Create the current engagement for one plan, reusing existing production logic.
|
||||
|
||||
Returns (engagement, created). Existing engagements are never duplicated or
|
||||
overwritten. No task/review/evidence history is modified.
|
||||
"""
|
||||
selected_financial_year = normalize_financial_year(financial_year)
|
||||
recurrence_type = getattr(plan, "recurrence_type", None) or getattr(plan.catalogue, "recurrence_type", None)
|
||||
requested_period = _current_period_for_financial_year(selected_financial_year, recurrence_type)
|
||||
|
||||
existing = db.execute(
|
||||
select(ClientServiceSubscription).where(
|
||||
ClientServiceSubscription.tenant_id == tenant_id,
|
||||
ClientServiceSubscription.service_catalogue_id == plan.service_catalogue_id,
|
||||
ClientServiceSubscription.scope_key == plan.scope_key,
|
||||
ClientServiceSubscription.financial_year == selected_financial_year,
|
||||
ClientServiceSubscription.period_label == requested_period,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
if existing.service_plan_id is None:
|
||||
existing.service_plan_id = plan.id
|
||||
return existing, False
|
||||
|
||||
client = db.get(Client, plan.client_id)
|
||||
if not client or client.tenant_id != tenant_id:
|
||||
raise ValueError("client")
|
||||
|
||||
firm_selection = get_enabled_firm_service(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
service_catalogue_id=plan.service_catalogue_id,
|
||||
)
|
||||
if not firm_selection:
|
||||
raise ValueError("service")
|
||||
|
||||
engagement_type = getattr(firm_selection.catalogue, "engagement_type", None) or "non_assurance"
|
||||
review_required = review_partner_required_for_engagement(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
engagement_type=engagement_type,
|
||||
)
|
||||
review_partner_id = plan.default_review_partner_user_id if review_required else None
|
||||
performing_partner_id = plan.default_performing_partner_user_id or plan.default_partner_user_id
|
||||
|
||||
if review_required and review_partner_id is None:
|
||||
raise ValueError("review_partner_required")
|
||||
if review_required and review_partner_id in {plan.default_partner_user_id, performing_partner_id}:
|
||||
raise ValueError("review_partner_independence")
|
||||
|
||||
engagement = ClientServiceSubscription(
|
||||
tenant_id=tenant_id,
|
||||
branch_id=plan.branch_id or getattr(client, "branch_id", None),
|
||||
service_plan_id=plan.id,
|
||||
client_id=client.id,
|
||||
scope_type=plan.scope_type,
|
||||
scope_key=plan.scope_key,
|
||||
business_unit_id=plan.business_unit_id,
|
||||
client_branch_id=plan.client_branch_id,
|
||||
registration_id=plan.registration_id,
|
||||
service_catalogue_id=plan.service_catalogue_id,
|
||||
firm_service_selection_id=firm_selection.id,
|
||||
assigned_partner_user_id=plan.default_partner_user_id,
|
||||
performing_partner_user_id=performing_partner_id,
|
||||
assigned_manager_user_id=plan.default_manager_user_id,
|
||||
assigned_staff_user_id=plan.default_staff_user_id,
|
||||
review_partner_user_id=review_partner_id,
|
||||
financial_year=selected_financial_year,
|
||||
period_label=requested_period,
|
||||
assessment_year=assessment_year_from_financial_year(selected_financial_year),
|
||||
engagement_type=engagement_type,
|
||||
start_date=plan.effective_from,
|
||||
end_date=plan.effective_to,
|
||||
status="active",
|
||||
remarks=plan.remarks,
|
||||
is_active=True,
|
||||
created_by_user_id=actor_user_id,
|
||||
updated_by_user_id=actor_user_id,
|
||||
)
|
||||
db.add(engagement)
|
||||
db.flush()
|
||||
|
||||
apply_due_date_rule_to_subscription(db, engagement, force=True)
|
||||
ensure_engagement_quality_workflow(
|
||||
db,
|
||||
subscription=engagement,
|
||||
actor_user_id=actor_user_id,
|
||||
create_declarations=False,
|
||||
)
|
||||
enforce_quality_gate_on_subscription(engagement)
|
||||
generate_tasks_for_subscription_if_ready(
|
||||
db,
|
||||
subscription=engagement,
|
||||
user_id=actor_user_id,
|
||||
)
|
||||
return engagement, True
|
||||
|
||||
|
||||
@router.get("")
|
||||
def subscription_master_list(request: Request, q: str = "", include_inactive: bool = False):
|
||||
def subscription_master_list(
|
||||
request: Request,
|
||||
q: str = "",
|
||||
include_inactive: bool = False,
|
||||
repaired: int = 0,
|
||||
skipped: int = 0,
|
||||
repair_error: str = "",
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
@@ -295,6 +456,10 @@ def subscription_master_list(request: Request, q: str = "", include_inactive: bo
|
||||
q=q,
|
||||
include_inactive=include_inactive,
|
||||
can_edit_subscription="clients.edit" in set(get_user_permissions(db, user.id)),
|
||||
repaired=repaired,
|
||||
skipped=skipped,
|
||||
repair_error=repair_error,
|
||||
financial_year=_active_financial_year(request),
|
||||
),
|
||||
)
|
||||
finally:
|
||||
@@ -302,6 +467,85 @@ def subscription_master_list(request: Request, q: str = "", include_inactive: bo
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/generate-missing")
|
||||
def subscription_generate_missing_submit(
|
||||
request: Request,
|
||||
financial_year: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
validate_csrf(request, csrf_token)
|
||||
db = CommonSessionLocal()
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
if not user:
|
||||
return RedirectResponse("/login", 303)
|
||||
try:
|
||||
require_permission(db, user, "clients.edit")
|
||||
except Exception:
|
||||
return _denied()
|
||||
|
||||
tenant_id = _tenant_id(request, user)
|
||||
branch_id = _branch_id(request, user)
|
||||
selected_financial_year = normalize_financial_year(financial_year or _active_financial_year(request))
|
||||
locked = redirect_if_financial_year_locked(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
year_code=selected_financial_year,
|
||||
redirect_url="/services/subscriptions?repair_error=financial_year",
|
||||
)
|
||||
if locked:
|
||||
return locked
|
||||
|
||||
stmt = (
|
||||
select(ClientServicePlan)
|
||||
.options(
|
||||
selectinload(ClientServicePlan.client),
|
||||
selectinload(ClientServicePlan.catalogue),
|
||||
)
|
||||
.where(
|
||||
ClientServicePlan.tenant_id == tenant_id,
|
||||
ClientServicePlan.is_active.is_(True),
|
||||
ClientServicePlan.status == "active",
|
||||
)
|
||||
.order_by(ClientServicePlan.id)
|
||||
)
|
||||
if branch_id:
|
||||
stmt = stmt.where(ClientServicePlan.branch_id == branch_id)
|
||||
|
||||
plans = db.execute(stmt).scalars().all()
|
||||
for plan in plans:
|
||||
try:
|
||||
_engagement, created = _ensure_current_engagement_for_plan(
|
||||
db,
|
||||
plan=plan,
|
||||
tenant_id=tenant_id,
|
||||
financial_year=selected_financial_year,
|
||||
actor_user_id=user.id,
|
||||
)
|
||||
if created:
|
||||
created_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
except ValueError:
|
||||
# A plan with an invalid/missing service setup or unmet assurance
|
||||
# review-partner gate is left untouched; the rest continue safely.
|
||||
skipped_count += 1
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
f"/services/subscriptions?repaired={created_count}&skipped={skipped_count}",
|
||||
status_code=303,
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/bulk")
|
||||
def subscription_bulk_page(
|
||||
request: Request,
|
||||
@@ -1192,8 +1436,83 @@ def subscription_master_edit_submit(
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
@router.post("/{plan_id}/generate-current")
|
||||
def subscription_generate_current_submit(
|
||||
request: Request,
|
||||
plan_id: int,
|
||||
financial_year: 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("/login", 303)
|
||||
try:
|
||||
require_permission(db, user, "clients.edit")
|
||||
except Exception:
|
||||
return _denied()
|
||||
|
||||
tenant_id = _tenant_id(request, user)
|
||||
branch_id = _branch_id(request, user)
|
||||
plan = _load_plan(db, plan_id=plan_id, tenant_id=tenant_id, branch_id=branch_id)
|
||||
if not plan:
|
||||
return RedirectResponse("/services/subscriptions", 303)
|
||||
if not plan.is_active or plan.status != "active":
|
||||
return RedirectResponse(
|
||||
f"/services/subscriptions/{plan.id}?generation_error=inactive",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
selected_financial_year = normalize_financial_year(financial_year or _active_financial_year(request))
|
||||
locked = redirect_if_financial_year_locked(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
year_code=selected_financial_year,
|
||||
redirect_url=f"/services/subscriptions/{plan.id}?generation_error=financial_year",
|
||||
)
|
||||
if locked:
|
||||
return locked
|
||||
|
||||
try:
|
||||
_engagement, created = _ensure_current_engagement_for_plan(
|
||||
db,
|
||||
plan=plan,
|
||||
tenant_id=tenant_id,
|
||||
financial_year=selected_financial_year,
|
||||
actor_user_id=user.id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
db.rollback()
|
||||
code = str(exc) or "validation"
|
||||
return RedirectResponse(
|
||||
f"/services/subscriptions/{plan.id}?generation_error={code}",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
flag = "generated=1" if created else "existing=1"
|
||||
return RedirectResponse(
|
||||
f"/services/subscriptions/{plan.id}?{flag}",
|
||||
status_code=303,
|
||||
)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{plan_id}")
|
||||
def subscription_master_detail(request: Request, plan_id: int):
|
||||
def subscription_master_detail(
|
||||
request: Request,
|
||||
plan_id: int,
|
||||
generated: int = 0,
|
||||
existing: int = 0,
|
||||
generation_error: str = "",
|
||||
):
|
||||
db = CommonSessionLocal()
|
||||
try:
|
||||
user = get_current_user(request, db=db)
|
||||
@@ -1228,6 +1547,10 @@ def subscription_master_detail(request: Request, plan_id: int):
|
||||
plan=plan,
|
||||
engagements=engagements,
|
||||
can_edit_subscription="clients.edit" in permissions,
|
||||
generated=generated,
|
||||
existing=existing,
|
||||
generation_error=generation_error,
|
||||
financial_year=_active_financial_year(request),
|
||||
**_registration_scope_context(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
|
||||
@@ -33,6 +33,17 @@
|
||||
Map Scope
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_edit_subscription and plan.is_active and plan.status == 'active' %}
|
||||
<form method="post" action="/services/subscriptions/{{ plan.id }}/generate-current"
|
||||
onsubmit="return confirm('Generate the current engagement for FY {{ financial_year }}? Existing matching engagements will be reused.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="financial_year" value="{{ financial_year }}">
|
||||
<button type="submit"
|
||||
class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">
|
||||
Generate Current Engagement
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="/services/engagements/new?client_id={{ plan.client_id }}"
|
||||
class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||
Create Engagement
|
||||
@@ -41,6 +52,34 @@
|
||||
</div>
|
||||
|
||||
|
||||
{% if generated %}
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
Current FY engagement generated successfully using the subscription defaults. Due dates, quality workflow and task generation were processed through the existing engagement engine.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if existing %}
|
||||
<div class="rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800">
|
||||
A matching engagement already exists for the current FY/period, so no duplicate was created.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if generation_error %}
|
||||
<div class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">
|
||||
{% if generation_error == 'financial_year' %}
|
||||
Engagement generation is not allowed because FY {{ financial_year }} is locked.
|
||||
{% elif generation_error == 'review_partner_required' %}
|
||||
This assurance service requires a Review Partner before an engagement can be generated.
|
||||
{% elif generation_error == 'review_partner_independence' %}
|
||||
The Review Partner must differ from the Engagement Partner and Performing Partner.
|
||||
{% elif generation_error == 'inactive' %}
|
||||
This subscription is inactive and cannot generate an engagement.
|
||||
{% elif generation_error == 'service' %}
|
||||
The underlying firm service is not enabled, so the engagement was not generated.
|
||||
{% else %}
|
||||
The current engagement could not be generated. Please verify the subscription scope and defaults.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@@ -13,11 +13,39 @@
|
||||
Persistent client services shown by their assigned scope. Period-wise engagements are generated and tracked separately.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{% if can_edit_subscription %}
|
||||
<form method="post" action="/services/subscriptions/generate-missing"
|
||||
onsubmit="return confirm('Generate the current missing engagement for all active subscriptions in FY {{ financial_year }}? Existing engagements will not be duplicated.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="financial_year" value="{{ financial_year }}">
|
||||
<button type="submit"
|
||||
class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">
|
||||
Generate Missing Engagements
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="/services/engagements/new"
|
||||
class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700">
|
||||
Assign Service
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if repaired or skipped %}
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
Missing-engagement repair completed for FY {{ financial_year }}. {{ repaired }} engagement(s) created and {{ skipped }} subscription(s) skipped because an engagement already existed or the subscription was not ready for generation.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if repair_error %}
|
||||
<div class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800">
|
||||
{% if repair_error == 'financial_year' %}
|
||||
Engagement generation is not allowed because FY {{ financial_year }} is locked.
|
||||
{% else %}
|
||||
Missing engagements could not be generated.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="get" class="flex flex-wrap items-end gap-3 rounded-2xl bg-white p-4 shadow-soft">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user