206 lines
7.6 KiB
Python
206 lines
7.6 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
from fastapi import UploadFile
|
|
|
|
from .analyzer import analyze_files, export_excel
|
|
|
|
ALLOWED_ROLES = {"Partner", "Manager", "Branch Manager", "Staff", "Employee", "Consultant"}
|
|
MAX_FILES = int(os.getenv("BANK_ANALYZER_MAX_FILES", "24"))
|
|
MAX_FILE_BYTES = int(os.getenv("BANK_ANALYZER_MAX_FILE_MB", "50")) * 1024 * 1024
|
|
RETENTION_HOURS = int(os.getenv("BANK_ANALYZER_FAILED_RETENTION_HOURS", "24"))
|
|
_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
|
|
|
|
def _resolve_document_storage_root() -> Path:
|
|
"""Resolve the same automatic document root without importing documents.services.
|
|
|
|
Importing app.modules.documents.services here creates a circular import through
|
|
clients.__init__ -> clients.ui -> documents.services. This local resolver
|
|
intentionally mirrors the existing document module's storage-root rules while
|
|
keeping the bank analyzer independent of document models and UI modules.
|
|
"""
|
|
configured = (os.getenv("DOCUMENT_STORAGE_ROOT") or "").strip()
|
|
if configured:
|
|
configured_path = Path(configured).expanduser()
|
|
if configured_path.is_absolute():
|
|
return configured_path
|
|
return (Path.cwd() / configured_path).resolve()
|
|
|
|
if os.name == "nt":
|
|
anchor = Path.cwd().anchor or (os.environ.get("SystemDrive", "C:") + "\\")
|
|
return Path(anchor) / "AuditFirmERPDocuments" / "engagement_documents"
|
|
|
|
return (Path.cwd() / "documents" / "engagement_documents").resolve()
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _root() -> Path:
|
|
return _resolve_document_storage_root().parent / "Work"
|
|
|
|
|
|
def _segment(value: object, default: str = "NA") -> str:
|
|
text = _SAFE.sub("_", str(value or default).strip()).strip("._-")
|
|
return text[:80] or default
|
|
|
|
|
|
def role_bucket(roles: Iterable[str]) -> str | None:
|
|
role_set = set(roles)
|
|
for role, bucket in (("Partner", "Partner"), ("Manager", "Manager"), ("Branch Manager", "Manager"), ("Staff", "Staff"), ("Employee", "Staff"), ("Consultant", "Consultant")):
|
|
if role in role_set:
|
|
return bucket
|
|
return None
|
|
|
|
|
|
def can_use(roles: Iterable[str]) -> bool:
|
|
return bool(set(roles) & ALLOWED_ROLES)
|
|
|
|
|
|
def _user_root(user, roles: Iterable[str]) -> Path:
|
|
bucket = role_bucket(roles)
|
|
if not bucket:
|
|
raise PermissionError("Bank Statement Analyzer is available only to Partner, Manager, Staff and Consultant roles.")
|
|
label = _segment(getattr(user, "full_name", None) or getattr(user, "email", None) or f"user_{user.id}")
|
|
return _root() / bucket / f"{int(user.id)}_{label}" / "Bank_Statement_Analyzer"
|
|
|
|
|
|
def cleanup_expired(user, roles: Iterable[str]) -> None:
|
|
base = _user_root(user, roles)
|
|
if not base.exists():
|
|
return
|
|
cutoff = _now() - timedelta(hours=RETENTION_HOURS)
|
|
for child in base.iterdir():
|
|
try:
|
|
modified = datetime.fromtimestamp(child.stat().st_mtime, tz=timezone.utc)
|
|
if child.is_dir() and modified < cutoff:
|
|
shutil.rmtree(child, ignore_errors=True)
|
|
except OSError:
|
|
continue
|
|
|
|
|
|
def create_job(user, roles: Iterable[str]) -> tuple[str, Path, Path]:
|
|
cleanup_expired(user, roles)
|
|
job_id = uuid.uuid4().hex
|
|
job = _user_root(user, roles) / job_id
|
|
input_dir = job / "Input"
|
|
output_dir = job / "Output"
|
|
input_dir.mkdir(parents=True, exist_ok=False)
|
|
output_dir.mkdir(parents=True, exist_ok=False)
|
|
return job_id, input_dir, output_dir
|
|
|
|
|
|
def _validate_pdf_header(data: bytes) -> None:
|
|
if not data.startswith(b"%PDF-"):
|
|
raise ValueError("Only genuine PDF files are allowed.")
|
|
|
|
|
|
async def save_uploads(files: list[UploadFile], input_dir: Path) -> list[Path]:
|
|
usable = [file for file in files if file and (file.filename or "").strip()]
|
|
if not usable:
|
|
raise ValueError("Please select at least one PDF bank statement.")
|
|
if len(usable) > MAX_FILES:
|
|
raise ValueError(f"A maximum of {MAX_FILES} PDF files can be analyzed in one job.")
|
|
saved: list[Path] = []
|
|
for index, upload in enumerate(usable, start=1):
|
|
name = Path(upload.filename or f"statement_{index}.pdf").name
|
|
if Path(name).suffix.lower() != ".pdf":
|
|
raise ValueError(f"{name}: only PDF files are allowed.")
|
|
safe_name = f"{index:02d}_{_segment(Path(name).stem, f'statement_{index}')}.pdf"
|
|
target = input_dir / safe_name
|
|
size = 0
|
|
first = b""
|
|
with target.open("wb") as handle:
|
|
while True:
|
|
chunk = await upload.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
if not first:
|
|
first = chunk[:8]
|
|
size += len(chunk)
|
|
if size > MAX_FILE_BYTES:
|
|
raise ValueError(f"{name}: file exceeds the {MAX_FILE_BYTES // (1024 * 1024)} MB limit.")
|
|
handle.write(chunk)
|
|
_validate_pdf_header(first)
|
|
saved.append(target)
|
|
return saved
|
|
|
|
|
|
def analyze_job(
|
|
*,
|
|
user,
|
|
roles: Iterable[str],
|
|
job_id: str,
|
|
paths: list[Path],
|
|
output_dir: Path,
|
|
customer_override: str = "",
|
|
account_override: str = "",
|
|
bank_selection: str = "auto",
|
|
financial_year: str = "",
|
|
classification_enabled: bool = True,
|
|
) -> dict:
|
|
metas, all_df, unique_df = analyze_files(
|
|
paths,
|
|
customer_override.strip(),
|
|
account_override.strip(),
|
|
bank_hint=bank_selection,
|
|
classification_enabled=classification_enabled,
|
|
)
|
|
output = output_dir / "Bank_Statement_Analysis.xlsx"
|
|
export_excel(
|
|
output,
|
|
metas,
|
|
all_df,
|
|
unique_df,
|
|
financial_year=financial_year.strip(),
|
|
selected_bank=bank_selection,
|
|
classification_enabled=classification_enabled,
|
|
)
|
|
summary = {
|
|
"job_id": job_id,
|
|
"owner_user_id": int(user.id),
|
|
"created_at": _now().isoformat(),
|
|
"statement_count": len(metas),
|
|
"rows_extracted": int(len(all_df)),
|
|
"unique_transactions": int(len(unique_df)),
|
|
"exact_duplicate_rows": int(all_df.exact_duplicate.sum()) if not all_df.empty else 0,
|
|
"possible_duplicate_rows": int(all_df.possible_duplicate.sum()) if not all_df.empty else 0,
|
|
"review_items": int(unique_df.review_note.fillna("").ne("").sum()) if not unique_df.empty else 0,
|
|
"categories": int(unique_df.category.nunique()) if not unique_df.empty else 0,
|
|
"banks": sorted({meta.bank_name for meta in metas}),
|
|
"customer_name": next((meta.customer_name for meta in metas if meta.customer_name), ""),
|
|
"account_number": next((meta.account_number for meta in metas if meta.account_number), ""),
|
|
"financial_year": financial_year.strip(),
|
|
"classification_enabled": classification_enabled,
|
|
"output_file": output.name,
|
|
}
|
|
(output_dir.parent / "job.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
|
return summary
|
|
|
|
|
|
def resolve_owned_job(user, roles: Iterable[str], job_id: str) -> tuple[Path, dict]:
|
|
if not re.fullmatch(r"[a-f0-9]{32}", job_id or ""):
|
|
raise FileNotFoundError("Analysis job not found.")
|
|
job = _user_root(user, roles) / job_id
|
|
meta_path = job / "job.json"
|
|
if not meta_path.is_file():
|
|
raise FileNotFoundError("Analysis job not found.")
|
|
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
if int(meta.get("owner_user_id", 0)) != int(user.id):
|
|
raise PermissionError("You cannot access another user's analysis job.")
|
|
return job, meta
|
|
|
|
|
|
def delete_job(job: Path) -> None:
|
|
shutil.rmtree(job, ignore_errors=True)
|