Add Phase 19 bank accounting and reconciliation workflow
This commit is contained in:
@@ -33,6 +33,12 @@ class BankStatementAnalysisJob(CommonBase):
|
||||
account_override: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
classification_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Phase 19: one analyzer, three controlled uses. Existing jobs default to
|
||||
# analyze_only and preserve the historical Bank Analyzer behaviour.
|
||||
purpose: Mapped[str] = mapped_column(String(30), nullable=False, default="analyze_only", index=True)
|
||||
accounting_import_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_requested", index=True)
|
||||
accounting_import_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True)
|
||||
progress_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
file_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
@@ -162,7 +162,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) -> 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") -> 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.")
|
||||
@@ -186,6 +186,12 @@ def enqueue_job(*, user, roles: Iterable[str], job_id: str, paths: list[Path], j
|
||||
customer_override=(customer_override or "").strip() or None,
|
||||
account_override=(account_override or "").strip() or None,
|
||||
classification_enabled=bool(classification_enabled),
|
||||
purpose=(purpose if purpose in {"analyze_only", "accounting_entries", "bank_reconciliation"} else "analyze_only"),
|
||||
accounting_import_status=(
|
||||
"pending"
|
||||
if purpose in {"accounting_entries", "bank_reconciliation"}
|
||||
else "not_requested"
|
||||
),
|
||||
status="queued",
|
||||
progress_percent=0,
|
||||
file_count=len(paths),
|
||||
@@ -333,6 +339,46 @@ def _process_job(job_id: str) -> None:
|
||||
job.completed_at_utc = _now()
|
||||
job.expires_at_utc = _now() + timedelta(hours=COMPLETED_RETENTION_HOURS)
|
||||
db.commit()
|
||||
|
||||
# Phase 19 reuses the existing AccountingBankTransaction import path.
|
||||
# Analyze-only remains unchanged. Accounting/Reconciliation jobs import
|
||||
# only after ownership has passed and the workbook has completed.
|
||||
if job.purpose in {"accounting_entries", "bank_reconciliation"}:
|
||||
try:
|
||||
if not job.client_id:
|
||||
raise ValueError(
|
||||
"Accounting / Bank Reconciliation purpose requires an ERP client."
|
||||
)
|
||||
from app.modules.accounting.bank_service import import_completed_job
|
||||
|
||||
added, skipped = import_completed_job(
|
||||
db,
|
||||
tenant_id=int(job.tenant_id or 0),
|
||||
client_id=int(job.client_id),
|
||||
job_id=job.id,
|
||||
user_id=int(job.user_id),
|
||||
)
|
||||
job = db.get(BankStatementAnalysisJob, job.id)
|
||||
job.accounting_import_status = "completed"
|
||||
job.accounting_import_json = json.dumps(
|
||||
{
|
||||
"added": int(added),
|
||||
"skipped_duplicate_fingerprints": int(skipped),
|
||||
"purpose": job.purpose,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
db.commit()
|
||||
except Exception as import_exc:
|
||||
db.rollback()
|
||||
job = db.get(BankStatementAnalysisJob, job_id)
|
||||
if job:
|
||||
job.accounting_import_status = "failed"
|
||||
job.accounting_import_json = json.dumps(
|
||||
{"error": str(import_exc)[:2000]},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
job = db.get(BankStatementAnalysisJob, job_id)
|
||||
@@ -481,6 +527,9 @@ def job_view(job: BankStatementAnalysisJob) -> dict:
|
||||
"file_count": job.file_count,
|
||||
"selected_bank": job.selected_bank,
|
||||
"financial_year": job.financial_year or "",
|
||||
"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 {},
|
||||
"submitted_at": job.submitted_at_utc,
|
||||
"completed_at": job.completed_at_utc,
|
||||
"expires_at": job.expires_at_utc,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="mx-auto max-w-6xl space-y-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><h1 class="text-2xl font-bold text-slate-900">Bank Statement Analyzer</h1><p class="mt-2 text-sm text-slate-600">Upload one or several PDF statements for the same client. Multiple bank accounts can be analysed together so equal-and-opposite inter-bank transfers can be identified as probable contra. Up to three analyses run globally at one time; additional jobs are queued safely.</p></div>
|
||||
<div><h1 class="text-2xl font-bold text-slate-900">Bank Statement Analyzer</h1><p class="mt-2 text-sm text-slate-600">Upload any number of PDF statements for the same client and choose how the same Bank Analyzer should be used: Analyze Only, Prepare Accounting Entries, or Bank Reconciliation. Multi-bank statements are analysed together for conservative contra detection; existing analysis/export behaviour remains available.</p></div>
|
||||
<a href="/tools/bank-statement-analyzer/jobs" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">My Analysis Jobs</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,6 +13,30 @@
|
||||
<form action="/tools/bank-statement-analyzer/analyze" method="post" enctype="multipart/form-data" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<div class="md:col-span-2 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="text-sm font-semibold text-slate-900">Purpose of this bank run</div>
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-3">
|
||||
<label class="rounded-xl border border-slate-200 bg-white p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<input type="radio" name="purpose" value="analyze_only" {% if purpose == 'analyze_only' %}checked{% endif %} class="mt-1">
|
||||
<span><span class="block text-sm font-semibold">Analyze Only</span><span class="mt-1 block text-xs text-slate-500">Existing Bank Analyzer workflow: classification, contra review, summaries and Excel output. No accounting import.</span></span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="rounded-xl border border-blue-200 bg-blue-50/40 p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<input type="radio" name="purpose" value="accounting_entries" {% if purpose == 'accounting_entries' %}checked{% endif %} class="mt-1">
|
||||
<span><span class="block text-sm font-semibold">Prepare Accounting Entries</span><span class="mt-1 block text-xs text-slate-500">After analysis and ownership validation, import normalized transactions directly into the existing Receipt / Payment / Contra accounting queue.</span></span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="rounded-xl border border-emerald-200 bg-emerald-50/40 p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<input type="radio" name="purpose" value="bank_reconciliation" {% if purpose == 'bank_reconciliation' %}checked{% endif %} class="mt-1">
|
||||
<span><span class="block text-sm font-semibold">Bank Reconciliation</span><span class="mt-1 block text-xs text-slate-500">Import the bank-side transactions and then compare them with the selected Tally bank ledger without posting new vouchers automatically.</span></span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-500">Accounting Entries and Bank Reconciliation require an ERP client. Analyze Only can still be used as a standalone job.</p>
|
||||
</div>
|
||||
{% if analyzer_clients %}
|
||||
<div class="md:col-span-2 rounded-xl border border-indigo-200 bg-indigo-50 p-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
@@ -64,7 +88,7 @@
|
||||
<div class="mt-6 rounded-xl border border-emerald-200 bg-emerald-50 p-5"><h3 class="font-bold text-emerald-900">Analysis completed</h3><div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><div><div class="text-xs text-emerald-700">Statements</div><div class="font-bold">{{ active_job.summary.statement_count or 0 }}</div></div><div><div class="text-xs text-emerald-700">Transactions extracted</div><div class="font-bold">{{ active_job.summary.rows_extracted or 0 }}</div></div><div><div class="text-xs text-emerald-700">Exact duplicates</div><div class="font-bold">{{ active_job.summary.exact_duplicate_rows or 0 }}</div></div><div><div class="text-xs text-emerald-700">Review items</div><div class="font-bold">{{ active_job.summary.review_items or 0 }}</div></div><div><div class="text-xs text-emerald-700">Inter-bank contra pairs</div><div class="font-bold">{{ active_job.summary.contra_pairs or 0 }}</div></div></div><div class="mt-5 flex flex-wrap gap-3"><a href="/tools/bank-statement-analyzer/jobs/{{ active_job.id }}/download" class="rounded-xl bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white">Download Excel</a>{% for file in active_job.original_files %}<a href="/tools/bank-statement-analyzer/jobs/{{ active_job.id }}/statements/{{ file.index }}/download" class="rounded-xl border border-emerald-300 bg-white px-5 py-2.5 text-sm font-semibold text-emerald-800">Download Statement {{ file.index }}</a>{% endfor %}<a href="/tools/bank-statement-analyzer" class="rounded-xl border border-emerald-300 bg-white px-5 py-2.5 text-sm font-semibold text-emerald-800">Analyze Another Bank</a></div><p class="mt-3 text-xs text-emerald-700">The workbook and original statement{{ 's' if active_job.file_count != 1 else '' }} remain available until {{ active_job.expires_at or '24 hours after completion' }}.</p></div>
|
||||
{% elif active_job.status == 'failed' %}<div class="mt-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"><strong>Analysis failed.</strong><div class="mt-1">{{ active_job.error_message }}</div>{% if active_job.original_files %}<div class="mt-4 flex flex-wrap gap-3">{% for file in active_job.original_files %}<a href="/tools/bank-statement-analyzer/jobs/{{ active_job.id }}/statements/{{ file.index }}/download" class="rounded-xl border border-red-300 bg-white px-4 py-2 text-sm font-semibold text-red-800">Download Statement {{ file.index }}</a>{% endfor %}</div><p class="mt-3 text-xs text-red-700">Original statement{{ 's are' if active_job.file_count != 1 else ' is' }} retained until {{ active_job.expires_at or '24 hours after failure' }} for debugging.</p>{% endif %}<a href="/tools/bank-statement-analyzer" class="mt-3 inline-block font-semibold underline">Analyze another statement</a></div>
|
||||
{% else %}<div id="live-message" class="mt-5 rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">{% if active_job.status == 'queued' %}Your job is queued. You may safely leave this page and return through My Analysis Jobs.{% else %}Your statements are being processed.{% endif %}</div>{% endif %}
|
||||
</section>
|
||||
{% if active_job.status == 'completed' and active_job.purpose in ['accounting_entries','bank_reconciliation'] %}<div class="mt-4 rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">Accounting import: {{ active_job.accounting_import_status|replace('_',' ')|title }}{% if active_job.summary.client_id %} · <a class="font-semibold underline" href="/tools/accounting/bank-posting?client_id={{ active_job.summary.client_id }}">Open accounting queue</a>{% endif %}</div>{% endif %}</section>
|
||||
{% endif %}
|
||||
|
||||
{% if recent_jobs %}<section class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft"><div class="flex items-center justify-between"><h2 class="text-lg font-bold text-slate-900">Recent Analyses</h2><a href="/tools/bank-statement-analyzer/jobs" class="text-sm font-semibold text-brand-700">View all</a></div><div class="mt-4 overflow-x-auto"><table class="min-w-full text-sm"><thead><tr class="border-b text-left text-slate-500"><th class="py-2 pr-4">Submitted</th><th class="py-2 pr-4">Bank</th><th class="py-2 pr-4">Files</th><th class="py-2 pr-4">Status</th><th class="py-2">Action</th></tr></thead><tbody>{% for item in recent_jobs %}<tr class="border-b border-slate-100"><td class="py-3 pr-4">{{ item.submitted_at }}</td><td class="py-3 pr-4">{{ item.selected_bank|replace('_',' ')|title }}</td><td class="py-3 pr-4">{{ item.file_count }}</td><td class="py-3 pr-4">{{ item.status|title }}{% if item.queue_position %} · Position {{ item.queue_position }}{% endif %}</td><td class="py-3"><a class="font-semibold text-brand-700" href="/tools/bank-statement-analyzer?job={{ item.id }}#analysis-status">View</a></td></tr>{% endfor %}</tbody></table></div></section>{% endif %}
|
||||
|
||||
@@ -86,6 +86,7 @@ def _ctx(request, db, user, **extra):
|
||||
"selected_bank": "auto",
|
||||
"financial_year": "",
|
||||
"classification_enabled": True,
|
||||
"purpose": "analyze_only",
|
||||
"analyzer_clients": [],
|
||||
"analyzer_engagements": [],
|
||||
"selected_client_id": None,
|
||||
@@ -137,7 +138,7 @@ def index(request: Request, job: str | None = None):
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze(request: Request, csrf_token: str = Form(...), bank_selection: str = Form("auto"), financial_year: str = Form(""), customer_name: str = Form(""), account_number: str = Form(""), client_id: str = Form(""), engagement_id: str = Form(""), confirm_same_client: str | None = Form(None), enable_classification: str | None = Form(None), statements: list[UploadFile] = File(...)):
|
||||
async def analyze(request: Request, csrf_token: str = Form(...), bank_selection: str = Form("auto"), financial_year: str = Form(""), customer_name: str = Form(""), account_number: str = Form(""), client_id: str = Form(""), engagement_id: str = Form(""), confirm_same_client: str | None = Form(None), enable_classification: str | None = Form(None), purpose: str = Form("analyze_only"), statements: list[UploadFile] = File(...)):
|
||||
db = CommonSessionLocal()
|
||||
selected_bank = bank_selection if bank_selection in dict(BANK_OPTIONS) else "auto"
|
||||
classification_enabled = enable_classification == "1"
|
||||
@@ -147,8 +148,16 @@ async def analyze(request: Request, csrf_token: str = Form(...), bank_selection:
|
||||
if denied:
|
||||
return denied
|
||||
validate_csrf(request, csrf_token)
|
||||
purpose = purpose if purpose in {"analyze_only", "accounting_entries", "bank_reconciliation"} else "analyze_only"
|
||||
resolved_client_id = int(client_id) if client_id.strip() else None
|
||||
resolved_engagement_id = int(engagement_id) if engagement_id.strip() else None
|
||||
|
||||
if purpose in {"accounting_entries", "bank_reconciliation"} and not resolved_client_id:
|
||||
raise ValueError(
|
||||
"Prepare Accounting Entries and Bank Reconciliation require an ERP client. "
|
||||
"Use Analyze Only for standalone analysis."
|
||||
)
|
||||
|
||||
validate_selected_client_and_engagement(
|
||||
db,
|
||||
request=request,
|
||||
@@ -175,6 +184,7 @@ async def analyze(request: Request, csrf_token: str = Form(...), bank_selection:
|
||||
client_id=resolved_client_id,
|
||||
engagement_id=resolved_engagement_id,
|
||||
ownership_confirmation=(confirm_same_client == "1"),
|
||||
purpose=purpose,
|
||||
)
|
||||
return RedirectResponse(f"/tools/bank-statement-analyzer?job={job_id}#analysis-status", status_code=303)
|
||||
except Exception as exc:
|
||||
@@ -187,7 +197,27 @@ async def analyze(request: Request, csrf_token: str = Form(...), bank_selection:
|
||||
timezone_name = _display_timezone_name(request, db, user)
|
||||
recent = [_localised_job_view(item, timezone_name) for item in list_user_jobs(user.id, limit=8)]
|
||||
client_context = analyzer_client_context(db, request=request, user=user, roles=get_user_roles(db, user.id))
|
||||
return templates.TemplateResponse("modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html", _ctx(request, db, user, error=str(exc), active_job=None, recent_jobs=recent, selected_bank=selected_bank, financial_year=financial_year, classification_enabled=classification_enabled, display_timezone=timezone_name), status_code=400)
|
||||
return templates.TemplateResponse(
|
||||
"modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html",
|
||||
_ctx(
|
||||
request,
|
||||
db,
|
||||
user,
|
||||
error=str(exc),
|
||||
active_job=None,
|
||||
recent_jobs=recent,
|
||||
selected_bank=selected_bank,
|
||||
financial_year=financial_year,
|
||||
classification_enabled=classification_enabled,
|
||||
purpose=purpose if purpose in {"analyze_only", "accounting_entries", "bank_reconciliation"} else "analyze_only",
|
||||
display_timezone=timezone_name,
|
||||
analyzer_clients=client_context["clients"],
|
||||
analyzer_engagements=client_context["engagements"],
|
||||
selected_client_id=(int(client_id) if str(client_id).strip().isdigit() else None),
|
||||
selected_engagement_id=(int(engagement_id) if str(engagement_id).strip().isdigit() else None),
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user