Add Phase 3 client business intelligence profile
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user