162 lines
6.2 KiB
Python
162 lines
6.2 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 app.modules.documents.services import DEFAULT_STORAGE_ROOT
|
|
|
|
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 _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _root() -> Path:
|
|
"""Return the automatic ERP work-storage root.
|
|
|
|
The document module already resolves its storage location for the current
|
|
deployment. Bank-statement jobs use a sibling ``Work`` folder so no new
|
|
environment variable or separate path configuration is required.
|
|
"""
|
|
return DEFAULT_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 = [f for f in files if f and (f.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 = "") -> dict:
|
|
metas, all_df, unique_df = analyze_files(paths, customer_override.strip(), account_override.strip())
|
|
output = output_dir / "Bank_Statement_Analysis.xlsx"
|
|
export_excel(output, metas, all_df, unique_df)
|
|
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,
|
|
"banks": sorted({m.bank_name for m in metas}),
|
|
"customer_name": next((m.customer_name for m in metas if m.customer_name), ""),
|
|
"account_number": next((m.account_number for m in metas if m.account_number), ""),
|
|
"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)
|