Show scope identity for client service subscriptions
This commit is contained in:
@@ -143,6 +143,239 @@ def _registration_scope_context(db, *, tenant_id: int, plan: ClientServicePlan):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_scope_display_map(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
plans: list[ClientServicePlan],
|
||||||
|
) -> dict[int, dict[str, str]]:
|
||||||
|
"""Resolve human-friendly subscription names without changing stored scope data."""
|
||||||
|
if not plans:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
business_ids = {int(row.business_unit_id) for row in plans if row.business_unit_id}
|
||||||
|
branch_ids = {int(row.client_branch_id) for row in plans if row.client_branch_id}
|
||||||
|
registration_ids = {int(row.registration_id) for row in plans if row.registration_id}
|
||||||
|
|
||||||
|
businesses = {}
|
||||||
|
if business_ids:
|
||||||
|
businesses = {
|
||||||
|
row.id: row
|
||||||
|
for row in db.execute(
|
||||||
|
select(ClientBusinessUnit).where(
|
||||||
|
ClientBusinessUnit.tenant_id == tenant_id,
|
||||||
|
ClientBusinessUnit.id.in_(business_ids),
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
branches = {}
|
||||||
|
if branch_ids:
|
||||||
|
branches = {
|
||||||
|
row.id: row
|
||||||
|
for row in db.execute(
|
||||||
|
select(ClientBranch).where(
|
||||||
|
ClientBranch.tenant_id == tenant_id,
|
||||||
|
ClientBranch.id.in_(branch_ids),
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
registrations = {}
|
||||||
|
registration_types = {}
|
||||||
|
if registration_ids:
|
||||||
|
registration_rows = db.execute(
|
||||||
|
select(ClientRegistration, RegistrationType)
|
||||||
|
.join(
|
||||||
|
RegistrationType,
|
||||||
|
RegistrationType.id == ClientRegistration.registration_type_id,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
ClientRegistration.tenant_id == tenant_id,
|
||||||
|
ClientRegistration.id.in_(registration_ids),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for registration, registration_type in registration_rows:
|
||||||
|
registrations[registration.id] = registration
|
||||||
|
registration_types[registration.id] = registration_type
|
||||||
|
|
||||||
|
# A mapped registration can carry its own Business Unit / Client Branch.
|
||||||
|
# Load those as fallbacks even when the plan's denormalised IDs are absent.
|
||||||
|
extra_business_ids = {
|
||||||
|
int(row.business_unit_id)
|
||||||
|
for row in registrations.values()
|
||||||
|
if row.business_unit_id and row.business_unit_id not in businesses
|
||||||
|
}
|
||||||
|
if extra_business_ids:
|
||||||
|
businesses.update({
|
||||||
|
row.id: row
|
||||||
|
for row in db.execute(
|
||||||
|
select(ClientBusinessUnit).where(
|
||||||
|
ClientBusinessUnit.tenant_id == tenant_id,
|
||||||
|
ClientBusinessUnit.id.in_(extra_business_ids),
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
})
|
||||||
|
|
||||||
|
extra_branch_ids = {
|
||||||
|
int(row.client_branch_id)
|
||||||
|
for row in registrations.values()
|
||||||
|
if row.client_branch_id and row.client_branch_id not in branches
|
||||||
|
}
|
||||||
|
if extra_branch_ids:
|
||||||
|
branches.update({
|
||||||
|
row.id: row
|
||||||
|
for row in db.execute(
|
||||||
|
select(ClientBranch).where(
|
||||||
|
ClientBranch.tenant_id == tenant_id,
|
||||||
|
ClientBranch.id.in_(extra_branch_ids),
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
})
|
||||||
|
|
||||||
|
result: dict[int, dict[str, str]] = {}
|
||||||
|
for plan in plans:
|
||||||
|
client = plan.client
|
||||||
|
client_name = (getattr(client, "client_name", None) or "").strip()
|
||||||
|
client_code = (getattr(client, "client_code", None) or "").strip()
|
||||||
|
client_trade_name = (getattr(client, "trade_name", None) or "").strip()
|
||||||
|
scope_type = (getattr(plan, "scope_type", None) or "client").strip().lower()
|
||||||
|
|
||||||
|
primary = client_name or client_trade_name or client_code or f"Client {plan.client_id}"
|
||||||
|
secondary = client_code
|
||||||
|
context = ""
|
||||||
|
scope_label = "Client"
|
||||||
|
|
||||||
|
if scope_type == "business_unit":
|
||||||
|
business = businesses.get(plan.business_unit_id)
|
||||||
|
if business:
|
||||||
|
primary = (
|
||||||
|
(getattr(business, "trade_name", None) or "").strip()
|
||||||
|
or (getattr(business, "business_name", None) or "").strip()
|
||||||
|
or primary
|
||||||
|
)
|
||||||
|
secondary = (getattr(business, "business_code", None) or "").strip()
|
||||||
|
context = client_name if client_name and client_name != primary else ""
|
||||||
|
scope_label = "Business Unit"
|
||||||
|
|
||||||
|
elif scope_type == "client_branch":
|
||||||
|
branch = branches.get(plan.client_branch_id)
|
||||||
|
business = businesses.get(
|
||||||
|
getattr(branch, "business_unit_id", None) if branch else plan.business_unit_id
|
||||||
|
)
|
||||||
|
if branch:
|
||||||
|
primary = (getattr(branch, "branch_name", None) or "").strip() or primary
|
||||||
|
secondary = (getattr(branch, "branch_code", None) or "").strip()
|
||||||
|
business_name = ""
|
||||||
|
if business:
|
||||||
|
business_name = (
|
||||||
|
(getattr(business, "trade_name", None) or "").strip()
|
||||||
|
or (getattr(business, "business_name", None) or "").strip()
|
||||||
|
)
|
||||||
|
context_parts = []
|
||||||
|
if business_name and business_name != primary:
|
||||||
|
context_parts.append(business_name)
|
||||||
|
if client_name and client_name not in {primary, business_name}:
|
||||||
|
context_parts.append(f"Client: {client_name}")
|
||||||
|
context = " · ".join(context_parts)
|
||||||
|
scope_label = "Client Branch"
|
||||||
|
|
||||||
|
elif scope_type == "registration":
|
||||||
|
registration = registrations.get(plan.registration_id)
|
||||||
|
registration_type = registration_types.get(plan.registration_id)
|
||||||
|
if registration:
|
||||||
|
business = businesses.get(
|
||||||
|
getattr(registration, "business_unit_id", None) or plan.business_unit_id
|
||||||
|
)
|
||||||
|
branch = branches.get(
|
||||||
|
getattr(registration, "client_branch_id", None) or plan.client_branch_id
|
||||||
|
)
|
||||||
|
|
||||||
|
business_trade_name = (
|
||||||
|
(getattr(business, "trade_name", None) or "").strip()
|
||||||
|
if business else ""
|
||||||
|
)
|
||||||
|
business_name = (
|
||||||
|
(getattr(business, "business_name", None) or "").strip()
|
||||||
|
if business else ""
|
||||||
|
)
|
||||||
|
branch_name = (
|
||||||
|
(getattr(branch, "branch_name", None) or "").strip()
|
||||||
|
if branch else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Registration-level subscriptions are identified by the
|
||||||
|
# registration's own trade name first. This prevents one
|
||||||
|
# individual/legal client with multiple registrations from
|
||||||
|
# appearing as several indistinguishable rows.
|
||||||
|
primary = (
|
||||||
|
(getattr(registration, "trade_name", None) or "").strip()
|
||||||
|
or business_trade_name
|
||||||
|
or business_name
|
||||||
|
or branch_name
|
||||||
|
or (getattr(registration, "legal_name", None) or "").strip()
|
||||||
|
or client_trade_name
|
||||||
|
or client_name
|
||||||
|
or primary
|
||||||
|
)
|
||||||
|
registration_number = (
|
||||||
|
getattr(registration, "registration_number", None) or ""
|
||||||
|
).strip()
|
||||||
|
registration_code = (
|
||||||
|
getattr(registration_type, "code", None) or ""
|
||||||
|
).strip()
|
||||||
|
secondary = (
|
||||||
|
f"{registration_code} · {registration_number}"
|
||||||
|
if registration_code and registration_number
|
||||||
|
else registration_number or registration_code
|
||||||
|
)
|
||||||
|
context_parts = []
|
||||||
|
if branch_name and branch_name != primary:
|
||||||
|
context_parts.append(branch_name)
|
||||||
|
if client_name and client_name != primary:
|
||||||
|
context_parts.append(f"Client: {client_name}")
|
||||||
|
context = " · ".join(context_parts)
|
||||||
|
scope_label = "Registration"
|
||||||
|
else:
|
||||||
|
# Legacy/unmapped rows retain their existing client display.
|
||||||
|
# No stored subscription or engagement data is altered.
|
||||||
|
scope_label = "Registration"
|
||||||
|
|
||||||
|
result[int(plan.id)] = {
|
||||||
|
"primary": primary,
|
||||||
|
"secondary": secondary,
|
||||||
|
"context": context,
|
||||||
|
"scope_label": scope_label,
|
||||||
|
"client_name": client_name,
|
||||||
|
"client_code": client_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _subscription_scope_display(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
tenant_id: int,
|
||||||
|
plan: ClientServicePlan,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
return _subscription_scope_display_map(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
plans=[plan],
|
||||||
|
).get(
|
||||||
|
int(plan.id),
|
||||||
|
{
|
||||||
|
"primary": plan.client.client_name,
|
||||||
|
"secondary": plan.client.client_code or "",
|
||||||
|
"context": "",
|
||||||
|
"scope_label": "Client",
|
||||||
|
"client_name": plan.client.client_name,
|
||||||
|
"client_code": plan.client.client_code or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def _scope_key_for(scope_type: str, target_id: int) -> str:
|
def _scope_key_for(scope_type: str, target_id: int) -> str:
|
||||||
prefixes = {
|
prefixes = {
|
||||||
"client": "CLIENT",
|
"client": "CLIENT",
|
||||||
@@ -282,6 +515,11 @@ def subscription_master_list(request: Request, q: str = "", include_inactive: bo
|
|||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
stmt.order_by(ClientServicePlan.is_active.desc(), ClientServicePlan.id.desc())
|
stmt.order_by(ClientServicePlan.is_active.desc(), ClientServicePlan.id.desc())
|
||||||
).all()
|
).all()
|
||||||
|
scope_displays = _subscription_scope_display_map(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
plans=[row[0] for row in rows],
|
||||||
|
)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"modules/services/templates/services/subscriptions/list.html",
|
"modules/services/templates/services/subscriptions/list.html",
|
||||||
_ctx(
|
_ctx(
|
||||||
@@ -290,6 +528,7 @@ def subscription_master_list(request: Request, q: str = "", include_inactive: bo
|
|||||||
user,
|
user,
|
||||||
title="Client Service Subscriptions",
|
title="Client Service Subscriptions",
|
||||||
rows=rows,
|
rows=rows,
|
||||||
|
scope_displays=scope_displays,
|
||||||
q=q,
|
q=q,
|
||||||
include_inactive=include_inactive,
|
include_inactive=include_inactive,
|
||||||
can_edit_subscription="clients.edit" in set(get_user_permissions(db, user.id)),
|
can_edit_subscription="clients.edit" in set(get_user_permissions(db, user.id)),
|
||||||
@@ -659,6 +898,11 @@ def subscription_scope_edit_page(
|
|||||||
saved=saved,
|
saved=saved,
|
||||||
updated_engagements=updated_engagements,
|
updated_engagements=updated_engagements,
|
||||||
error_message=error_messages.get(error, ""),
|
error_message=error_messages.get(error, ""),
|
||||||
|
scope_display=_subscription_scope_display(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
plan=plan,
|
||||||
|
),
|
||||||
**scope_context,
|
**scope_context,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -971,6 +1215,11 @@ def subscription_master_edit_page(
|
|||||||
updated_engagements=updated_engagements,
|
updated_engagements=updated_engagements,
|
||||||
updated_tasks=updated_tasks,
|
updated_tasks=updated_tasks,
|
||||||
error=error,
|
error=error,
|
||||||
|
scope_display=_subscription_scope_display(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
plan=plan,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -1192,6 +1441,11 @@ def subscription_master_detail(request: Request, plan_id: int):
|
|||||||
plan=plan,
|
plan=plan,
|
||||||
engagements=engagements,
|
engagements=engagements,
|
||||||
can_edit_subscription="clients.edit" in permissions,
|
can_edit_subscription="clients.edit" in permissions,
|
||||||
|
scope_display=_subscription_scope_display(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
plan=plan,
|
||||||
|
),
|
||||||
**_registration_scope_context(
|
**_registration_scope_context(
|
||||||
db,
|
db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|||||||
@@ -9,10 +9,13 @@
|
|||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-slate-900">
|
<h2 class="text-xl font-semibold text-slate-900">
|
||||||
{{ plan.client.client_name }} — {{ plan.catalogue.service_name }}
|
{{ scope_display.primary if scope_display else plan.client.client_name }} — {{ plan.catalogue.service_name }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-slate-500">
|
<p class="text-sm text-slate-500">
|
||||||
Client service subscription master and its period-wise engagements.
|
Client service subscription master and its period-wise engagements.
|
||||||
|
{% if scope_display and scope_display.context %}
|
||||||
|
<span class="block">{{ scope_display.context }}</span>
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-slate-900">Edit Client Service Subscription</h2>
|
<h2 class="text-xl font-semibold text-slate-900">Edit Client Service Subscription</h2>
|
||||||
<p class="text-sm text-slate-500">
|
<p class="text-sm text-slate-500">
|
||||||
{{ plan.client.client_name }} — {{ plan.catalogue.service_name }}
|
{{ scope_display.primary if scope_display else plan.client.client_name }} — {{ plan.catalogue.service_name }}
|
||||||
|
{% if scope_display and scope_display.context %}
|
||||||
|
<span class="block text-xs text-slate-400">{{ scope_display.context }}</span>
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/services/subscriptions/{{ plan.id }}"
|
<a href="/services/subscriptions/{{ plan.id }}"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-slate-900">Client Service Subscriptions</h2>
|
<h2 class="text-xl font-semibold text-slate-900">Client Service Subscriptions</h2>
|
||||||
<p class="text-sm text-slate-500">
|
<p class="text-sm text-slate-500">
|
||||||
Persistent client-level services. Period-wise engagements are generated and tracked separately.
|
Persistent client services shown by their assigned scope. Period-wise engagements are generated and tracked separately.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/services/engagements/new"
|
<a href="/services/engagements/new"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<input type="text"
|
<input type="text"
|
||||||
name="q"
|
name="q"
|
||||||
value="{{ q or '' }}"
|
value="{{ q or '' }}"
|
||||||
placeholder="Client or service"
|
placeholder="Client, scope or service"
|
||||||
class="w-72 rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
class="w-72 rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||||
</div>
|
</div>
|
||||||
<label class="inline-flex items-center gap-2 pb-2 text-sm text-slate-700">
|
<label class="inline-flex items-center gap-2 pb-2 text-sm text-slate-700">
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<table class="min-w-full divide-y divide-slate-200">
|
<table class="min-w-full divide-y divide-slate-200">
|
||||||
<thead class="bg-slate-50">
|
<thead class="bg-slate-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client</th>
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Client / Scope</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Service</th>
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Service</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Recurrence</th>
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Recurrence</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Default Team</th>
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">Default Team</th>
|
||||||
@@ -60,8 +60,25 @@
|
|||||||
{% for plan, count, latest_due in rows %}
|
{% for plan, count, latest_due in rows %}
|
||||||
<tr class="align-top">
|
<tr class="align-top">
|
||||||
<td class="px-4 py-3 text-sm">
|
<td class="px-4 py-3 text-sm">
|
||||||
<div class="font-medium text-slate-900">{{ plan.client.client_name }}</div>
|
{% set display = scope_displays.get(plan.id) %}
|
||||||
<div class="text-xs text-slate-500">{{ plan.client.client_code or '' }}</div>
|
<div class="font-medium text-slate-900">
|
||||||
|
{{ display.primary if display else plan.client.client_name }}
|
||||||
|
</div>
|
||||||
|
{% if display and display.secondary %}
|
||||||
|
<div class="text-xs text-slate-500">{{ display.secondary }}</div>
|
||||||
|
{% elif plan.client.client_code %}
|
||||||
|
<div class="text-xs text-slate-500">{{ plan.client.client_code }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if display and display.context %}
|
||||||
|
<div class="mt-0.5 text-xs text-slate-400">{{ display.context }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if display and display.scope_label != 'Client' %}
|
||||||
|
<div class="mt-1">
|
||||||
|
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||||
|
{{ display.scope_label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm">
|
<td class="px-4 py-3 text-sm">
|
||||||
<div class="font-medium text-slate-900">{{ plan.catalogue.service_name }}</div>
|
<div class="font-medium text-slate-900">{{ plan.catalogue.service_name }}</div>
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-slate-900">Map Subscription Scope</h2>
|
<h2 class="text-xl font-semibold text-slate-900">Map Subscription Scope</h2>
|
||||||
<p class="text-sm text-slate-500">
|
<p class="text-sm text-slate-500">
|
||||||
{{ plan.client.client_name }} — {{ plan.catalogue.service_name }}
|
{{ scope_display.primary if scope_display else plan.client.client_name }} — {{ plan.catalogue.service_name }}
|
||||||
|
{% if scope_display and scope_display.context %}
|
||||||
|
<span class="block text-xs text-slate-400">{{ scope_display.context }}</span>
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/services/subscriptions/{{ plan.id }}"
|
<a href="/services/subscriptions/{{ plan.id }}"
|
||||||
|
|||||||
Reference in New Issue
Block a user