Add Phase 20 stored bank reuse and richer reconciliation
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user