Add Phase 3 client business intelligence profile

This commit is contained in:
A R R R Associates
2026-08-22 12:04:44 +05:30
parent f7bf1437ef
commit 397c887703
6 changed files with 467 additions and 0 deletions
@@ -0,0 +1,140 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.clients.models import Client, ClientBusinessProfile, ClientBusinessProfileSource
BUSINESS_MODEL_OPTIONS = (
"manufacturing",
"trading",
"services",
"manufacturing_and_trading",
"contracting",
"construction",
"professional",
"transport_logistics",
"hospitality",
"mixed",
"other",
)
SOURCE_TYPE_OPTIONS = ("manual_user", "gst", "income_tax", "tally", "system_inference", "ai_inference", "other")
def get_profile(db: Session, client_id: int) -> ClientBusinessProfile | None:
return db.execute(select(ClientBusinessProfile).where(ClientBusinessProfile.client_id == int(client_id))).scalar_one_or_none()
def ensure_profile(db: Session, client: Client, actor_user_id: int | None = None) -> ClientBusinessProfile:
row = get_profile(db, client.id)
if row:
return row
row = ClientBusinessProfile(
client_id=client.id,
tenant_id=client.tenant_id,
branch_id=client.branch_id,
created_by_user_id=actor_user_id,
updated_by_user_id=actor_user_id,
)
db.add(row)
db.flush()
return row
def list_sources(db: Session, client_id: int) -> list[ClientBusinessProfileSource]:
return list(db.execute(
select(ClientBusinessProfileSource)
.where(ClientBusinessProfileSource.client_id == int(client_id))
.order_by(ClientBusinessProfileSource.created_at_utc.desc(), ClientBusinessProfileSource.id.desc())
).scalars().all())
def _optional_bool(value: str | None) -> bool | None:
if value in (None, "", "unknown"):
return None
return str(value).lower() in {"1", "true", "yes", "on"}
def update_profile(db: Session, *, client: Client, actor_user_id: int, form) -> ClientBusinessProfile:
row = ensure_profile(db, client, actor_user_id)
row.primary_industry = (form.get("primary_industry") or "").strip() or None
row.primary_business_activity = (form.get("primary_business_activity") or "").strip() or None
row.secondary_business_activities = (form.get("secondary_business_activities") or "").strip() or None
business_model = (form.get("business_model") or "").strip()
row.business_model = business_model if business_model in BUSINESS_MODEL_OPTIONS else None
row.main_products = (form.get("main_products") or "").strip() or None
row.main_services = (form.get("main_services") or "").strip() or None
row.inventory_maintained = _optional_bool(form.get("inventory_maintained"))
row.project_job_based = _optional_bool(form.get("project_job_based"))
row.capital_intensive = _optional_bool(form.get("capital_intensive"))
row.vehicle_intensive = _optional_bool(form.get("vehicle_intensive"))
row.notes = (form.get("notes") or "").strip() or None
row.updated_by_user_id = actor_user_id
# Editing a confirmed profile returns it to review so automated enrichment can
# never silently remain marked confirmed after a human changes the facts.
if row.profile_status == "confirmed":
row.profile_status = "draft"
row.confirmed_by_user_id = None
row.confirmed_at_utc = None
db.commit()
db.refresh(row)
return row
def confirm_profile(db: Session, *, client: Client, actor_user_id: int) -> ClientBusinessProfile:
row = ensure_profile(db, client, actor_user_id)
row.profile_status = "confirmed"
row.confidence_score = 100
row.confirmed_by_user_id = actor_user_id
row.confirmed_at_utc = datetime.now(timezone.utc)
row.updated_by_user_id = actor_user_id
db.commit()
db.refresh(row)
return row
def add_source(db: Session, *, client: Client, actor_user_id: int, form) -> ClientBusinessProfileSource:
profile = ensure_profile(db, client, actor_user_id)
source_type = (form.get("source_type") or "manual_user").strip()
if source_type not in SOURCE_TYPE_OPTIONS:
source_type = "other"
raw_confidence = (form.get("source_confidence") or "50").strip()
try:
confidence = max(0, min(100, int(raw_confidence)))
except ValueError:
confidence = 50
observed_raw = (form.get("observed_on") or "").strip()
observed_on = None
if observed_raw:
try:
observed_on = date.fromisoformat(observed_raw)
except ValueError:
observed_on = None
row = ClientBusinessProfileSource(
profile_id=profile.id,
client_id=client.id,
tenant_id=client.tenant_id,
source_type=source_type,
source_reference=(form.get("source_reference") or "").strip() or None,
observed_on=observed_on,
confidence_score=confidence,
is_authoritative=str(form.get("is_authoritative") or "").lower() in {"1", "true", "yes", "on"},
evidence_json={
"primary_industry": profile.primary_industry,
"primary_business_activity": profile.primary_business_activity,
"business_model": profile.business_model,
"main_products": profile.main_products,
"main_services": profile.main_services,
},
notes=(form.get("source_notes") or "").strip() or None,
created_by_user_id=actor_user_id,
)
db.add(row)
# Confidence is an evidence summary only. User confirmation remains authoritative.
if profile.profile_status != "confirmed":
scores = [s.confidence_score for s in list_sources(db, client.id)] + [confidence]
profile.confidence_score = round(sum(scores) / len(scores)) if scores else 0
db.commit()
db.refresh(row)
return row
+62
View File
@@ -250,3 +250,65 @@ class ClientBranch(CommonBase):
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)
class ClientBusinessProfile(CommonBase):
"""Server-side business context used by accounting intelligence.
This profile is intentionally separate from the legal client master so later
GST/Income-tax/Tally enrichment can evolve without changing existing client
onboarding behaviour.
"""
__tablename__ = "client_business_profiles"
__table_args__ = (UniqueConstraint("client_id", name="uq_client_business_profiles_client"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.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", ondelete="SET NULL"), nullable=True, index=True)
primary_industry: Mapped[str | None] = mapped_column(String(150), nullable=True, index=True)
primary_business_activity: Mapped[str | None] = mapped_column(String(200), nullable=True)
secondary_business_activities: Mapped[str | None] = mapped_column(Text, nullable=True)
business_model: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
main_products: Mapped[str | None] = mapped_column(Text, nullable=True)
main_services: Mapped[str | None] = mapped_column(Text, nullable=True)
inventory_maintained: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
project_job_based: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
capital_intensive: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
vehicle_intensive: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
profile_status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft", index=True)
confidence_score: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
confirmed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
confirmed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), 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)
class ClientBusinessProfileSource(CommonBase):
"""Provenance/evidence rows supporting a client business profile."""
__tablename__ = "client_business_profile_sources"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
profile_id: Mapped[int] = mapped_column(ForeignKey("client_business_profiles.id", ondelete="CASCADE"), nullable=False, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
source_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
source_reference: Mapped[str | None] = mapped_column(String(255), nullable=True)
observed_on: Mapped[date | None] = mapped_column(Date, nullable=True)
confidence_score: Mapped[int] = mapped_column(Integer, nullable=False, default=50)
is_authoritative: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
evidence_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
@@ -0,0 +1,72 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<a href="/clients/{{ row.id }}" class="text-sm font-medium text-brand-700 hover:underline">← Back to client</a>
<h2 class="mt-2 text-2xl font-semibold text-slate-900">Business Intelligence Profile</h2>
<p class="text-sm text-slate-500">{{ row.client_name }} · {{ row.client_code }}</p>
</div>
<div class="flex items-center gap-2">
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if profile and profile.profile_status == 'confirmed' %}bg-emerald-100 text-emerald-700{% else %}bg-amber-100 text-amber-800{% endif %}">
{{ (profile.profile_status if profile else 'draft')|replace('_',' ')|title }}
</span>
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">Confidence {{ profile.confidence_score if profile else 0 }}%</span>
</div>
</div>
<div class="rounded-2xl border border-sky-200 bg-sky-50 p-4 text-sm text-sky-900">
This server-side profile is an input to future accounting classification. GST, Income-tax, Tally and AI enrichment will add evidence here later; none of those sources can silently overwrite a user-confirmed profile.
</div>
<form method="post" action="/clients/{{ row.id }}/business-profile" class="rounded-2xl bg-white p-6 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="grid gap-5 md:grid-cols-2">
<label class="block"><span class="text-sm font-medium text-slate-700">Primary Industry</span><input name="primary_industry" value="{{ profile.primary_industry if profile else '' }}" {% if not can_edit %}disabled{% endif %} class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="block"><span class="text-sm font-medium text-slate-700">Business Model</span><select name="business_model" {% if not can_edit %}disabled{% endif %} class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">Select</option>{% for option in business_model_options %}<option value="{{ option }}" {% if profile and profile.business_model == option %}selected{% endif %}>{{ option|replace('_',' ')|title }}</option>{% endfor %}</select></label>
<label class="block md:col-span-2"><span class="text-sm font-medium text-slate-700">Primary Business Activity</span><input name="primary_business_activity" value="{{ profile.primary_business_activity if profile else '' }}" {% if not can_edit %}disabled{% endif %} class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="block md:col-span-2"><span class="text-sm font-medium text-slate-700">Secondary Business Activities</span><textarea name="secondary_business_activities" rows="3" {% if not can_edit %}disabled{% endif %} class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2">{{ profile.secondary_business_activities if profile else '' }}</textarea></label>
<label class="block"><span class="text-sm font-medium text-slate-700">Main Products</span><textarea name="main_products" rows="4" {% if not can_edit %}disabled{% endif %} class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2">{{ profile.main_products if profile else '' }}</textarea></label>
<label class="block"><span class="text-sm font-medium text-slate-700">Main Services</span><textarea name="main_services" rows="4" {% if not can_edit %}disabled{% endif %} class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2">{{ profile.main_services if profile else '' }}</textarea></label>
{% for field,label in [('inventory_maintained','Inventory Maintained'),('project_job_based','Project / Job Based'),('capital_intensive','Capital Intensive'),('vehicle_intensive','Vehicle Intensive')] %}
<label class="block"><span class="text-sm font-medium text-slate-700">{{ label }}</span><select name="{{ field }}" {% if not can_edit %}disabled{% endif %} class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="unknown">Unknown</option><option value="yes" {% if profile and profile[field] is sameas true %}selected{% endif %}>Yes</option><option value="no" {% if profile and profile[field] is sameas false %}selected{% endif %}>No</option></select></label>
{% endfor %}
<label class="block md:col-span-2"><span class="text-sm font-medium text-slate-700">Profile Notes</span><textarea name="notes" rows="3" {% if not can_edit %}disabled{% endif %} class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2">{{ profile.notes if profile else '' }}</textarea></label>
</div>
{% if can_edit %}<div class="mt-5 flex gap-3"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Save Profile</button></div>{% endif %}
</form>
{% if can_edit %}
<div class="grid gap-6 xl:grid-cols-2">
<form method="post" action="/clients/{{ row.id }}/business-profile/sources" class="rounded-2xl bg-white p-6 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h3 class="font-semibold text-slate-900">Add Profile Evidence</h3>
<p class="mt-1 text-sm text-slate-500">Prepared for GST, Income-tax, Tally and future system enrichment.</p>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<label><span class="text-sm text-slate-700">Source</span><select name="source_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2">{% for option in source_type_options %}<option value="{{ option }}">{{ option|replace('_',' ')|title }}</option>{% endfor %}</select></label>
<label><span class="text-sm text-slate-700">Confidence</span><input type="number" min="0" max="100" name="source_confidence" value="100" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label><span class="text-sm text-slate-700">Reference</span><input name="source_reference" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label><span class="text-sm text-slate-700">Observed On</span><input type="date" name="observed_on" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></label>
<label class="md:col-span-2 flex items-center gap-2"><input type="checkbox" name="is_authoritative" value="1"><span class="text-sm text-slate-700">Authoritative source</span></label>
<label class="md:col-span-2"><span class="text-sm text-slate-700">Notes</span><textarea name="source_notes" rows="2" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"></textarea></label>
</div>
<button class="mt-4 rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Add Evidence</button>
</form>
<div class="rounded-2xl bg-white p-6 shadow-soft">
<h3 class="font-semibold text-slate-900">Human Confirmation</h3>
<p class="mt-2 text-sm text-slate-600">Confirmation makes the current profile the authoritative accounting context. Editing it later automatically returns it to Draft for review.</p>
<form method="post" action="/clients/{{ row.id }}/business-profile/confirm" class="mt-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700">Confirm Current Profile</button>
</form>
</div>
</div>
{% endif %}
<section class="rounded-2xl bg-white p-6 shadow-soft">
<h3 class="font-semibold text-slate-900">Evidence / Source History</h3>
<div class="mt-4 overflow-x-auto"><table class="min-w-full text-sm"><thead class="text-left text-xs uppercase text-slate-500"><tr><th class="px-3 py-2">Source</th><th class="px-3 py-2">Reference</th><th class="px-3 py-2">Observed</th><th class="px-3 py-2">Confidence</th><th class="px-3 py-2">Authority</th><th class="px-3 py-2">Notes</th></tr></thead><tbody class="divide-y divide-slate-100">{% for source in sources %}<tr><td class="px-3 py-3">{{ source.source_type|replace('_',' ')|title }}</td><td class="px-3 py-3">{{ source.source_reference or '-' }}</td><td class="px-3 py-3">{{ source.observed_on or '-' }}</td><td class="px-3 py-3">{{ source.confidence_score }}%</td><td class="px-3 py-3">{{ 'Authoritative' if source.is_authoritative else 'Supporting' }}</td><td class="px-3 py-3">{{ source.notes or '-' }}</td></tr>{% else %}<tr><td colspan="6" class="px-3 py-6 text-center text-slate-500">No profile evidence recorded yet.</td></tr>{% endfor %}</tbody></table></div>
</section>
</div>
{% endblock %}
@@ -9,6 +9,7 @@
<div class="flex flex-wrap gap-3">
{% if can_edit %}
<a href="/clients/{{ row.id }}/business-profile" class="rounded-xl border border-sky-300 px-4 py-2 text-sm font-medium text-sky-700 hover:bg-sky-50">Business Profile</a>
<a href="/clients/{{ row.id }}/business-structure" class="rounded-xl border border-emerald-300 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50">Business Structure</a>
<a href="/registrations/clients/{{ row.id }}" class="rounded-xl border border-amber-300 px-4 py-2 text-sm font-medium text-amber-700 hover:bg-amber-50">Registrations</a>
<a href="/client-identity/clients/{{ row.id }}" class="rounded-xl border border-indigo-300 px-4 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50">Portal Identity</a>
+112
View File
@@ -55,6 +55,16 @@ 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.execution import list_client_visible_task_comments
from app.modules.clients.auditor_service import build_client_auditor_card
from app.modules.clients.business_profile_service import (
BUSINESS_MODEL_OPTIONS,
SOURCE_TYPE_OPTIONS,
add_source,
confirm_profile,
ensure_profile,
get_profile,
list_sources,
update_profile,
)
from app.modules.client_groups.service import get_group, list_groups
from app.modules.clients.portal_service import (
build_client_portal_summary,
@@ -2069,3 +2079,105 @@ async def client_portal_engagement_letter_upload_signed(request: Request, letter
return RedirectResponse(url="/client/engagement-letter", status_code=303)
finally:
db.close()
@router.get("/{client_id}/business-profile")
def client_business_profile_page(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.view"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = _apply_role_scope(build_scope(request, user, has), user, role_names)
client = get_client_or_404(
db,
client_id=client_id,
tenant_id=scope.tenant_id,
branch_id=scope.branch_id,
allow_cross_branch=scope.allow_cross_branch,
allow_all_clients=scope.allow_all_clients,
viewer_partner_id=_viewer_partner_id(user, role_names),
)
profile = get_profile(db, client.id)
return _render(
request,
"modules/clients/templates/clients/business_profile.html",
db,
user,
title=f"Business Profile • {client.client_name}",
row=client,
profile=profile,
sources=list_sources(db, client.id),
business_model_options=BUSINESS_MODEL_OPTIONS,
source_type_options=SOURCE_TYPE_OPTIONS,
can_edit=has("clients.edit"),
)
finally:
db.close()
@router.post("/{client_id}/business-profile")
async def client_business_profile_update(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.edit"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = _apply_role_scope(build_scope(request, user, has), user, role_names)
client = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=_viewer_partner_id(user, role_names))
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
update_profile(db, client=client, actor_user_id=user.id, form=form)
return RedirectResponse(url=f"/clients/{client.id}/business-profile", status_code=303)
finally:
db.close()
@router.post("/{client_id}/business-profile/confirm")
async def client_business_profile_confirm(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.edit"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = _apply_role_scope(build_scope(request, user, has), user, role_names)
client = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=_viewer_partner_id(user, role_names))
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
confirm_profile(db, client=client, actor_user_id=user.id)
return RedirectResponse(url=f"/clients/{client.id}/business-profile", status_code=303)
finally:
db.close()
@router.post("/{client_id}/business-profile/sources")
async def client_business_profile_add_source(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user = get_current_user(request, db=db)
if not user:
return RedirectResponse(url="/login", status_code=303)
has = _has_perm_factory(db, user)
if not has("clients.edit"):
return _redirect_denied()
role_names = _role_names(db, user)
scope = _apply_role_scope(build_scope(request, user, has), user, role_names)
client = get_client_or_404(db, client_id=client_id, tenant_id=scope.tenant_id, branch_id=scope.branch_id, allow_cross_branch=scope.allow_cross_branch, allow_all_clients=scope.allow_all_clients, viewer_partner_id=_viewer_partner_id(user, role_names))
form = await request.form()
validate_csrf(request, form.get("csrf_token"))
add_source(db, client=client, actor_user_id=user.id, form=form)
return RedirectResponse(url=f"/clients/{client.id}/business-profile", status_code=303)
finally:
db.close()