Separate client service subscriptions from engagement instances
This commit is contained in:
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user