diff --git a/alembic/versions/20260822_client_business_profile_phase3.py b/alembic/versions/20260822_client_business_profile_phase3.py new file mode 100644 index 0000000..3244b41 --- /dev/null +++ b/alembic/versions/20260822_client_business_profile_phase3.py @@ -0,0 +1,80 @@ +"""Phase 3 client business intelligence profile foundation. + +Revision ID: 20260822_business_profile_p3 +Revises: 20260822_lean_agent_p2 +""" +from alembic import op +import sqlalchemy as sa + +revision = "20260822_business_profile_p3" +down_revision = "20260822_lean_agent_p2" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "client_business_profiles", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + 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"), nullable=True), + sa.Column("primary_industry", sa.String(length=150), nullable=True), + sa.Column("primary_business_activity", sa.String(length=200), nullable=True), + sa.Column("secondary_business_activities", sa.Text(), nullable=True), + sa.Column("business_model", sa.String(length=50), nullable=True), + sa.Column("main_products", sa.Text(), nullable=True), + sa.Column("main_services", sa.Text(), nullable=True), + sa.Column("inventory_maintained", sa.Boolean(), nullable=True), + sa.Column("project_job_based", sa.Boolean(), nullable=True), + sa.Column("capital_intensive", sa.Boolean(), nullable=True), + sa.Column("vehicle_intensive", sa.Boolean(), nullable=True), + sa.Column("profile_status", sa.String(length=30), nullable=False, server_default="draft"), + sa.Column("confidence_score", sa.Integer(), nullable=False, server_default="0"), + sa.Column("confirmed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("confirmed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("client_id", name="uq_client_business_profiles_client"), + ) + for name, cols in [ + ("ix_client_business_profiles_client_id", ["client_id"]), + ("ix_client_business_profiles_tenant_id", ["tenant_id"]), + ("ix_client_business_profiles_branch_id", ["branch_id"]), + ("ix_client_business_profiles_primary_industry", ["primary_industry"]), + ("ix_client_business_profiles_business_model", ["business_model"]), + ("ix_client_business_profiles_profile_status", ["profile_status"]), + ]: + op.create_index(name, "client_business_profiles", cols) + + op.create_table( + "client_business_profile_sources", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("profile_id", sa.Integer(), sa.ForeignKey("client_business_profiles.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("source_type", sa.String(length=40), nullable=False), + sa.Column("source_reference", sa.String(length=255), nullable=True), + sa.Column("observed_on", sa.Date(), nullable=True), + sa.Column("confidence_score", sa.Integer(), nullable=False, server_default="50"), + sa.Column("is_authoritative", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("evidence_json", sa.JSON(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False), + ) + for name, cols in [ + ("ix_client_business_profile_sources_profile_id", ["profile_id"]), + ("ix_client_business_profile_sources_client_id", ["client_id"]), + ("ix_client_business_profile_sources_tenant_id", ["tenant_id"]), + ("ix_client_business_profile_sources_source_type", ["source_type"]), + ]: + op.create_index(name, "client_business_profile_sources", cols) + + +def downgrade(): + op.drop_table("client_business_profile_sources") + op.drop_table("client_business_profiles") diff --git a/app/modules/clients/business_profile_service.py b/app/modules/clients/business_profile_service.py new file mode 100644 index 0000000..7560b94 --- /dev/null +++ b/app/modules/clients/business_profile_service.py @@ -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 diff --git a/app/modules/clients/models.py b/app/modules/clients/models.py index bcc88d1..044fa37 100644 --- a/app/modules/clients/models.py +++ b/app/modules/clients/models.py @@ -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) diff --git a/app/modules/clients/templates/clients/business_profile.html b/app/modules/clients/templates/clients/business_profile.html new file mode 100644 index 0000000..c1ee88c --- /dev/null +++ b/app/modules/clients/templates/clients/business_profile.html @@ -0,0 +1,72 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+ ← Back to client +

Business Intelligence Profile

+

{{ row.client_name }} · {{ row.client_code }}

+
+
+ + {{ (profile.profile_status if profile else 'draft')|replace('_',' ')|title }} + + Confidence {{ profile.confidence_score if profile else 0 }}% +
+
+ +
+ 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. +
+ +
+ +
+ + + + + + + {% for field,label in [('inventory_maintained','Inventory Maintained'),('project_job_based','Project / Job Based'),('capital_intensive','Capital Intensive'),('vehicle_intensive','Vehicle Intensive')] %} + + {% endfor %} + +
+ {% if can_edit %}
{% endif %} +
+ + {% if can_edit %} +
+
+ +

Add Profile Evidence

+

Prepared for GST, Income-tax, Tally and future system enrichment.

+
+ + + + + + +
+ +
+ +
+

Human Confirmation

+

Confirmation makes the current profile the authoritative accounting context. Editing it later automatically returns it to Draft for review.

+
+ + +
+
+
+ {% endif %} + +
+

Evidence / Source History

+
{% for source in sources %}{% else %}{% endfor %}
SourceReferenceObservedConfidenceAuthorityNotes
{{ source.source_type|replace('_',' ')|title }}{{ source.source_reference or '-' }}{{ source.observed_on or '-' }}{{ source.confidence_score }}%{{ 'Authoritative' if source.is_authoritative else 'Supporting' }}{{ source.notes or '-' }}
No profile evidence recorded yet.
+
+
+{% endblock %} diff --git a/app/modules/clients/templates/clients/detail.html b/app/modules/clients/templates/clients/detail.html index 384f4b5..56f2f4f 100644 --- a/app/modules/clients/templates/clients/detail.html +++ b/app/modules/clients/templates/clients/detail.html @@ -9,6 +9,7 @@
{% if can_edit %} + Business Profile Business Structure Registrations Portal Identity diff --git a/app/modules/clients/ui.py b/app/modules/clients/ui.py index 9d2c2d4..5d67add 100644 --- a/app/modules/clients/ui.py +++ b/app/modules/clients/ui.py @@ -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()