Add Phase 11B client aware multi bank ownership and engagement archive
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from app.modules.clients.models import Client, ClientBranch, ClientBusinessUnit
|
||||
from app.modules.consultants.models import ClientConsultantLink
|
||||
from app.modules.consultants.service import get_consultant_by_user
|
||||
from app.modules.documents.services import (
|
||||
build_document_scope,
|
||||
list_visible_engagements,
|
||||
save_uploaded_revision,
|
||||
user_can_upload_to_engagement,
|
||||
)
|
||||
from app.modules.registrations.models import ClientRegistration
|
||||
from app.modules.services.models import ClientServiceSubscription
|
||||
|
||||
|
||||
_LEGAL_NOISE = {
|
||||
"M", "S", "MS", "MR", "MRS", "MISS",
|
||||
"PRIVATE", "PVT", "LIMITED", "LTD", "LLP",
|
||||
"PROPRIETOR", "PROPRIETORSHIP", "PROP",
|
||||
"THE", "INDIA",
|
||||
}
|
||||
|
||||
|
||||
def _text(value) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _normalise_name(value) -> str:
|
||||
raw = re.sub(r"[^A-Z0-9]+", " ", _text(value).upper()).strip()
|
||||
tokens = [token for token in raw.split() if token and token not in _LEGAL_NOISE]
|
||||
return " ".join(tokens)
|
||||
|
||||
|
||||
def _name_match(extracted: str, alias: str) -> tuple[bool, int, str]:
|
||||
left = _normalise_name(extracted)
|
||||
right = _normalise_name(alias)
|
||||
if not left or not right:
|
||||
return False, 0, ""
|
||||
if left == right:
|
||||
return True, 100, "exact normalized account-holder match"
|
||||
|
||||
# Conservative containment is allowed only for reasonably descriptive names.
|
||||
if min(len(left), len(right)) >= 8 and (left in right or right in left):
|
||||
score = int(round(100 * min(len(left), len(right)) / max(len(left), len(right))))
|
||||
if score >= 82:
|
||||
return True, max(90, score), "legal/trade-name containment match"
|
||||
|
||||
ratio = int(round(SequenceMatcher(None, left, right).ratio() * 100))
|
||||
if ratio >= 92:
|
||||
return True, ratio, "high-confidence legal/trade-name similarity match"
|
||||
return False, ratio, ""
|
||||
|
||||
|
||||
def client_owner_aliases(db, *, tenant_id: int, client_id: int) -> list[str]:
|
||||
client = db.get(Client, int(client_id))
|
||||
if not client or client.tenant_id != int(tenant_id) or client.is_archived:
|
||||
raise ValueError("Selected client was not found.")
|
||||
|
||||
values = [client.client_name, client.trade_name]
|
||||
|
||||
for unit in db.execute(
|
||||
select(ClientBusinessUnit).where(
|
||||
ClientBusinessUnit.tenant_id == tenant_id,
|
||||
ClientBusinessUnit.client_id == client.id,
|
||||
ClientBusinessUnit.is_active.is_(True),
|
||||
)
|
||||
).scalars().all():
|
||||
values.extend([unit.business_name, unit.trade_name])
|
||||
|
||||
for branch in db.execute(
|
||||
select(ClientBranch).where(
|
||||
ClientBranch.tenant_id == tenant_id,
|
||||
ClientBranch.client_id == client.id,
|
||||
ClientBranch.is_active.is_(True),
|
||||
)
|
||||
).scalars().all():
|
||||
values.append(branch.branch_name)
|
||||
|
||||
for registration in db.execute(
|
||||
select(ClientRegistration).where(
|
||||
ClientRegistration.tenant_id == tenant_id,
|
||||
ClientRegistration.client_id == client.id,
|
||||
ClientRegistration.status.in_(["active", "valid", "registered"]),
|
||||
)
|
||||
).scalars().all():
|
||||
values.extend([registration.legal_name, registration.trade_name])
|
||||
|
||||
result = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
value = _text(value)
|
||||
key = _normalise_name(value)
|
||||
if value and key and key not in seen:
|
||||
seen.add(key)
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
|
||||
def _consultant_context(db, user, tenant_id: int):
|
||||
consultant = get_consultant_by_user(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=int(user.id),
|
||||
)
|
||||
return consultant
|
||||
|
||||
|
||||
def analyzer_client_context(db, *, request, user, roles: list[str]) -> dict:
|
||||
"""Return role-scoped clients and archive-capable engagements.
|
||||
|
||||
Partner/Manager/Staff reuse the existing Document module visibility rules.
|
||||
Consultant reuses the existing ClientConsultantLink permissions.
|
||||
"""
|
||||
tenant_id = int(
|
||||
request.session.get("active_tenant_id")
|
||||
or getattr(user, "tenant_id", 0)
|
||||
or 0
|
||||
)
|
||||
role_set = set(roles or [])
|
||||
clients: dict[int, Client] = {}
|
||||
engagements: dict[int, ClientServiceSubscription] = {}
|
||||
|
||||
if "Consultant" in role_set:
|
||||
consultant = _consultant_context(db, user, tenant_id)
|
||||
if not consultant:
|
||||
return {"clients": [], "engagements": [], "consultant": None}
|
||||
|
||||
links = list(db.execute(
|
||||
select(ClientConsultantLink).where(
|
||||
ClientConsultantLink.tenant_id == tenant_id,
|
||||
ClientConsultantLink.consultant_id == consultant.id,
|
||||
ClientConsultantLink.is_active.is_(True),
|
||||
ClientConsultantLink.can_view_client.is_(True),
|
||||
)
|
||||
).scalars().all())
|
||||
|
||||
client_ids = {int(link.client_id) for link in links}
|
||||
upload_ids = {
|
||||
int(link.client_id)
|
||||
for link in links
|
||||
if bool(link.can_upload_documents)
|
||||
}
|
||||
|
||||
if client_ids:
|
||||
for client in db.execute(
|
||||
select(Client).where(
|
||||
Client.tenant_id == tenant_id,
|
||||
Client.id.in_(client_ids),
|
||||
Client.is_archived.is_(False),
|
||||
Client.is_active.is_(True),
|
||||
).order_by(Client.client_name.asc())
|
||||
).scalars().all():
|
||||
clients[int(client.id)] = client
|
||||
|
||||
if upload_ids:
|
||||
for engagement in db.execute(
|
||||
select(ClientServiceSubscription).where(
|
||||
ClientServiceSubscription.tenant_id == tenant_id,
|
||||
ClientServiceSubscription.client_id.in_(upload_ids),
|
||||
ClientServiceSubscription.is_active.is_(True),
|
||||
).order_by(
|
||||
ClientServiceSubscription.financial_year.desc(),
|
||||
ClientServiceSubscription.id.desc(),
|
||||
)
|
||||
).scalars().all():
|
||||
engagements[int(engagement.id)] = engagement
|
||||
|
||||
return {
|
||||
"clients": list(clients.values()),
|
||||
"engagements": list(engagements.values()),
|
||||
"consultant": consultant,
|
||||
}
|
||||
|
||||
# Firm Partner / Manager / Staff use the existing engagement-document scope.
|
||||
scope = build_document_scope(request, db, user)
|
||||
visible = list_visible_engagements(db, user, scope, limit=1000)
|
||||
for engagement in visible:
|
||||
client = getattr(engagement, "client", None) or db.get(Client, engagement.client_id)
|
||||
if client and client.is_active and not client.is_archived:
|
||||
clients[int(client.id)] = client
|
||||
if user_can_upload_to_engagement(db, user, engagement, scope):
|
||||
engagements[int(engagement.id)] = engagement
|
||||
|
||||
return {
|
||||
"clients": sorted(clients.values(), key=lambda c: (c.client_name or "").casefold()),
|
||||
"engagements": list(engagements.values()),
|
||||
"consultant": None,
|
||||
}
|
||||
|
||||
|
||||
def validate_selected_client_and_engagement(
|
||||
db,
|
||||
*,
|
||||
request,
|
||||
user,
|
||||
roles: list[str],
|
||||
client_id: int | None,
|
||||
engagement_id: int | None,
|
||||
):
|
||||
if not client_id:
|
||||
if engagement_id:
|
||||
raise ValueError("Select a client before selecting an engagement.")
|
||||
return None, None
|
||||
|
||||
context = analyzer_client_context(db, request=request, user=user, roles=roles)
|
||||
clients = {int(row.id): row for row in context["clients"]}
|
||||
engagements = {int(row.id): row for row in context["engagements"]}
|
||||
|
||||
client = clients.get(int(client_id))
|
||||
if not client:
|
||||
raise ValueError("Selected client is not available in your Bank Analyzer scope.")
|
||||
|
||||
if not engagement_id:
|
||||
raise ValueError(
|
||||
"Select an engagement document destination when a client is selected."
|
||||
)
|
||||
|
||||
engagement = engagements.get(int(engagement_id))
|
||||
if not engagement or int(engagement.client_id) != int(client.id):
|
||||
raise ValueError(
|
||||
"Selected engagement is not available for document upload for this client."
|
||||
)
|
||||
return client, engagement
|
||||
|
||||
|
||||
def validate_statement_ownership(
|
||||
db,
|
||||
*,
|
||||
tenant_id: int,
|
||||
client_id: int | None,
|
||||
metas: list,
|
||||
owner_override: str = "",
|
||||
ownership_confirmation: bool = False,
|
||||
) -> dict:
|
||||
"""Validate that all statements belong to one ERP client/legal/trade-name family.
|
||||
|
||||
With an ERP client selected the check is strict. Every statement must expose
|
||||
an owner name or use a manually confirmed owner override, and that name must
|
||||
match one of the ERP legal/trade aliases.
|
||||
"""
|
||||
extracted = []
|
||||
for position, meta in enumerate(metas, start=1):
|
||||
raw = _text(
|
||||
getattr(meta, "extracted_customer_name", None)
|
||||
or getattr(meta, "customer_name", None)
|
||||
)
|
||||
extracted.append({
|
||||
"statement_id": getattr(meta, "statement_id", f"STMT-{position:03d}"),
|
||||
"source_file": _text(getattr(meta, "source_file", "")),
|
||||
"owner_name": raw,
|
||||
})
|
||||
|
||||
if not client_id:
|
||||
# Preserve standalone behavior. We record the owner names for review but
|
||||
# do not change current storage or reject mixed standalone analyses.
|
||||
return {
|
||||
"status": "standalone",
|
||||
"client_id": None,
|
||||
"aliases": [],
|
||||
"statements": extracted,
|
||||
"message": "Standalone analysis; no ERP client ownership binding requested.",
|
||||
}
|
||||
|
||||
aliases = client_owner_aliases(
|
||||
db,
|
||||
tenant_id=int(tenant_id),
|
||||
client_id=int(client_id),
|
||||
)
|
||||
if not aliases:
|
||||
raise ValueError(
|
||||
"Selected client does not have a usable legal/trade name for bank ownership validation."
|
||||
)
|
||||
|
||||
override = _text(owner_override)
|
||||
if override:
|
||||
matched_override = any(_name_match(override, alias)[0] for alias in aliases)
|
||||
if not matched_override:
|
||||
raise ValueError(
|
||||
f"Account-holder override '{override}' does not match the selected client's "
|
||||
"legal/trade names."
|
||||
)
|
||||
|
||||
results = []
|
||||
failures = []
|
||||
|
||||
for statement in extracted:
|
||||
owner = statement["owner_name"] or override
|
||||
if not owner:
|
||||
failures.append(
|
||||
f"{statement['statement_id']}: account-holder name could not be extracted. "
|
||||
"Use Account holder override only after verifying the statement manually."
|
||||
)
|
||||
results.append({**statement, "matched": False, "score": 0, "matched_alias": ""})
|
||||
continue
|
||||
|
||||
best = (False, 0, "", "")
|
||||
for alias in aliases:
|
||||
matched, score, reason = _name_match(owner, alias)
|
||||
if score > best[1]:
|
||||
best = (matched, score, alias, reason)
|
||||
|
||||
matched, score, alias, reason = best
|
||||
results.append({
|
||||
**statement,
|
||||
"effective_owner_name": owner,
|
||||
"matched": bool(matched),
|
||||
"score": int(score),
|
||||
"matched_alias": alias,
|
||||
"reason": reason,
|
||||
})
|
||||
if not matched:
|
||||
failures.append(
|
||||
f"{statement['statement_id']}: '{owner}' does not match the selected client's "
|
||||
"legal/trade names."
|
||||
)
|
||||
|
||||
if failures:
|
||||
raise ValueError(
|
||||
"Bank account ownership validation failed. " + " ".join(failures)
|
||||
)
|
||||
|
||||
if len(metas) > 1 and not ownership_confirmation:
|
||||
raise ValueError(
|
||||
"Confirm that all uploaded bank statements belong to the selected client "
|
||||
"or its approved trade/business names."
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "confirmed",
|
||||
"client_id": int(client_id),
|
||||
"aliases": aliases,
|
||||
"statements": results,
|
||||
"message": f"Ownership confirmed for {len(results)} statement(s).",
|
||||
}
|
||||
|
||||
|
||||
def _upload_proxy(path: Path, content_type: str):
|
||||
handle = path.open("rb")
|
||||
return SimpleNamespace(
|
||||
filename=path.name,
|
||||
content_type=content_type,
|
||||
file=handle,
|
||||
)
|
||||
|
||||
|
||||
def archive_analysis_to_engagement(
|
||||
db,
|
||||
*,
|
||||
job,
|
||||
engagement: ClientServiceSubscription | None,
|
||||
metas: list,
|
||||
source_paths: list[Path],
|
||||
output_path: Path,
|
||||
user,
|
||||
) -> dict:
|
||||
if not job.client_id or not job.engagement_id or engagement is None:
|
||||
return {"status": "not_requested", "documents": []}
|
||||
|
||||
archived = []
|
||||
meta_by_source = {
|
||||
Path(_text(getattr(meta, "source_file", ""))).name: meta
|
||||
for meta in metas
|
||||
}
|
||||
|
||||
for source in source_paths:
|
||||
upload = _upload_proxy(source, "application/pdf")
|
||||
try:
|
||||
meta = meta_by_source.get(source.name)
|
||||
bank = _text(getattr(meta, "bank_name", "")) if meta else ""
|
||||
account = _text(getattr(meta, "account_number", "")) if meta else ""
|
||||
title_bits = ["Bank Statement"]
|
||||
if bank:
|
||||
title_bits.append(bank)
|
||||
if account:
|
||||
title_bits.append(account[-6:])
|
||||
document = save_uploaded_revision(
|
||||
db,
|
||||
engagement=engagement,
|
||||
upload_file=upload,
|
||||
title=" - ".join(title_bits),
|
||||
document_type="BANK_STATEMENT",
|
||||
description=(
|
||||
f"Bank Statement Analyzer source. Job {job.id}. "
|
||||
f"Ownership status: {job.ownership_status}."
|
||||
),
|
||||
remarks="Automatically archived from Bank Statement Analyzer.",
|
||||
user=user,
|
||||
udin_required=False,
|
||||
)
|
||||
archived.append({
|
||||
"document_id": int(document.id),
|
||||
"filename": source.name,
|
||||
"type": "BANK_STATEMENT",
|
||||
})
|
||||
finally:
|
||||
upload.file.close()
|
||||
|
||||
workbook_upload = _upload_proxy(
|
||||
output_path,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
try:
|
||||
document = save_uploaded_revision(
|
||||
db,
|
||||
engagement=engagement,
|
||||
upload_file=workbook_upload,
|
||||
title="Bank Statement Analyzer - Analysis Workbook",
|
||||
document_type="WORKING_PAPER",
|
||||
description=(
|
||||
f"Generated Bank Statement Analyzer workbook for job {job.id}; "
|
||||
f"includes multi-bank contra review where applicable."
|
||||
),
|
||||
remarks="Automatically archived from Bank Statement Analyzer.",
|
||||
user=user,
|
||||
udin_required=False,
|
||||
)
|
||||
archived.append({
|
||||
"document_id": int(document.id),
|
||||
"filename": output_path.name,
|
||||
"type": "WORKING_PAPER",
|
||||
})
|
||||
finally:
|
||||
workbook_upload.file.close()
|
||||
|
||||
return {
|
||||
"status": "archived",
|
||||
"client_id": int(job.client_id),
|
||||
"engagement_id": int(job.engagement_id),
|
||||
"documents": archived,
|
||||
}
|
||||
Reference in New Issue
Block a user