Add Phase 20 stored bank reuse and richer reconciliation

This commit is contained in:
A R R R Associates
2026-08-24 18:58:58 +05:30
parent 769add8cb8
commit b8fbaf55ec
12 changed files with 1419 additions and 78 deletions
+94 -1
View File
@@ -68,6 +68,80 @@ def _segment(value: object, default: str = "NA") -> str:
def _statement_period(value):
text_value = str(value or "").strip()
if not text_value:
return None
for fmt in (
"%Y-%m-%d",
"%d/%m/%Y",
"%d-%m-%Y",
"%d.%m.%Y",
"%d-%b-%Y",
"%d %b %Y",
"%d %B %Y",
"%Y/%m/%d",
):
try:
return datetime.strptime(text_value[:10], fmt).date()
except Exception:
pass
try:
return datetime.fromisoformat(text_value.replace("Z", "+00:00")).date()
except Exception:
return None
def _statement_overlap_warnings(metas: list) -> list[dict]:
warnings = []
groups = {}
for meta in metas:
account = str(getattr(meta, "account_number", "") or "").strip()
bank = str(getattr(meta, "bank_name", "") or "").strip()
start = _statement_period(getattr(meta, "period_from", ""))
end = _statement_period(getattr(meta, "period_to", ""))
if not account or not start or not end:
continue
if end < start:
start, end = end, start
groups.setdefault((bank.casefold(), account), []).append(
{
"bank_name": bank,
"account_number": account,
"period_from": start,
"period_to": end,
"source_file": Path(str(getattr(meta, "source_file", "") or "")).name,
}
)
for (_bank_key, _account), rows in groups.items():
rows.sort(key=lambda row: (row["period_from"], row["period_to"]))
for idx, left in enumerate(rows):
for right in rows[idx + 1:]:
if right["period_from"] > left["period_to"]:
break
overlap_from = max(left["period_from"], right["period_from"])
overlap_to = min(left["period_to"], right["period_to"])
if overlap_from <= overlap_to:
warnings.append(
{
"bank_name": left["bank_name"],
"account_number": left["account_number"],
"first_file": left["source_file"],
"second_file": right["source_file"],
"overlap_from": overlap_from.isoformat(),
"overlap_to": overlap_to.isoformat(),
"message": (
f"Overlapping statement periods for account "
f"{left['account_number']}: {left['source_file']} and "
f"{right['source_file']} overlap from "
f"{overlap_from.isoformat()} to {overlap_to.isoformat()}."
),
}
)
return warnings
def _workbook_filename(metas: list) -> str:
"""Build a safe BankName_ClientName.xlsx filename for every bank."""
bank_names = [str(getattr(meta, "bank_name", "") or "").strip() for meta in metas]
@@ -162,7 +236,7 @@ def pending_count_for_user(user_id: int) -> int:
db.close()
def enqueue_job(*, user, roles: Iterable[str], job_id: str, paths: list[Path], job_dir: Path, bank_selection: str, financial_year: str, customer_override: str, account_override: str, classification_enabled: bool, client_id: int | None = None, engagement_id: int | None = None, ownership_confirmation: bool = False, purpose: str = "analyze_only") -> BankStatementAnalysisJob:
def enqueue_job(*, user, roles: Iterable[str], job_id: str, paths: list[Path], job_dir: Path, bank_selection: str, financial_year: str, customer_override: str, account_override: str, classification_enabled: bool, client_id: int | None = None, engagement_id: int | None = None, ownership_confirmation: bool = False, purpose: str = "analyze_only", stored_source_versions: list[dict] | None = None, source_hashes: list[dict] | None = None) -> BankStatementAnalysisJob:
if pending_count_for_user(int(user.id)) >= MAX_PENDING_PER_USER:
shutil.rmtree(job_dir, ignore_errors=True)
raise ValueError("You already have three queued or processing analyses. Please wait for one to complete before submitting another.")
@@ -192,6 +266,16 @@ def enqueue_job(*, user, roles: Iterable[str], job_id: str, paths: list[Path], j
if purpose in {"accounting_entries", "bank_reconciliation"}
else "not_requested"
),
stored_source_versions_json=(
json.dumps(stored_source_versions, ensure_ascii=False)
if stored_source_versions
else None
),
source_hashes_json=(
json.dumps(source_hashes, ensure_ascii=False)
if source_hashes
else None
),
status="queued",
progress_percent=0,
file_count=len(paths),
@@ -328,6 +412,13 @@ def _process_job(job_id: str) -> None:
"engagement_id": job.engagement_id,
"ownership_status": job.ownership_status,
"engagement_archive_status": job.engagement_archive_status,
"stored_statement_reuse_count": len(
json.loads(job.stored_source_versions_json or "[]")
) if getattr(job, "stored_source_versions_json", None) else 0,
"source_hash_count": len(
json.loads(job.source_hashes_json or "[]")
) if getattr(job, "source_hashes_json", None) else 0,
"statement_overlap_warnings": _statement_overlap_warnings(metas),
}
# Keep the original uploaded statements until the job expiry time.
# This applies equally to completed and failed jobs and allows the
@@ -530,6 +621,8 @@ def job_view(job: BankStatementAnalysisJob) -> dict:
"purpose": getattr(job, "purpose", "analyze_only"),
"accounting_import_status": getattr(job, "accounting_import_status", "not_requested"),
"accounting_import": json.loads(job.accounting_import_json) if getattr(job, "accounting_import_json", None) else {},
"stored_source_versions": json.loads(job.stored_source_versions_json) if getattr(job, "stored_source_versions_json", None) else [],
"source_hashes": json.loads(job.source_hashes_json) if getattr(job, "source_hashes_json", None) else [],
"submitted_at": job.submitted_at_utc,
"completed_at": job.completed_at_utc,
"expires_at": job.expires_at_utc,