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
@@ -0,0 +1,36 @@
"""add persistent client service subscription master
Revision ID: 20260805_client_service_plan_master
Revises: 20260805_task_instance_period_label
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
revision="20260805_client_service_plan_master"
down_revision="20260805_task_instance_period_label"
branch_labels=None
depends_on=None
def upgrade():
bind=op.get_bind(); insp=inspect(bind); tables=set(insp.get_table_names())
if "client_service_plans" not in tables:
op.create_table("client_service_plans",
sa.Column("id",sa.Integer(),primary_key=True),sa.Column("tenant_id",sa.Integer(),sa.ForeignKey("tenants.id",ondelete="CASCADE"),nullable=False),sa.Column("branch_id",sa.Integer(),sa.ForeignKey("branches.id",ondelete="SET NULL")),sa.Column("client_id",sa.Integer(),sa.ForeignKey("clients.id",ondelete="CASCADE"),nullable=False),sa.Column("service_catalogue_id",sa.Integer(),sa.ForeignKey("service_catalogues.id",ondelete="CASCADE"),nullable=False),sa.Column("firm_service_selection_id",sa.Integer(),sa.ForeignKey("firm_service_selections.id",ondelete="SET NULL")),sa.Column("default_partner_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("default_performing_partner_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("default_manager_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("default_staff_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("default_review_partner_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("recurrence_type",sa.String(30),nullable=False,server_default="one_time"),sa.Column("effective_from",sa.Date()),sa.Column("effective_to",sa.Date()),sa.Column("auto_generate_periods",sa.Boolean(),nullable=False,server_default=sa.text("false")),sa.Column("status",sa.String(30),nullable=False,server_default="active"),sa.Column("is_active",sa.Boolean(),nullable=False,server_default=sa.text("true")),sa.Column("remarks",sa.Text()),sa.Column("created_by_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("updated_by_user_id",sa.Integer(),sa.ForeignKey("users.id")),sa.Column("created_at_utc",sa.DateTime(timezone=True),nullable=False,server_default=sa.text("CURRENT_TIMESTAMP")),sa.Column("updated_at_utc",sa.DateTime(timezone=True),nullable=False,server_default=sa.text("CURRENT_TIMESTAMP")),sa.UniqueConstraint("tenant_id","client_id","service_catalogue_id",name="uq_csp_tenant_client_service"))
for col in ("tenant_id","branch_id","client_id","service_catalogue_id","default_partner_user_id","default_performing_partner_user_id","default_manager_user_id","default_staff_user_id","default_review_partner_user_id","recurrence_type","status","is_active"):
op.create_index(f"ix_csp_{col}","client_service_plans",[col])
cols={c["name"] for c in inspect(bind).get_columns("client_service_subscriptions")}
if "service_plan_id" not in cols:
op.add_column("client_service_subscriptions",sa.Column("service_plan_id",sa.Integer(),nullable=True))
op.create_foreign_key("fk_css_service_plan","client_service_subscriptions","client_service_plans",["service_plan_id"],["id"],ondelete="SET NULL")
op.create_index("ix_css_service_plan_id","client_service_subscriptions",["service_plan_id"])
op.execute(sa.text("""INSERT INTO client_service_plans (tenant_id,branch_id,client_id,service_catalogue_id,firm_service_selection_id,default_partner_user_id,default_performing_partner_user_id,default_manager_user_id,default_staff_user_id,default_review_partner_user_id,recurrence_type,status,is_active,remarks,created_by_user_id,updated_by_user_id) SELECT DISTINCT ON (s.tenant_id,s.client_id,s.service_catalogue_id) s.tenant_id,s.branch_id,s.client_id,s.service_catalogue_id,s.firm_service_selection_id,s.assigned_partner_user_id,s.performing_partner_user_id,s.assigned_manager_user_id,s.assigned_staff_user_id,s.review_partner_user_id,COALESCE(c.recurrence_type,'one_time'),'active',true,s.remarks,s.created_by_user_id,s.updated_by_user_id FROM client_service_subscriptions s JOIN service_catalogues c ON c.id=s.service_catalogue_id LEFT JOIN client_service_plans p ON p.tenant_id=s.tenant_id AND p.client_id=s.client_id AND p.service_catalogue_id=s.service_catalogue_id WHERE p.id IS NULL ORDER BY s.tenant_id,s.client_id,s.service_catalogue_id,s.id DESC"""))
op.execute(sa.text("""UPDATE client_service_subscriptions s SET service_plan_id=p.id FROM client_service_plans p WHERE s.service_plan_id IS NULL AND p.tenant_id=s.tenant_id AND p.client_id=s.client_id AND p.service_catalogue_id=s.service_catalogue_id"""))
def downgrade():
bind=op.get_bind(); cols={c["name"] for c in inspect(bind).get_columns("client_service_subscriptions")}
if "service_plan_id" in cols:
op.drop_index("ix_css_service_plan_id",table_name="client_service_subscriptions")
op.drop_constraint("fk_css_service_plan","client_service_subscriptions",type_="foreignkey")
op.drop_column("client_service_subscriptions","service_plan_id")
if "client_service_plans" in inspect(bind).get_table_names(): op.drop_table("client_service_plans")
+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.services import normalize_code, normalize_engagement_type
from app.modules.services.client_services import ( from app.modules.services.client_services import (
assessment_year_from_financial_year, assessment_year_from_financial_year,
attach_engagement_to_plan,
normalize_financial_year, normalize_financial_year,
normalize_period_label, normalize_period_label,
review_partner_required_for_engagement, review_partner_required_for_engagement,
@@ -842,6 +843,10 @@ def import_client_service_assignments(
sub.is_active = is_active sub.is_active = is_active
sub.remarks = _clean(_cell(row, headers, "remarks")) or None sub.remarks = _clean(_cell(row, headers, "remarks")) or None
sub.updated_by_user_id = current_user.id 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) 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) ensure_engagement_quality_workflow(db, subscription=sub, actor_user_id=current_user.id, create_declarations=False)
enforce_quality_gate_on_subscription(sub) 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.iam.models import User
from app.modules.core.rbac.models import Role, UserRole from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Tenant 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 = [ SUBSCRIPTION_STATUSES = [
("draft", "Draft"), ("draft", "Draft"),
@@ -640,3 +640,80 @@ def enforce_quality_gate_on_subscription(subscription: ClientServiceSubscription
subscription.is_active = False subscription.is_active = False
if not subscription.quality_block_reason: if not subscription.quality_block_reason:
subscription.quality_block_reason = "AQMM acceptance workflow pending for assurance engagement." 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.models import ClientServiceSubscription
from app.modules.services.client_services import ( from app.modules.services.client_services import (
SUBSCRIPTION_STATUSES, SUBSCRIPTION_STATUSES,
attach_engagement_to_plan,
get_enabled_firm_service, get_enabled_firm_service,
get_existing_subscription, get_existing_subscription,
get_subscription, get_subscription,
@@ -216,6 +217,12 @@ def subscription_create_submit(
row.remarks = remarks.strip() or None row.remarks = remarks.strip() or None
row.is_active = is_active is not None row.is_active = is_active is not None
row.updated_by_user_id = user.id 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.commit()
db.refresh(row) 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 ( from app.modules.services.client_services import (
SUBSCRIPTION_STATUSES, SUBSCRIPTION_STATUSES,
assessment_year_from_financial_year, assessment_year_from_financial_year,
attach_engagement_to_plan,
current_financial_year, current_financial_year,
get_enabled_firm_service, get_enabled_firm_service,
get_existing_subscription, get_existing_subscription,
@@ -366,6 +367,10 @@ def subscription_create_submit(
row.remarks = remarks.strip() or None row.remarks = remarks.strip() or None
row.is_active = is_active is not None row.is_active = is_active is not None
row.updated_by_user_id = user.id 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) 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) ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(row) enforce_quality_gate_on_subscription(row)
@@ -619,6 +624,10 @@ def subscription_bulk_create_submit(
) )
db.add(row) db.add(row)
db.flush() 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) 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) ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False)
enforce_quality_gate_on_subscription(row) enforce_quality_gate_on_subscription(row)
+50
View File
@@ -371,6 +371,54 @@ class ServiceDueDateExtension(CommonBase):
due_rule = relationship("ServiceDueDateRule") 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): class ClientServiceSubscription(CommonBase):
"""Firm-level subscription of an enabled service to a specific client.""" """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) 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) 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) 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) 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) 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) 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) 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") client = relationship("Client")
catalogue = relationship("ServiceCatalogue") catalogue = relationship("ServiceCatalogue")
due_date_rule = relationship("ServiceDueDateRule", foreign_keys=[due_date_rule_id]) 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 %}
+2
View File
@@ -14,6 +14,7 @@ from app.modules.core.iam.ui import router as iam_ui_router
from app.modules.core.rbac.ui import router as rbac_ui_router from app.modules.core.rbac.ui import router as rbac_ui_router
from app.modules.services.ui import router as services_ui_router from app.modules.services.ui import router as services_ui_router
from app.modules.services.engagements_ui import router as engagements_ui_router from app.modules.services.engagements_ui import router as engagements_ui_router
from app.modules.services.subscriptions_ui import router as service_subscriptions_ui_router
from app.modules.services.work_tracker_ui import router as work_tracker_ui_router from app.modules.services.work_tracker_ui import router as work_tracker_ui_router
from app.modules.billing.ui import router as billing_ui_router from app.modules.billing.ui import router as billing_ui_router
from app.modules.platform_billing.ui import router as platform_billing_ui_router from app.modules.platform_billing.ui import router as platform_billing_ui_router
@@ -80,6 +81,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(employee_portal_router) app.include_router(employee_portal_router)
app.include_router(consultants_ui_router) app.include_router(consultants_ui_router)
app.include_router(engagements_ui_router) app.include_router(engagements_ui_router)
app.include_router(service_subscriptions_ui_router)
app.include_router(client_portal_router) app.include_router(client_portal_router)
app.include_router(consultant_portal_router) app.include_router(consultant_portal_router)
@@ -77,8 +77,13 @@
{ {
'label': 'Engagements', 'label': 'Engagements',
'visible': true, 'visible': true,
'active': _partner_path.startswith('/services/engagements') or _partner_path.startswith('/services/bulk-imports'), 'active': _partner_path.startswith('/services/subscriptions') or _partner_path.startswith('/services/engagements') or _partner_path.startswith('/services/bulk-imports'),
'children': [ 'children': [
{
'label': 'Client Subscriptions',
'url': '/services/subscriptions',
'active': _partner_path.startswith('/services/subscriptions')
},
{ {
'label': 'All Engagements', 'label': 'All Engagements',
'url': '/services/engagements', 'url': '/services/engagements',