846 lines
34 KiB
Python
846 lines
34 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timezone
|
|
|
|
from fastapi import APIRouter, Form, Request
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.db.common import CommonSessionLocal
|
|
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
|
from app.core.security.session_auth import get_current_user
|
|
from app.core.templating import templates
|
|
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
|
|
from app.modules.services.client_services import (
|
|
SUBSCRIPTION_STATUSES,
|
|
assessment_year_from_financial_year,
|
|
attach_engagement_to_plan,
|
|
ensure_engagement_quality_workflow,
|
|
enforce_quality_gate_on_subscription,
|
|
get_enabled_firm_service,
|
|
get_existing_subscription,
|
|
get_or_create_client_service_plan,
|
|
list_assignable_users,
|
|
list_clients_for_assignment,
|
|
list_enabled_services_for_assignment,
|
|
list_review_partners,
|
|
normalize_financial_year,
|
|
normalize_period_label,
|
|
parse_date,
|
|
period_choices_for_service,
|
|
recurrence_requires_period,
|
|
review_partner_required_for_engagement,
|
|
)
|
|
from app.modules.clients.models import Client
|
|
from app.modules.services.due_dates import apply_due_date_rule_to_subscription
|
|
from app.modules.services.models import (
|
|
ClientServicePlan,
|
|
ClientServiceSubscription,
|
|
ClientServiceTaskInstance,
|
|
)
|
|
|
|
router = APIRouter(prefix="/services/subscriptions", tags=["client-service-subscriptions-ui"])
|
|
|
|
|
|
def _tenant_id(request, user):
|
|
return int(
|
|
request.session.get("active_tenant_id")
|
|
or request.session.get("selected_tenant_id")
|
|
or request.session.get("tenant_id")
|
|
or user.tenant_id
|
|
)
|
|
|
|
|
|
def _branch_id(request, user):
|
|
value = request.session.get("active_branch_id")
|
|
if value in (None, "", 0, "0"):
|
|
return int(getattr(user, "branch_id", 0) or 0) or None
|
|
return int(value)
|
|
|
|
|
|
|
|
def _active_financial_year(request: Request) -> str:
|
|
return normalize_financial_year(
|
|
request.session.get("active_financial_year")
|
|
or getattr(request.state, "year_code", None)
|
|
)
|
|
|
|
|
|
def _partner_scope_id(db, user) -> int | None:
|
|
roles = set(get_user_roles(db, user.id))
|
|
return int(user.id) if "Partner" in roles else None
|
|
|
|
def _ctx(request, db, user, **extra):
|
|
data = {
|
|
"request": request,
|
|
"current_user": user,
|
|
"current_user_roles": get_user_roles(db, user.id),
|
|
"current_user_permissions": get_user_permissions(db, user.id),
|
|
"csrf_token": get_or_create_csrf_token(request),
|
|
"subscription_statuses": SUBSCRIPTION_STATUSES,
|
|
}
|
|
data.update(extra)
|
|
return data
|
|
|
|
|
|
def _denied():
|
|
from app.core.http_responses import ui_access_denied
|
|
return ui_access_denied()
|
|
|
|
|
|
def _load_plan(db, *, plan_id: int, tenant_id: int, branch_id: int | None):
|
|
stmt = (
|
|
select(ClientServicePlan)
|
|
.options(
|
|
selectinload(ClientServicePlan.client),
|
|
selectinload(ClientServicePlan.catalogue),
|
|
selectinload(ClientServicePlan.default_partner),
|
|
selectinload(ClientServicePlan.default_performing_partner),
|
|
selectinload(ClientServicePlan.default_manager),
|
|
selectinload(ClientServicePlan.default_staff),
|
|
selectinload(ClientServicePlan.default_review_partner),
|
|
)
|
|
.where(
|
|
ClientServicePlan.id == plan_id,
|
|
ClientServicePlan.tenant_id == tenant_id,
|
|
)
|
|
)
|
|
if branch_id:
|
|
stmt = stmt.where(ClientServicePlan.branch_id == branch_id)
|
|
return db.execute(stmt).scalar_one_or_none()
|
|
|
|
|
|
def _optional_int(value: str) -> int | None:
|
|
value = (value or "").strip()
|
|
return int(value) if value else None
|
|
|
|
|
|
def _allowed_id(value: int | None, allowed: set[int], field_name: str) -> int | None:
|
|
if value is not None and value not in allowed:
|
|
raise ValueError(field_name)
|
|
return value
|
|
|
|
|
|
def _engagement_is_open(row: ClientServiceSubscription) -> bool:
|
|
return (
|
|
not bool(getattr(row, "is_locked", False))
|
|
and (getattr(row, "status", "") or "").lower() not in {"completed", "cancelled", "inactive"}
|
|
)
|
|
|
|
|
|
def _engagement_is_effective(row: ClientServiceSubscription, effective_from: date | None) -> bool:
|
|
if not effective_from:
|
|
return True
|
|
comparison_date = (
|
|
getattr(row, "start_date", None)
|
|
or getattr(row, "current_due_date", None)
|
|
or getattr(row, "original_due_date", None)
|
|
)
|
|
return comparison_date is None or comparison_date >= effective_from
|
|
|
|
|
|
@router.get("")
|
|
def subscription_master_list(request: Request, q: str = "", include_inactive: bool = False):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return RedirectResponse("/login", 303)
|
|
try:
|
|
require_permission(db, user, "clients.view")
|
|
except Exception:
|
|
return _denied()
|
|
|
|
tenant_id = _tenant_id(request, user)
|
|
branch_id = _branch_id(request, user)
|
|
counts = (
|
|
select(
|
|
ClientServiceSubscription.service_plan_id,
|
|
func.count(ClientServiceSubscription.id).label("engagement_count"),
|
|
func.max(ClientServiceSubscription.current_due_date).label("latest_due_date"),
|
|
)
|
|
.where(ClientServiceSubscription.service_plan_id.is_not(None))
|
|
.group_by(ClientServiceSubscription.service_plan_id)
|
|
.subquery()
|
|
)
|
|
stmt = (
|
|
select(ClientServicePlan, counts.c.engagement_count, counts.c.latest_due_date)
|
|
.options(
|
|
selectinload(ClientServicePlan.client),
|
|
selectinload(ClientServicePlan.catalogue),
|
|
selectinload(ClientServicePlan.default_partner),
|
|
selectinload(ClientServicePlan.default_performing_partner),
|
|
selectinload(ClientServicePlan.default_manager),
|
|
selectinload(ClientServicePlan.default_staff),
|
|
selectinload(ClientServicePlan.default_review_partner),
|
|
)
|
|
.outerjoin(counts, counts.c.service_plan_id == ClientServicePlan.id)
|
|
.where(ClientServicePlan.tenant_id == tenant_id)
|
|
)
|
|
if branch_id:
|
|
stmt = stmt.where(ClientServicePlan.branch_id == branch_id)
|
|
if not include_inactive:
|
|
stmt = stmt.where(ClientServicePlan.is_active.is_(True))
|
|
if q.strip():
|
|
from app.modules.clients.models import Client
|
|
from app.modules.services.models import ServiceCatalogue
|
|
term = f"%{q.strip()}%"
|
|
stmt = (
|
|
stmt.join(Client, Client.id == ClientServicePlan.client_id)
|
|
.join(ServiceCatalogue, ServiceCatalogue.id == ClientServicePlan.service_catalogue_id)
|
|
.where(
|
|
(Client.client_name.ilike(term))
|
|
| (Client.client_code.ilike(term))
|
|
| (ServiceCatalogue.service_name.ilike(term))
|
|
| (ServiceCatalogue.service_code.ilike(term))
|
|
)
|
|
)
|
|
rows = db.execute(
|
|
stmt.order_by(ClientServicePlan.is_active.desc(), ClientServicePlan.id.desc())
|
|
).all()
|
|
return templates.TemplateResponse(
|
|
"modules/services/templates/services/subscriptions/list.html",
|
|
_ctx(
|
|
request,
|
|
db,
|
|
user,
|
|
title="Client Service Subscriptions",
|
|
rows=rows,
|
|
q=q,
|
|
include_inactive=include_inactive,
|
|
can_edit_subscription="clients.edit" in set(get_user_permissions(db, user.id)),
|
|
),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
|
|
@router.get("/bulk")
|
|
def subscription_bulk_page(
|
|
request: Request,
|
|
error: str = "",
|
|
subscriptions_created: int = 0,
|
|
subscriptions_reused: int = 0,
|
|
engagements_created: int = 0,
|
|
engagements_skipped: int = 0,
|
|
):
|
|
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)
|
|
clients = list_clients_for_assignment(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
branch_id=branch_id,
|
|
partner_id=_partner_scope_id(db, user),
|
|
)
|
|
enabled_services = list_enabled_services_for_assignment(db, tenant_id=tenant_id)
|
|
partners = list_assignable_users(
|
|
db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Partner",)
|
|
)
|
|
managers = list_assignable_users(
|
|
db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Branch Manager",)
|
|
)
|
|
staff_users = list_assignable_users(
|
|
db, tenant_id=tenant_id, branch_id=branch_id, role_names=("Staff",)
|
|
)
|
|
review_partners = list_review_partners(
|
|
db, tenant_id=tenant_id, branch_id=branch_id
|
|
)
|
|
error_messages = {
|
|
"clients": "Select at least one permitted client.",
|
|
"service": "Select a valid enabled firm service.",
|
|
"partner": "Select a valid Engagement Partner.",
|
|
"performing_partner": "Select a valid Performing Partner.",
|
|
"manager": "Select a valid Manager.",
|
|
"staff": "Select a valid Staff member.",
|
|
"review_partner": "Select a valid Review Partner.",
|
|
"review_partner_required": "Review Partner is mandatory for this assurance service.",
|
|
"review_partner_independence": "Review Partner must differ from the Engagement and Performing Partners.",
|
|
"period": "Select a valid month or quarter.",
|
|
"generation": "Select a valid engagement generation option.",
|
|
"financial_year": "The selected financial year is locked.",
|
|
}
|
|
return templates.TemplateResponse(
|
|
"modules/services/templates/services/subscriptions/bulk.html",
|
|
_ctx(
|
|
request,
|
|
db,
|
|
user,
|
|
title="Bulk Client Subscriptions",
|
|
clients=clients,
|
|
enabled_services=enabled_services,
|
|
partners=partners,
|
|
managers=managers,
|
|
staff_users=staff_users,
|
|
review_partners=review_partners,
|
|
client_partner_names={
|
|
row.id: (row.full_name or row.email)
|
|
for row in partners
|
|
},
|
|
financial_year=_active_financial_year(request),
|
|
error_message=error_messages.get(error, ""),
|
|
subscriptions_created=subscriptions_created,
|
|
subscriptions_reused=subscriptions_reused,
|
|
engagements_created=engagements_created,
|
|
engagements_skipped=engagements_skipped,
|
|
),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/bulk")
|
|
def subscription_bulk_submit(
|
|
request: Request,
|
|
client_ids: list[int] = Form([]),
|
|
service_catalogue_id: int = Form(...),
|
|
default_partner_user_id: int = Form(...),
|
|
default_performing_partner_user_id: str = Form(""),
|
|
default_manager_user_id: str = Form(""),
|
|
default_staff_user_id: str = Form(""),
|
|
default_review_partner_user_id: str = Form(""),
|
|
effective_from: str = Form(""),
|
|
effective_to: str = Form(""),
|
|
auto_generate_periods: str | None = Form(None),
|
|
remarks: str = Form(""),
|
|
generation_mode: str = Form("subscription_only"),
|
|
financial_year: str = Form(""),
|
|
period_label: str = Form(""),
|
|
csrf_token: str = Form(...),
|
|
):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
subscriptions_created = 0
|
|
subscriptions_reused = 0
|
|
engagements_created = 0
|
|
engagements_skipped = 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)
|
|
permitted_clients = list_clients_for_assignment(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
branch_id=branch_id,
|
|
partner_id=_partner_scope_id(db, user),
|
|
)
|
|
permitted_client_map = {row.id: row for row in permitted_clients}
|
|
selected_client_ids = list(dict.fromkeys(int(value) for value in client_ids))
|
|
if not selected_client_ids or any(value not in permitted_client_map for value in selected_client_ids):
|
|
return RedirectResponse("/services/subscriptions/bulk?error=clients", 303)
|
|
|
|
firm_selection = get_enabled_firm_service(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
service_catalogue_id=service_catalogue_id,
|
|
)
|
|
if not firm_selection:
|
|
return RedirectResponse("/services/subscriptions/bulk?error=service", 303)
|
|
|
|
plan_branch_id = (
|
|
branch_id
|
|
or getattr(firm_selection, "default_branch_id", None)
|
|
or getattr(user, "branch_id", None)
|
|
)
|
|
partners = list_assignable_users(
|
|
db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Partner",)
|
|
)
|
|
managers = list_assignable_users(
|
|
db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Branch Manager",)
|
|
)
|
|
staff_users = list_assignable_users(
|
|
db, tenant_id=tenant_id, branch_id=plan_branch_id, role_names=("Staff",)
|
|
)
|
|
review_partners = list_review_partners(
|
|
db, tenant_id=tenant_id, branch_id=plan_branch_id
|
|
)
|
|
|
|
partner_ids = {row.id for row in partners}
|
|
manager_ids = {row.id for row in managers}
|
|
staff_ids = {row.id for row in staff_users}
|
|
review_partner_ids = {row.id for row in review_partners}
|
|
|
|
if default_partner_user_id not in partner_ids:
|
|
return RedirectResponse("/services/subscriptions/bulk?error=partner", 303)
|
|
performing_partner_id = _optional_int(default_performing_partner_user_id) or default_partner_user_id
|
|
if performing_partner_id not in partner_ids:
|
|
return RedirectResponse("/services/subscriptions/bulk?error=performing_partner", 303)
|
|
manager_id = _optional_int(default_manager_user_id)
|
|
if manager_id is not None and manager_id not in manager_ids:
|
|
return RedirectResponse("/services/subscriptions/bulk?error=manager", 303)
|
|
staff_id = _optional_int(default_staff_user_id)
|
|
if staff_id is not None and staff_id not in staff_ids:
|
|
return RedirectResponse("/services/subscriptions/bulk?error=staff", 303)
|
|
review_partner_id = _optional_int(default_review_partner_user_id)
|
|
if review_partner_id is not None and review_partner_id not in review_partner_ids:
|
|
return RedirectResponse("/services/subscriptions/bulk?error=review_partner", 303)
|
|
|
|
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
|
|
)
|
|
if review_required and review_partner_id is None:
|
|
return RedirectResponse("/services/subscriptions/bulk?error=review_partner_required", 303)
|
|
if review_required and review_partner_id in {default_partner_user_id, performing_partner_id}:
|
|
return RedirectResponse("/services/subscriptions/bulk?error=review_partner_independence", 303)
|
|
|
|
if generation_mode not in {"subscription_only", "current_period", "all_periods"}:
|
|
return RedirectResponse("/services/subscriptions/bulk?error=generation", 303)
|
|
|
|
selected_financial_year = normalize_financial_year(
|
|
financial_year or _active_financial_year(request)
|
|
)
|
|
if generation_mode != "subscription_only":
|
|
locked_response = redirect_if_financial_year_locked(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
year_code=selected_financial_year,
|
|
redirect_url="/services/subscriptions/bulk?error=financial_year",
|
|
)
|
|
if locked_response:
|
|
return locked_response
|
|
|
|
recurrence_type = getattr(firm_selection.catalogue, "recurrence_type", None)
|
|
if generation_mode == "subscription_only":
|
|
requested_periods: list[str] = []
|
|
elif recurrence_requires_period(recurrence_type):
|
|
if generation_mode == "all_periods":
|
|
requested_periods = [
|
|
code for code, _label in period_choices_for_service(
|
|
selected_financial_year, recurrence_type
|
|
)
|
|
]
|
|
else:
|
|
try:
|
|
requested_periods = [
|
|
normalize_period_label(
|
|
period_label,
|
|
financial_year=selected_financial_year,
|
|
recurrence_type=recurrence_type,
|
|
)
|
|
]
|
|
except ValueError:
|
|
return RedirectResponse("/services/subscriptions/bulk?error=period", 303)
|
|
else:
|
|
requested_periods = [""]
|
|
|
|
plan_effective_from = parse_date(effective_from)
|
|
plan_effective_to = parse_date(effective_to)
|
|
if plan_effective_from and plan_effective_to and plan_effective_to < plan_effective_from:
|
|
return RedirectResponse("/services/subscriptions/bulk?error=period", 303)
|
|
|
|
for client_id in selected_client_ids:
|
|
client = permitted_client_map[client_id]
|
|
existing_plan = db.execute(
|
|
select(ClientServicePlan).where(
|
|
ClientServicePlan.tenant_id == tenant_id,
|
|
ClientServicePlan.client_id == client.id,
|
|
ClientServicePlan.service_catalogue_id == service_catalogue_id,
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
plan = get_or_create_client_service_plan(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
client=client,
|
|
catalogue=firm_selection.catalogue,
|
|
firm_selection=firm_selection,
|
|
branch_id=plan_branch_id or getattr(client, "branch_id", None),
|
|
partner_user_id=default_partner_user_id,
|
|
performing_partner_user_id=performing_partner_id,
|
|
manager_user_id=manager_id,
|
|
staff_user_id=staff_id,
|
|
review_partner_user_id=review_partner_id,
|
|
actor_user_id=user.id,
|
|
remarks=remarks.strip() or None,
|
|
)
|
|
plan.effective_from = plan_effective_from
|
|
plan.effective_to = plan_effective_to
|
|
plan.auto_generate_periods = auto_generate_periods is not None
|
|
plan.status = "active"
|
|
plan.is_active = True
|
|
plan.updated_by_user_id = user.id
|
|
if remarks.strip():
|
|
plan.remarks = remarks.strip()
|
|
|
|
if existing_plan is None:
|
|
subscriptions_created += 1
|
|
else:
|
|
subscriptions_reused += 1
|
|
|
|
for requested_period in requested_periods:
|
|
existing = get_existing_subscription(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
client_id=client.id,
|
|
service_catalogue_id=service_catalogue_id,
|
|
financial_year=selected_financial_year,
|
|
period_label=requested_period,
|
|
)
|
|
if existing:
|
|
if existing.service_plan_id is None:
|
|
existing.service_plan_id = plan.id
|
|
engagements_skipped += 1
|
|
continue
|
|
|
|
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,
|
|
service_catalogue_id=service_catalogue_id,
|
|
firm_service_selection_id=firm_selection.id,
|
|
assigned_partner_user_id=default_partner_user_id,
|
|
performing_partner_user_id=performing_partner_id,
|
|
assigned_manager_user_id=manager_id,
|
|
assigned_staff_user_id=staff_id,
|
|
review_partner_user_id=review_partner_id if review_required else None,
|
|
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=remarks.strip() or None,
|
|
is_active=True,
|
|
created_by_user_id=user.id,
|
|
updated_by_user_id=user.id,
|
|
)
|
|
db.add(engagement)
|
|
db.flush()
|
|
attach_engagement_to_plan(
|
|
db,
|
|
engagement=engagement,
|
|
client=client,
|
|
catalogue=firm_selection.catalogue,
|
|
firm_selection=firm_selection,
|
|
actor_user_id=user.id,
|
|
)
|
|
apply_due_date_rule_to_subscription(db, engagement, force=True)
|
|
ensure_engagement_quality_workflow(
|
|
db,
|
|
subscription=engagement,
|
|
actor_user_id=user.id,
|
|
create_declarations=False,
|
|
)
|
|
enforce_quality_gate_on_subscription(engagement)
|
|
engagements_created += 1
|
|
|
|
db.commit()
|
|
return RedirectResponse(
|
|
"/services/subscriptions/bulk"
|
|
f"?subscriptions_created={subscriptions_created}"
|
|
f"&subscriptions_reused={subscriptions_reused}"
|
|
f"&engagements_created={engagements_created}"
|
|
f"&engagements_skipped={engagements_skipped}",
|
|
status_code=303,
|
|
)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
finally:
|
|
db.close()
|
|
|
|
@router.get("/{plan_id}/edit")
|
|
def subscription_master_edit_page(
|
|
request: Request,
|
|
plan_id: int,
|
|
saved: int = 0,
|
|
updated_engagements: int = 0,
|
|
updated_tasks: int = 0,
|
|
error: str = "",
|
|
):
|
|
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)
|
|
|
|
partners = list_assignable_users(
|
|
db, tenant_id=tenant_id, branch_id=plan.branch_id, role_names=("Partner",)
|
|
)
|
|
managers = list_assignable_users(
|
|
db, tenant_id=tenant_id, branch_id=plan.branch_id, role_names=("Branch Manager",)
|
|
)
|
|
staff_users = list_assignable_users(
|
|
db, tenant_id=tenant_id, branch_id=plan.branch_id, role_names=("Staff",)
|
|
)
|
|
review_partners = list_review_partners(
|
|
db, tenant_id=tenant_id, branch_id=plan.branch_id
|
|
)
|
|
return templates.TemplateResponse(
|
|
"modules/services/templates/services/subscriptions/edit.html",
|
|
_ctx(
|
|
request,
|
|
db,
|
|
user,
|
|
title="Edit Client Service Subscription",
|
|
plan=plan,
|
|
partners=partners,
|
|
managers=managers,
|
|
staff_users=staff_users,
|
|
review_partners=review_partners,
|
|
saved=saved,
|
|
updated_engagements=updated_engagements,
|
|
updated_tasks=updated_tasks,
|
|
error=error,
|
|
),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/{plan_id}/edit")
|
|
def subscription_master_edit_submit(
|
|
request: Request,
|
|
plan_id: int,
|
|
default_partner_user_id: str = Form(""),
|
|
default_performing_partner_user_id: str = Form(""),
|
|
default_manager_user_id: str = Form(""),
|
|
default_staff_user_id: str = Form(""),
|
|
default_review_partner_user_id: str = Form(""),
|
|
effective_from: str = Form(""),
|
|
effective_to: str = Form(""),
|
|
status: str = Form("active"),
|
|
auto_generate_periods: str | None = Form(None),
|
|
remarks: str = Form(""),
|
|
propagation_mode: str = Form("future_only"),
|
|
reassignment_effective_from: str = Form(""),
|
|
reassignment_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("/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)
|
|
|
|
partners = list_assignable_users(
|
|
db, tenant_id=tenant_id, branch_id=plan.branch_id, role_names=("Partner",)
|
|
)
|
|
managers = list_assignable_users(
|
|
db, tenant_id=tenant_id, branch_id=plan.branch_id, role_names=("Branch Manager",)
|
|
)
|
|
staff_users = list_assignable_users(
|
|
db, tenant_id=tenant_id, branch_id=plan.branch_id, role_names=("Staff",)
|
|
)
|
|
review_partners = list_review_partners(
|
|
db, tenant_id=tenant_id, branch_id=plan.branch_id
|
|
)
|
|
|
|
try:
|
|
new_partner_id = _allowed_id(
|
|
_optional_int(default_partner_user_id), {row.id for row in partners}, "partner"
|
|
)
|
|
requested_performing_id = _allowed_id(
|
|
_optional_int(default_performing_partner_user_id),
|
|
{row.id for row in partners},
|
|
"performing_partner",
|
|
)
|
|
new_performing_id = requested_performing_id or new_partner_id
|
|
new_manager_id = _allowed_id(
|
|
_optional_int(default_manager_user_id), {row.id for row in managers}, "manager"
|
|
)
|
|
new_staff_id = _allowed_id(
|
|
_optional_int(default_staff_user_id), {row.id for row in staff_users}, "staff"
|
|
)
|
|
new_review_partner_id = _allowed_id(
|
|
_optional_int(default_review_partner_user_id),
|
|
{row.id for row in review_partners},
|
|
"review_partner",
|
|
)
|
|
plan_effective_from = parse_date(effective_from)
|
|
plan_effective_to = parse_date(effective_to)
|
|
change_effective_from = parse_date(reassignment_effective_from)
|
|
except (TypeError, ValueError) as exc:
|
|
code = str(exc) if str(exc) else "validation"
|
|
db.rollback()
|
|
return RedirectResponse(
|
|
f"/services/subscriptions/{plan.id}/edit?error={code}", status_code=303
|
|
)
|
|
|
|
allowed_statuses = {item[0] for item in SUBSCRIPTION_STATUSES}
|
|
normalized_status = (status or "active").strip().lower()
|
|
if normalized_status not in allowed_statuses:
|
|
return RedirectResponse(
|
|
f"/services/subscriptions/{plan.id}/edit?error=status", status_code=303
|
|
)
|
|
if plan_effective_from and plan_effective_to and plan_effective_to < plan_effective_from:
|
|
return RedirectResponse(
|
|
f"/services/subscriptions/{plan.id}/edit?error=dates", status_code=303
|
|
)
|
|
if propagation_mode not in {"future_only", "open_engagements"}:
|
|
return RedirectResponse(
|
|
f"/services/subscriptions/{plan.id}/edit?error=propagation", status_code=303
|
|
)
|
|
|
|
old_staff_id = plan.default_staff_user_id
|
|
plan.default_partner_user_id = new_partner_id
|
|
plan.default_performing_partner_user_id = new_performing_id
|
|
plan.default_manager_user_id = new_manager_id
|
|
plan.default_staff_user_id = new_staff_id
|
|
plan.default_review_partner_user_id = new_review_partner_id
|
|
plan.effective_from = plan_effective_from
|
|
plan.effective_to = plan_effective_to
|
|
plan.auto_generate_periods = auto_generate_periods is not None
|
|
plan.status = normalized_status
|
|
plan.is_active = normalized_status not in {"inactive", "cancelled"}
|
|
plan.remarks = remarks.strip() or None
|
|
plan.updated_by_user_id = user.id
|
|
plan.updated_at_utc = datetime.now(timezone.utc)
|
|
|
|
updated_engagement_count = 0
|
|
updated_task_count = 0
|
|
if propagation_mode == "open_engagements":
|
|
engagements = db.execute(
|
|
select(ClientServiceSubscription).where(
|
|
ClientServiceSubscription.service_plan_id == plan.id,
|
|
ClientServiceSubscription.tenant_id == tenant_id,
|
|
)
|
|
).scalars().all()
|
|
|
|
affected_ids: list[int] = []
|
|
for row in engagements:
|
|
if not _engagement_is_open(row):
|
|
continue
|
|
if not _engagement_is_effective(row, change_effective_from):
|
|
continue
|
|
row.assigned_partner_user_id = new_partner_id
|
|
row.performing_partner_user_id = new_performing_id
|
|
row.assigned_manager_user_id = new_manager_id
|
|
row.assigned_staff_user_id = new_staff_id
|
|
row.review_partner_user_id = new_review_partner_id
|
|
row.updated_by_user_id = user.id
|
|
row.updated_at_utc = datetime.now(timezone.utc)
|
|
if reassignment_reason.strip():
|
|
note = (
|
|
f"Subscription team updated on {datetime.now(timezone.utc).date().isoformat()} "
|
|
f"by user {user.id}: {reassignment_reason.strip()}"
|
|
)
|
|
row.remarks = f"{row.remarks.strip()}\n{note}" if row.remarks else note
|
|
affected_ids.append(row.id)
|
|
updated_engagement_count += 1
|
|
|
|
if affected_ids and old_staff_id != new_staff_id:
|
|
task_stmt = select(ClientServiceTaskInstance).where(
|
|
ClientServiceTaskInstance.subscription_id.in_(affected_ids),
|
|
ClientServiceTaskInstance.is_active.is_(True),
|
|
ClientServiceTaskInstance.is_locked.is_(False),
|
|
ClientServiceTaskInstance.status.notin_(("completed", "cancelled")),
|
|
)
|
|
if old_staff_id is None:
|
|
task_stmt = task_stmt.where(
|
|
ClientServiceTaskInstance.assigned_to_user_id.is_(None),
|
|
func.lower(func.coalesce(ClientServiceTaskInstance.default_role_name, "")) == "staff",
|
|
)
|
|
else:
|
|
task_stmt = task_stmt.where(
|
|
ClientServiceTaskInstance.assigned_to_user_id == old_staff_id
|
|
)
|
|
tasks = db.execute(task_stmt).scalars().all()
|
|
for task in tasks:
|
|
task.assigned_to_user_id = new_staff_id
|
|
task.updated_by_user_id = user.id
|
|
task.updated_at_utc = datetime.now(timezone.utc)
|
|
updated_task_count += 1
|
|
|
|
db.commit()
|
|
return RedirectResponse(
|
|
f"/services/subscriptions/{plan.id}/edit?saved=1"
|
|
f"&updated_engagements={updated_engagement_count}"
|
|
f"&updated_tasks={updated_task_count}",
|
|
status_code=303,
|
|
)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/{plan_id}")
|
|
def subscription_master_detail(request: Request, plan_id: int):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return RedirectResponse("/login", 303)
|
|
try:
|
|
require_permission(db, user, "clients.view")
|
|
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)
|
|
engagements = db.execute(
|
|
select(ClientServiceSubscription)
|
|
.where(ClientServiceSubscription.service_plan_id == plan.id)
|
|
.order_by(
|
|
ClientServiceSubscription.financial_year.desc(),
|
|
ClientServiceSubscription.period_label.asc(),
|
|
ClientServiceSubscription.id.desc(),
|
|
)
|
|
).scalars().all()
|
|
permissions = set(get_user_permissions(db, user.id))
|
|
return templates.TemplateResponse(
|
|
"modules/services/templates/services/subscriptions/detail.html",
|
|
_ctx(
|
|
request,
|
|
db,
|
|
user,
|
|
title="Client Service Subscription",
|
|
plan=plan,
|
|
engagements=engagements,
|
|
can_edit_subscription="clients.edit" in permissions,
|
|
),
|
|
)
|
|
finally:
|
|
db.close()
|