diff --git a/app/modules/services/subscriptions_ui.py b/app/modules/services/subscriptions_ui.py index d198331..da1d47c 100644 --- a/app/modules/services/subscriptions_ui.py +++ b/app/modules/services/subscriptions_ui.py @@ -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, diff --git a/app/modules/services/templates/services/subscriptions/detail.html b/app/modules/services/templates/services/subscriptions/detail.html index e422735..09ca7ec 100644 --- a/app/modules/services/templates/services/subscriptions/detail.html +++ b/app/modules/services/templates/services/subscriptions/detail.html @@ -33,6 +33,17 @@ Map Scope {% endif %} + {% if can_edit_subscription and plan.is_active and plan.status == 'active' %} +
+ {% endif %} Create Engagement @@ -41,6 +52,34 @@ + {% if generated %} +