Separate client service subscriptions from engagement instances

This commit is contained in:
A R R R Associates
2026-08-05 13:42:11 +05:30
parent 3bba0c1e32
commit 0e9eb2bef2
11 changed files with 273 additions and 2 deletions
+5
View File
@@ -25,6 +25,7 @@ from app.modules.services.models import (
from app.modules.services.services import normalize_code, normalize_engagement_type
from app.modules.services.client_services import (
assessment_year_from_financial_year,
attach_engagement_to_plan,
normalize_financial_year,
normalize_period_label,
review_partner_required_for_engagement,
@@ -842,6 +843,10 @@ def import_client_service_assignments(
sub.is_active = is_active
sub.remarks = _clean(_cell(row, headers, "remarks")) or None
sub.updated_by_user_id = current_user.id
attach_engagement_to_plan(
db, engagement=sub, client=client, catalogue=catalogue,
firm_selection=firm_selection, actor_user_id=current_user.id,
)
apply_due_date_rule_to_subscription(db, sub, force=True)
ensure_engagement_quality_workflow(db, subscription=sub, actor_user_id=current_user.id, create_declarations=False)
enforce_quality_gate_on_subscription(sub)
+78 -1
View File
@@ -9,7 +9,7 @@ from app.modules.clients.models import Client
from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Tenant
from app.modules.services.models import ClientServiceSubscription, FirmServiceSelection, ServiceCatalogue
from app.modules.services.models import ClientServicePlan, ClientServiceSubscription, FirmServiceSelection, ServiceCatalogue
SUBSCRIPTION_STATUSES = [
("draft", "Draft"),
@@ -640,3 +640,80 @@ def enforce_quality_gate_on_subscription(subscription: ClientServiceSubscription
subscription.is_active = False
if not subscription.quality_block_reason:
subscription.quality_block_reason = "AQMM acceptance workflow pending for assurance engagement."
def get_or_create_client_service_plan(
db: Session,
*,
tenant_id: int,
client: Client,
catalogue: ServiceCatalogue,
firm_selection: FirmServiceSelection | None,
branch_id: int | None,
partner_user_id: int | None,
performing_partner_user_id: int | None,
manager_user_id: int | None,
staff_user_id: int | None,
review_partner_user_id: int | None,
actor_user_id: int | None,
remarks: str | None = None,
) -> ClientServicePlan:
plan = db.execute(
select(ClientServicePlan).where(
ClientServicePlan.tenant_id == tenant_id,
ClientServicePlan.client_id == client.id,
ClientServicePlan.service_catalogue_id == catalogue.id,
)
).scalar_one_or_none()
if plan is None:
plan = ClientServicePlan(
tenant_id=tenant_id,
client_id=client.id,
service_catalogue_id=catalogue.id,
created_by_user_id=actor_user_id,
)
db.add(plan)
plan.branch_id = branch_id or getattr(client, "branch_id", None)
plan.firm_service_selection_id = getattr(firm_selection, "id", None)
plan.default_partner_user_id = partner_user_id or getattr(client, "partner_id", None)
plan.default_performing_partner_user_id = performing_partner_user_id or getattr(client, "default_performing_partner_user_id", None) or plan.default_partner_user_id
plan.default_manager_user_id = manager_user_id
plan.default_staff_user_id = staff_user_id
plan.default_review_partner_user_id = review_partner_user_id or getattr(client, "default_review_partner_user_id", None)
plan.recurrence_type = normalized_recurrence_type(getattr(catalogue, "recurrence_type", None)) or "one_time"
plan.auto_generate_periods = recurrence_requires_period(plan.recurrence_type)
plan.status = "active"
plan.is_active = True
if remarks and not plan.remarks:
plan.remarks = remarks
plan.updated_by_user_id = actor_user_id
db.flush()
return plan
def attach_engagement_to_plan(
db: Session,
*,
engagement: ClientServiceSubscription,
client: Client,
catalogue: ServiceCatalogue,
firm_selection: FirmServiceSelection | None,
actor_user_id: int | None,
) -> ClientServicePlan:
plan = get_or_create_client_service_plan(
db,
tenant_id=engagement.tenant_id,
client=client,
catalogue=catalogue,
firm_selection=firm_selection,
branch_id=engagement.branch_id,
partner_user_id=engagement.assigned_partner_user_id,
performing_partner_user_id=getattr(engagement, "performing_partner_user_id", None),
manager_user_id=engagement.assigned_manager_user_id,
staff_user_id=engagement.assigned_staff_user_id,
review_partner_user_id=engagement.review_partner_user_id,
actor_user_id=actor_user_id,
remarks=engagement.remarks,
)
engagement.service_plan_id = plan.id
return plan
@@ -10,6 +10,7 @@ from app.core.templating import templates
from app.modules.services.models import ClientServiceSubscription
from app.modules.services.client_services import (
SUBSCRIPTION_STATUSES,
attach_engagement_to_plan,
get_enabled_firm_service,
get_existing_subscription,
get_subscription,
@@ -216,6 +217,12 @@ def subscription_create_submit(
row.remarks = remarks.strip() or None
row.is_active = is_active is not None
row.updated_by_user_id = user.id
client = db.get(__import__("app.modules.clients.models", fromlist=["Client"]).Client, client_id)
if client is not None:
attach_engagement_to_plan(
db, engagement=row, client=client, catalogue=firm_selection.catalogue,
firm_selection=firm_selection, actor_user_id=user.id,
)
db.commit()
db.refresh(row)
+9
View File
@@ -22,6 +22,7 @@ from app.modules.core.iam.models import User
from app.modules.services.client_services import (
SUBSCRIPTION_STATUSES,
assessment_year_from_financial_year,
attach_engagement_to_plan,
current_financial_year,
get_enabled_firm_service,
get_existing_subscription,
@@ -366,6 +367,10 @@ def subscription_create_submit(
row.remarks = remarks.strip() or None
row.is_active = is_active is not None
row.updated_by_user_id = user.id
attach_engagement_to_plan(
db, engagement=row, client=client, catalogue=firm_selection.catalogue,
firm_selection=firm_selection, actor_user_id=user.id,
)
apply_due_date_rule_to_subscription(db, row, force=True)
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(row)
@@ -619,6 +624,10 @@ def subscription_bulk_create_submit(
)
db.add(row)
db.flush()
attach_engagement_to_plan(
db, engagement=row, client=client, catalogue=firm_selection.catalogue,
firm_selection=firm_selection, actor_user_id=user.id,
)
apply_due_date_rule_to_subscription(db, row, force=True)
ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(row)
+50
View File
@@ -371,6 +371,54 @@ class ServiceDueDateExtension(CommonBase):
due_rule = relationship("ServiceDueDateRule")
class ClientServicePlan(CommonBase):
"""Persistent client-level service subscription from which period engagements are created."""
__tablename__ = "client_service_plans"
__table_args__ = (
UniqueConstraint(
"tenant_id", "client_id", "service_catalogue_id",
name="uq_csp_tenant_client_service",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True)
firm_service_selection_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_selections.id", ondelete="SET NULL"), nullable=True, index=True)
default_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
default_performing_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
default_manager_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
default_staff_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
default_review_partner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
recurrence_type: Mapped[str] = mapped_column(String(30), nullable=False, default="one_time", server_default="one_time", index=True)
effective_from: Mapped[date | None] = mapped_column(Date, nullable=True)
effective_to: Mapped[date | None] = mapped_column(Date, nullable=True)
auto_generate_periods: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", server_default="active", index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true", index=True)
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
client = relationship("Client")
catalogue = relationship("ServiceCatalogue")
firm_selection = relationship("FirmServiceSelection")
default_partner = relationship("User", foreign_keys=[default_partner_user_id])
default_performing_partner = relationship("User", foreign_keys=[default_performing_partner_user_id])
default_manager = relationship("User", foreign_keys=[default_manager_user_id])
default_staff = relationship("User", foreign_keys=[default_staff_user_id])
default_review_partner = relationship("User", foreign_keys=[default_review_partner_user_id])
engagements = relationship("ClientServiceSubscription", back_populates="service_plan")
class ClientServiceSubscription(CommonBase):
"""Firm-level subscription of an enabled service to a specific client."""
@@ -390,6 +438,7 @@ class ClientServiceSubscription(CommonBase):
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id"), nullable=True, index=True)
service_plan_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_plans.id", ondelete="SET NULL"), nullable=True, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
service_catalogue_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="CASCADE"), nullable=False, index=True)
firm_service_selection_id: Mapped[int | None] = mapped_column(ForeignKey("firm_service_selections.id", ondelete="SET NULL"), nullable=True, index=True)
@@ -447,6 +496,7 @@ class ClientServiceSubscription(CommonBase):
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
service_plan = relationship("ClientServicePlan", back_populates="engagements")
client = relationship("Client")
catalogue = relationship("ServiceCatalogue")
due_date_rule = relationship("ServiceDueDateRule", foreign_keys=[due_date_rule_id])
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token
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.services.models import ClientServicePlan, ClientServiceSubscription
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 _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)}
data.update(extra); return data
@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:
from app.core.http_responses import ui_access_denied
return ui_access_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))
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:
from app.core.http_responses import ui_access_denied
return ui_access_denied()
tenant_id=_tenant_id(request,user)
plan=db.execute(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)).scalar_one_or_none()
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()
return templates.TemplateResponse("modules/services/templates/services/subscriptions/detail.html",_ctx(request,db,user,title="Client Service Subscription",plan=plan,engagements=engagements))
finally: db.close()
@@ -0,0 +1,4 @@
{% extends "base/layout.html" %}
{% block content %}<div class="page-shell"><div class="page-header"><div><h1>{{ plan.client.client_name }} — {{ plan.catalogue.service_name }}</h1><p>Client service subscription master and its period-wise engagements.</p></div><div><a class="btn" href="/services/subscriptions">Back</a> <a class="btn btn-primary" href="/services/engagements/new?client_id={{ plan.client_id }}">Create Engagement</a></div></div>
<div class="card"><h2>Subscription defaults</h2><div class="detail-grid"><div><b>Recurrence</b><br>{{ plan.recurrence_type|replace('_',' ')|title }}</div><div><b>Status</b><br>{{ plan.status|title }}</div><div><b>Engagement Partner</b><br>{{ plan.default_partner.full_name if plan.default_partner else '-' }}</div><div><b>Performing Partner</b><br>{{ plan.default_performing_partner.full_name if plan.default_performing_partner else '-' }}</div><div><b>Manager</b><br>{{ plan.default_manager.full_name if plan.default_manager else '-' }}</div><div><b>Staff</b><br>{{ plan.default_staff.full_name if plan.default_staff else '-' }}</div><div><b>Review Partner</b><br>{{ plan.default_review_partner.full_name if plan.default_review_partner else '-' }}</div><div><b>Auto-generate periods</b><br>{{ 'Yes' if plan.auto_generate_periods else 'No' }}</div></div></div>
<div class="card table-wrap"><h2>Engagement instances</h2><table><thead><tr><th>FY</th><th>Period</th><th>Due date</th><th>Status</th><th></th></tr></thead><tbody>{% for row in engagements %}<tr><td>{{ row.financial_year }}</td><td>{{ row.period_label or '-' }}</td><td>{{ row.current_due_date or '-' }}</td><td>{{ row.status|title }}</td><td><a href="/services/engagements/{{ row.id }}">View</a></td></tr>{% else %}<tr><td colspan="5">No engagement instances generated.</td></tr>{% endfor %}</tbody></table></div></div>{% endblock %}
@@ -0,0 +1,6 @@
{% extends "base/layout.html" %}
{% block content %}
<div class="page-shell"><div class="page-header"><div><h1>Client Service Subscriptions</h1><p>Persistent client-level services. Period-wise engagements are generated and tracked separately.</p></div><a class="btn btn-primary" href="/services/engagements/new">Assign Service</a></div>
<div class="card"><form method="get" class="filter-row"><input name="q" value="{{ q }}" placeholder="Client or service"><label><input type="checkbox" name="include_inactive" value="true" {% if include_inactive %}checked{% endif %}> Include inactive</label><button class="btn" type="submit">Filter</button></form></div>
<div class="card table-wrap"><table><thead><tr><th>Client</th><th>Service</th><th>Recurrence</th><th>Default Team</th><th>Engagements</th><th>Status</th><th></th></tr></thead><tbody>{% for plan,count,latest_due in rows %}<tr><td><strong>{{ plan.client.client_name }}</strong><div class="muted">{{ plan.client.client_code or '' }}</div></td><td><strong>{{ plan.catalogue.service_name }}</strong><div class="muted">{{ plan.catalogue.service_code }}</div></td><td>{{ plan.recurrence_type|replace('_',' ')|title }}</td><td>Partner: {{ plan.default_partner.full_name if plan.default_partner else '-' }}<br>Performing: {{ plan.default_performing_partner.full_name if plan.default_performing_partner else '-' }}<br>Manager: {{ plan.default_manager.full_name if plan.default_manager else '-' }}<br>Staff: {{ plan.default_staff.full_name if plan.default_staff else '-' }}</td><td>{{ count or 0 }}{% if latest_due %}<div class="muted">Latest due: {{ latest_due }}</div>{% endif %}</td><td>{{ plan.status|title }}</td><td><a href="/services/subscriptions/{{ plan.id }}">View</a></td></tr>{% else %}<tr><td colspan="7">No client service subscriptions found.</td></tr>{% endfor %}</tbody></table></div></div>
{% endblock %}