Add Phase 19 bank accounting and reconciliation workflow

This commit is contained in:
A R R R Associates
2026-08-24 15:23:49 +05:30
parent 67ebd587e9
commit 769add8cb8
17 changed files with 1363 additions and 10 deletions
@@ -0,0 +1,156 @@
"""Phase 19 Bank Analyzer Accounting + Reconciliation.
Revision ID: 20260824_bank_reconciliation_p19
Revises: 20260823_auto_stock_voucher_p18
"""
from alembic import op
import sqlalchemy as sa
revision = "20260824_bank_reconciliation_p19"
down_revision = "20260823_auto_stock_voucher_p18"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"bank_statement_analysis_jobs",
sa.Column("purpose", sa.String(30), nullable=False, server_default="analyze_only"),
)
op.add_column(
"bank_statement_analysis_jobs",
sa.Column("accounting_import_status", sa.String(30), nullable=False, server_default="not_requested"),
)
op.add_column(
"bank_statement_analysis_jobs",
sa.Column("accounting_import_json", sa.Text(), nullable=True),
)
op.create_index(
"ix_bank_statement_analysis_jobs_purpose",
"bank_statement_analysis_jobs",
["purpose"],
)
op.create_index(
"ix_bank_statement_analysis_jobs_accounting_import_status",
"bank_statement_analysis_jobs",
["accounting_import_status"],
)
op.create_table(
"accounting_bank_reconciliation_runs",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
sa.Column("source_job_id", sa.String(32), sa.ForeignKey("bank_statement_analysis_jobs.id", ondelete="CASCADE"), nullable=False),
sa.Column("tally_guid", sa.String(120), nullable=False),
sa.Column("company_name", sa.String(255), nullable=False, server_default=""),
sa.Column("bank_ledger_name", sa.String(255), nullable=False),
sa.Column("date_from", sa.String(10), nullable=False),
sa.Column("date_to", sa.String(10), nullable=False),
sa.Column("workstation_agent_id", sa.Integer(), sa.ForeignKey("erp_workstation_agents.id", ondelete="RESTRICT"), nullable=False),
sa.Column("agent_job_id", sa.Integer(), sa.ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True),
sa.Column("status", sa.String(30), nullable=False, server_default="queued"),
sa.Column("summary_json", sa.Text(), nullable=False, server_default="{}"),
sa.Column("last_error", sa.Text(), nullable=False, server_default=""),
sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True),
)
for name in (
"tenant_id",
"client_id",
"source_job_id",
"tally_guid",
"workstation_agent_id",
"agent_job_id",
"status",
"created_at_utc",
):
op.create_index(
f"ix_accounting_bank_reconciliation_runs_{name}",
"accounting_bank_reconciliation_runs",
[name],
)
op.add_column(
"accounting_bank_transactions",
sa.Column("reconciliation_status", sa.String(30), nullable=False, server_default="not_tested"),
)
op.add_column(
"accounting_bank_transactions",
sa.Column(
"last_reconciliation_run_id",
sa.Integer(),
sa.ForeignKey("accounting_bank_reconciliation_runs.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index(
"ix_accounting_bank_transactions_reconciliation_status",
"accounting_bank_transactions",
["reconciliation_status"],
)
op.create_index(
"ix_accounting_bank_transactions_last_reconciliation_run_id",
"accounting_bank_transactions",
["last_reconciliation_run_id"],
)
op.create_table(
"accounting_bank_reconciliation_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("run_id", sa.Integer(), sa.ForeignKey("accounting_bank_reconciliation_runs.id", ondelete="CASCADE"), nullable=False),
sa.Column("bank_transaction_id", sa.Integer(), sa.ForeignKey("accounting_bank_transactions.id", ondelete="SET NULL"), nullable=True),
sa.Column("match_status", sa.String(30), nullable=False),
sa.Column("confidence", sa.Integer(), nullable=False, server_default="0"),
sa.Column("match_reason", sa.Text(), nullable=False, server_default=""),
sa.Column("bank_date", sa.String(10), nullable=False, server_default=""),
sa.Column("bank_direction", sa.String(10), nullable=False, server_default=""),
sa.Column("bank_amount", sa.Float(), nullable=False, server_default="0"),
sa.Column("bank_reference", sa.String(180), nullable=False, server_default=""),
sa.Column("bank_narration", sa.Text(), nullable=False, server_default=""),
sa.Column("tally_guid", sa.String(120), nullable=False, server_default=""),
sa.Column("tally_voucher_number", sa.String(160), nullable=False, server_default=""),
sa.Column("tally_voucher_type", sa.String(60), nullable=False, server_default=""),
sa.Column("tally_date", sa.String(10), nullable=False, server_default=""),
sa.Column("tally_reference", sa.String(180), nullable=False, server_default=""),
sa.Column("tally_narration", sa.Text(), nullable=False, server_default=""),
sa.Column("tally_amount", sa.Float(), nullable=False, server_default="0"),
sa.Column("tally_direction", sa.String(10), nullable=False, server_default=""),
)
for name in ("run_id", "bank_transaction_id", "match_status"):
op.create_index(
f"ix_accounting_bank_reconciliation_items_{name}",
"accounting_bank_reconciliation_items",
[name],
)
def downgrade():
op.drop_table("accounting_bank_reconciliation_items")
op.drop_index(
"ix_accounting_bank_transactions_last_reconciliation_run_id",
table_name="accounting_bank_transactions",
)
op.drop_index(
"ix_accounting_bank_transactions_reconciliation_status",
table_name="accounting_bank_transactions",
)
op.drop_column("accounting_bank_transactions", "last_reconciliation_run_id")
op.drop_column("accounting_bank_transactions", "reconciliation_status")
op.drop_table("accounting_bank_reconciliation_runs")
op.drop_index(
"ix_bank_statement_analysis_jobs_accounting_import_status",
table_name="bank_statement_analysis_jobs",
)
op.drop_index(
"ix_bank_statement_analysis_jobs_purpose",
table_name="bank_statement_analysis_jobs",
)
op.drop_column("bank_statement_analysis_jobs", "accounting_import_json")
op.drop_column("bank_statement_analysis_jobs", "accounting_import_status")
op.drop_column("bank_statement_analysis_jobs", "purpose")
+10
View File
@@ -69,6 +69,16 @@ class AccountingBankTransaction(CommonBase):
preflight_result_json: Mapped[str | None] = mapped_column(Text, nullable=True) preflight_result_json: Mapped[str | None] = mapped_column(Text, nullable=True)
posting_result_json: Mapped[str | None] = mapped_column(Text, nullable=True) posting_result_json: Mapped[str | None] = mapped_column(Text, nullable=True)
posting_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_started", index=True) posting_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_started", index=True)
# Phase 19 reconciliation state. This does not alter the existing posting
# workflow; it tells Accounting whether the bank row already exists in Tally.
reconciliation_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_tested", index=True)
last_reconciliation_run_id: Mapped[int | None] = mapped_column(
ForeignKey("accounting_bank_reconciliation_runs.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True) last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
tally_voucher_id: Mapped[str] = mapped_column(String(120), nullable=False, default="") tally_voucher_id: Mapped[str] = mapped_column(String(120), nullable=False, default="")
tally_voucher_number: Mapped[str] = mapped_column(String(160), nullable=False, default="") tally_voucher_number: Mapped[str] = mapped_column(String(160), nullable=False, default="")
@@ -0,0 +1,64 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
def _utcnow():
return datetime.now(timezone.utc)
class BankReconciliationRun(CommonBase):
__tablename__ = "accounting_bank_reconciliation_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
source_job_id: Mapped[str] = mapped_column(ForeignKey("bank_statement_analysis_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
bank_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False)
date_from: Mapped[str] = mapped_column(String(10), nullable=False)
date_to: Mapped[str] = mapped_column(String(10), nullable=False)
workstation_agent_id: Mapped[int] = mapped_column(ForeignKey("erp_workstation_agents.id", ondelete="RESTRICT"), nullable=False, index=True)
agent_job_id: Mapped[int | None] = mapped_column(ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, default="queued", index=True)
summary_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
last_error: Mapped[str] = mapped_column(Text, nullable=False, default="")
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow, index=True)
completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class BankReconciliationItem(CommonBase):
__tablename__ = "accounting_bank_reconciliation_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
run_id: Mapped[int] = mapped_column(ForeignKey("accounting_bank_reconciliation_runs.id", ondelete="CASCADE"), nullable=False, index=True)
bank_transaction_id: Mapped[int | None] = mapped_column(ForeignKey("accounting_bank_transactions.id", ondelete="SET NULL"), nullable=True, index=True)
match_status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
match_reason: Mapped[str] = mapped_column(Text, nullable=False, default="")
bank_date: Mapped[str] = mapped_column(String(10), nullable=False, default="")
bank_direction: Mapped[str] = mapped_column(String(10), nullable=False, default="")
bank_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
bank_reference: Mapped[str] = mapped_column(String(180), nullable=False, default="")
bank_narration: Mapped[str] = mapped_column(Text, nullable=False, default="")
tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="")
tally_voucher_number: Mapped[str] = mapped_column(String(160), nullable=False, default="")
tally_voucher_type: Mapped[str] = mapped_column(String(60), nullable=False, default="")
tally_date: Mapped[str] = mapped_column(String(10), nullable=False, default="")
tally_reference: Mapped[str] = mapped_column(String(180), nullable=False, default="")
tally_narration: Mapped[str] = mapped_column(Text, nullable=False, default="")
tally_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
tally_direction: Mapped[str] = mapped_column(String(10), nullable=False, default="")
@@ -0,0 +1,512 @@
from __future__ import annotations
import json
import re
from datetime import date, datetime, timezone
from difflib import SequenceMatcher
from sqlalchemy import delete, func, select
from app.modules.accounting.bank_models import AccountingBankTransaction
from app.modules.accounting.bank_reconciliation_models import (
BankReconciliationItem,
BankReconciliationRun,
)
from app.modules.accounting.chart_models import AccountingChartLedger
from app.modules.accounting.chart_service import effective_role
from app.modules.bank_statement_analyzer.models import BankStatementAnalysisJob
from app.modules.documents.agent_jobs import enqueue_agent_job
from app.modules.documents.models import ERPAgentJob, ERPWorkstationAgent
ACTION = "accounting_bank_reconciliation_extract"
def _utcnow():
return datetime.now(timezone.utc)
def _s(value):
return str(value or "").strip()
def _loads(value, default=None):
try:
return json.loads(value or "")
except Exception:
return {} if default is None else default
def _norm_ref(value):
return re.sub(r"[^A-Z0-9]", "", _s(value).upper())
def _norm_text(value):
return re.sub(r"[^A-Z0-9]+", " ", _s(value).upper()).strip()
def _date_obj(value):
try:
return date.fromisoformat(_s(value)[:10])
except Exception:
return None
def visible_bank_ledgers(db, *, tenant_id: int, client_id: int, tally_guid: str):
rows = list(
db.execute(
select(AccountingChartLedger)
.where(
AccountingChartLedger.tenant_id == int(tenant_id),
AccountingChartLedger.client_id == int(client_id),
AccountingChartLedger.tally_guid == _s(tally_guid),
)
.order_by(AccountingChartLedger.name)
).scalars().all()
)
# Prefer the semantic role, but also trust Tally's immediate parent group.
# This keeps reconciliation usable even before a previously mis-resolved
# Phase 16 root hierarchy is manually refreshed.
return [
row
for row in rows
if effective_role(row) == "BANK"
or _norm_text(row.parent_group_name) == "BANK ACCOUNTS"
or _norm_text(row.root_group_name) == "BANK ACCOUNTS"
]
def visible_workstations(db, *, tenant_id: int, branch_id: int | None = None):
stmt = select(ERPWorkstationAgent).where(
ERPWorkstationAgent.tenant_id == int(tenant_id),
ERPWorkstationAgent.is_active.is_(True),
ERPWorkstationAgent.tally_connected.is_(True),
)
if branch_id is not None:
stmt = stmt.where(ERPWorkstationAgent.branch_id == int(branch_id))
return list(
db.execute(
stmt.order_by(
ERPWorkstationAgent.last_seen_at_utc.desc(),
ERPWorkstationAgent.id.desc(),
)
).scalars().all()
)
def completed_client_jobs(db, *, tenant_id: int, client_id: int, limit: int = 50):
return list(
db.execute(
select(BankStatementAnalysisJob)
.where(
BankStatementAnalysisJob.tenant_id == int(tenant_id),
BankStatementAnalysisJob.client_id == int(client_id),
BankStatementAnalysisJob.status == "completed",
BankStatementAnalysisJob.ownership_status == "confirmed",
)
.order_by(BankStatementAnalysisJob.completed_at_utc.desc())
.limit(limit)
).scalars().all()
)
def _job_period(db, *, tenant_id: int, client_id: int, job_id: str):
values = list(
db.execute(
select(AccountingBankTransaction.transaction_date)
.where(
AccountingBankTransaction.tenant_id == int(tenant_id),
AccountingBankTransaction.client_id == int(client_id),
AccountingBankTransaction.source_job_id == job_id,
)
.order_by(AccountingBankTransaction.transaction_date)
).scalars().all()
)
values = [v for v in values if _date_obj(v)]
if not values:
raise ValueError(
"No imported bank transactions were found for this Bank Analyzer job. "
"Use a Bank Reconciliation purpose job or import the completed job into Accounting first."
)
return values[0][:10], values[-1][:10]
def queue_reconciliation(
db,
*,
tenant_id: int,
client_id: int,
source_job_id: str,
tally_guid: str,
company_name: str,
bank_ledger_name: str,
workstation_id: int,
user_id: int,
):
source_job = db.get(BankStatementAnalysisJob, source_job_id)
if (
not source_job
or source_job.status != "completed"
or int(source_job.client_id or 0) != int(client_id)
or int(source_job.tenant_id or 0) != int(tenant_id)
or source_job.ownership_status != "confirmed"
):
raise ValueError("The selected completed Bank Analyzer job is not valid for this client.")
if source_job.accounting_import_status != "completed":
raise ValueError(
"The selected Bank Analyzer job has not been imported successfully into the Accounting bank queue."
)
date_from, date_to = _job_period(
db,
tenant_id=tenant_id,
client_id=client_id,
job_id=source_job_id,
)
allowed = {
row.name
for row in visible_bank_ledgers(
db,
tenant_id=tenant_id,
client_id=client_id,
tally_guid=tally_guid,
)
}
if bank_ledger_name not in allowed:
raise ValueError("Select a Bank ledger from the client's synchronized Chart of Accounts.")
ws = db.get(ERPWorkstationAgent, int(workstation_id))
if (
not ws
or not ws.is_active
or not ws.tally_connected
or int(ws.tenant_id) != int(tenant_id)
):
raise ValueError("Selected workstation is unavailable or Tally is not connected.")
run = BankReconciliationRun(
tenant_id=int(tenant_id),
client_id=int(client_id),
source_job_id=source_job_id,
tally_guid=_s(tally_guid),
company_name=_s(company_name),
bank_ledger_name=bank_ledger_name,
date_from=date_from,
date_to=date_to,
workstation_agent_id=ws.id,
status="queued",
created_by_user_id=int(user_id),
)
db.add(run)
db.commit()
db.refresh(run)
job = enqueue_agent_job(
db,
workstation_agent_id=ws.id,
action=ACTION,
payload={
"tenant_id": int(tenant_id),
"client_id": int(client_id),
"tally_guid": _s(tally_guid),
"company_name": _s(company_name),
"bank_ledger_name": bank_ledger_name,
"date_from": date_from,
"date_to": date_to,
"reconciliation_run_id": run.id,
},
idempotency_key=f"bank-recon:{tenant_id}:{client_id}:{source_job_id}:{tally_guid}:{bank_ledger_name}:{run.id}",
priority=8,
max_attempts=2,
created_by_user_id=user_id,
)
run.agent_job_id = job.id
db.add(run)
db.commit()
return run
def _tally_side(voucher: dict, bank_ledger_name: str):
bank_key = bank_ledger_name.casefold()
entries = list(voucher.get("ledger_entries") or [])
bank_entries = [
row for row in entries
if _s(row.get("ledger_name")).casefold() == bank_key
]
if not bank_entries:
return None
# Tally exports in this project use negative amount for a Debit ledger and
# positive amount for Credit. Bank statement direction is opposite from the
# bank ledger accounting side: bank Debit statement = bank ledger Credit.
amount = round(abs(float(bank_entries[0].get("amount") or 0)), 2)
signed = float(bank_entries[0].get("amount") or 0)
direction = "DEBIT" if signed > 0 else "CREDIT"
return {
"guid": _s(voucher.get("guid")),
"voucher_number": _s(voucher.get("voucher_number")),
"voucher_type": _s(voucher.get("voucher_type_name")),
"date": _s(voucher.get("date"))[:10],
"reference": _s(voucher.get("reference")),
"narration": _s(voucher.get("narration")),
"amount": amount,
"direction": direction,
}
def _pair_score(bank: AccountingBankTransaction, tally: dict):
if round(float(bank.amount or 0), 2) != round(float(tally["amount"] or 0), 2):
return 0, ""
if _s(bank.direction).upper() != _s(tally["direction"]).upper():
return 0, ""
bd = _date_obj(bank.transaction_date)
td = _date_obj(tally["date"])
if not bd or not td:
return 0, ""
gap = abs((bd - td).days)
if gap > 7:
return 0, ""
bank_ref = _norm_ref(bank.transfer_reference or bank.reference_no)
tally_ref = _norm_ref(tally.get("reference"))
ref_exact = bool(bank_ref and tally_ref and bank_ref == tally_ref)
bank_text = _norm_text(f"{bank.auto_party} {bank.narration}")
tally_text = _norm_text(tally.get("narration"))
similarity = int(round(SequenceMatcher(None, bank_text, tally_text).ratio() * 100)) if bank_text and tally_text else 0
score = 60
reasons = ["same amount", "same bank direction"]
if gap == 0:
score += 22
reasons.append("same date")
elif gap <= 2:
score += 12
reasons.append(f"{gap}-day timing difference")
elif gap <= 4:
score += 7
reasons.append(f"{gap}-day timing difference")
else:
score += 2
reasons.append(f"{gap}-day timing difference")
if ref_exact:
score += 18
reasons.append("same reference")
elif similarity >= 85:
score += 10
reasons.append("high narration similarity")
elif similarity >= 65:
score += 5
reasons.append("narration similarity")
return min(100, score), ", ".join(reasons)
def build_reconciliation(db, run: BankReconciliationRun, vouchers: list[dict]):
db.execute(delete(BankReconciliationItem).where(BankReconciliationItem.run_id == run.id))
db.commit()
bank_rows = list(
db.execute(
select(AccountingBankTransaction)
.where(
AccountingBankTransaction.tenant_id == run.tenant_id,
AccountingBankTransaction.client_id == run.client_id,
AccountingBankTransaction.source_job_id == run.source_job_id,
)
.order_by(
AccountingBankTransaction.transaction_date,
AccountingBankTransaction.id,
)
).scalars().all()
)
tally_rows = []
for voucher in vouchers:
if _s(voucher.get("is_cancelled")).lower() in {"yes", "true", "1"}:
continue
row = _tally_side(voucher, run.bank_ledger_name)
if row and row["amount"] > 0:
tally_rows.append(row)
candidates = {}
for bank in bank_rows:
scored = []
for index, tally in enumerate(tally_rows):
score, reason = _pair_score(bank, tally)
if score:
scored.append((score, index, reason))
scored.sort(key=lambda item: (-item[0], item[1]))
candidates[bank.id] = scored
used_tally = set()
exact = probable = bank_only = duplicates = 0
for bank in bank_rows:
options = [
item for item in candidates.get(bank.id, [])
if item[1] not in used_tally
]
if not options:
status = "bank_only"
confidence = 0
reason = "No Tally bank-ledger voucher matched amount, direction and permitted date window."
tally = None
bank_only += 1
else:
top_score, tally_index, reason = options[0]
tied = [item for item in options if item[0] == top_score]
if len(tied) > 1 and top_score < 95:
status = "duplicate_candidate"
confidence = top_score
tally = tally_rows[tally_index]
duplicates += 1
else:
used_tally.add(tally_index)
tally = tally_rows[tally_index]
confidence = top_score
status = "matched" if top_score >= 90 else "probable_match"
if status == "matched":
exact += 1
else:
probable += 1
item = BankReconciliationItem(
run_id=run.id,
bank_transaction_id=bank.id,
match_status=status,
confidence=int(confidence),
match_reason=reason,
bank_date=_s(bank.transaction_date)[:10],
bank_direction=_s(bank.direction).upper(),
bank_amount=float(bank.amount or 0),
bank_reference=_s(bank.transfer_reference or bank.reference_no),
bank_narration=_s(bank.narration),
)
if tally:
item.tally_guid = tally["guid"]
item.tally_voucher_number = tally["voucher_number"]
item.tally_voucher_type = tally["voucher_type"]
item.tally_date = tally["date"]
item.tally_reference = tally["reference"]
item.tally_narration = tally["narration"]
item.tally_amount = tally["amount"]
item.tally_direction = tally["direction"]
bank.reconciliation_status = status
bank.last_reconciliation_run_id = run.id
db.add(bank)
db.add(item)
books_only = 0
for index, tally in enumerate(tally_rows):
if index in used_tally:
continue
books_only += 1
db.add(
BankReconciliationItem(
run_id=run.id,
bank_transaction_id=None,
match_status="books_only",
confidence=0,
match_reason="Tally bank-ledger voucher has no matching transaction in the uploaded bank statement set.",
tally_guid=tally["guid"],
tally_voucher_number=tally["voucher_number"],
tally_voucher_type=tally["voucher_type"],
tally_date=tally["date"],
tally_reference=tally["reference"],
tally_narration=tally["narration"],
tally_amount=tally["amount"],
tally_direction=tally["direction"],
)
)
run.summary_json = json.dumps(
{
"bank_transactions": len(bank_rows),
"tally_bank_vouchers": len(tally_rows),
"matched": exact,
"probable_match": probable,
"bank_only": bank_only,
"books_only": books_only,
"duplicate_candidate": duplicates,
},
ensure_ascii=False,
)
run.status = "completed"
run.completed_at_utc = _utcnow()
run.last_error = ""
db.add(run)
db.commit()
return run
def sync_run(db, run: BankReconciliationRun):
if not run.agent_job_id or run.status == "completed":
return run
job = db.get(ERPAgentJob, int(run.agent_job_id))
if not job:
return run
if job.status in {"queued", "claimed"}:
next_status = "extracting" if job.status == "claimed" else "queued"
if run.status != next_status:
run.status = next_status
db.add(run)
db.commit()
return run
if job.status == "succeeded":
result = _loads(job.result_json, {})
vouchers = list(result.get("vouchers") or [])
return build_reconciliation(db, run, vouchers)
if job.status in {"failed", "cancelled"}:
run.status = "failed"
run.last_error = _s(job.last_error) or f"Local Agent reconciliation extraction {job.status}."
run.completed_at_utc = _utcnow()
db.add(run)
db.commit()
return run
def list_runs(db, *, tenant_id: int, client_id: int, limit: int = 30):
rows = list(
db.execute(
select(BankReconciliationRun)
.where(
BankReconciliationRun.tenant_id == int(tenant_id),
BankReconciliationRun.client_id == int(client_id),
)
.order_by(BankReconciliationRun.id.desc())
.limit(limit)
).scalars().all()
)
for row in rows:
sync_run(db, row)
return rows
def run_items(db, *, run_id: int, status: str = ""):
stmt = select(BankReconciliationItem).where(
BankReconciliationItem.run_id == int(run_id)
)
if _s(status):
stmt = stmt.where(BankReconciliationItem.match_status == _s(status))
return list(
db.execute(
stmt.order_by(
BankReconciliationItem.bank_date,
BankReconciliationItem.tally_date,
BankReconciliationItem.id,
)
).scalars().all()
)
@@ -0,0 +1,218 @@
from __future__ import annotations
import json
from urllib.parse import urlencode
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.templating import templates
from app.modules.accounting.bank_reconciliation_service import (
completed_client_jobs,
list_runs,
queue_reconciliation,
run_items,
visible_bank_ledgers,
visible_workstations,
)
from app.modules.accounting.chart_models import AccountingChartLedger
from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from sqlalchemy import select
router = APIRouter(
prefix="/tools/accounting/bank-reconciliation",
tags=["accounting-bank-reconciliation-ui"],
)
def _go(client_id=0, message="", error="", **extra):
params = {}
if client_id:
params["client_id"] = client_id
if message:
params["message"] = message[:350]
if error:
params["error"] = error[:350]
for key, value in extra.items():
if value not in ("", None):
params[key] = value
return RedirectResponse(
"/tools/accounting/bank-reconciliation"
+ ("?" + urlencode(params) if params else ""),
status_code=303,
)
def _company_options(db, tenant_id, client_id):
rows = list(
db.execute(
select(AccountingChartLedger.tally_guid, AccountingChartLedger.company_name)
.where(
AccountingChartLedger.tenant_id == int(tenant_id),
AccountingChartLedger.client_id == int(client_id),
)
.distinct()
.order_by(AccountingChartLedger.company_name)
).all()
)
return [{"guid": guid, "name": name or guid} for guid, name in rows if guid]
@router.get("")
def page(
request: Request,
client_id: int | None = None,
job_id: str = "",
tally_guid: str = "",
run_id: int | None = None,
status: str = "",
message: str = "",
error: str = "",
):
db = CommonSessionLocal()
try:
user, denied = _require_partner(request, db, "accounting.learning.view")
if denied:
return denied
clients, scope = _visible_clients(db, request, user)
selected = next(
(c for c in clients if client_id and int(c.id) == int(client_id)),
None,
)
jobs = []
companies = []
bank_ledgers = []
workstations = []
runs = []
items = []
selected_run = None
if selected:
jobs = completed_client_jobs(
db,
tenant_id=scope.tenant_id,
client_id=selected.id,
limit=50,
)
companies = _company_options(db, scope.tenant_id, selected.id)
if not tally_guid and len(companies) == 1:
tally_guid = companies[0]["guid"]
if tally_guid:
bank_ledgers = visible_bank_ledgers(
db,
tenant_id=scope.tenant_id,
client_id=selected.id,
tally_guid=tally_guid,
)
workstations = visible_workstations(
db,
tenant_id=scope.tenant_id,
branch_id=getattr(user, "branch_id", None),
)
runs = list_runs(
db,
tenant_id=scope.tenant_id,
client_id=selected.id,
limit=30,
)
if run_id:
selected_run = next(
(row for row in runs if int(row.id) == int(run_id)),
None,
)
elif runs:
selected_run = runs[0]
if selected_run and selected_run.status == "completed":
items = run_items(
db,
run_id=selected_run.id,
status=status,
)
return templates.TemplateResponse(
"modules/accounting/templates/accounting/bank_reconciliation.html",
{
"request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id),
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
"title": "Bank Reconciliation",
"clients": clients,
"selected_client": selected,
"jobs": jobs,
"selected_job_id": job_id,
"companies": companies,
"selected_tally_guid": tally_guid,
"bank_ledgers": bank_ledgers,
"workstations": workstations,
"runs": runs,
"selected_run": selected_run,
"items": items,
"status_filter": status,
"message": message,
"error": error,
},
)
finally:
db.close()
@router.post("/run")
def create_run(
request: Request,
client_id: int = Form(...),
source_job_id: str = Form(...),
tally_guid: str = Form(...),
company_name: str = Form(...),
bank_ledger_name: str = Form(...),
workstation_id: int = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, denied = _require_partner(request, db, "accounting.learning.manage")
if denied:
return denied
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
return _go(error="Client is not visible.")
run = queue_reconciliation(
db,
tenant_id=scope.tenant_id,
client_id=client.id,
source_job_id=source_job_id,
tally_guid=tally_guid,
company_name=company_name,
bank_ledger_name=bank_ledger_name,
workstation_id=workstation_id,
user_id=user.id,
)
return _go(
client.id,
run_id=run.id,
tally_guid=tally_guid,
message=(
f"Bank reconciliation run #{run.id} queued. "
"The Local Agent will read the selected Tally bank ledger; no voucher is posted."
),
)
except Exception as exc:
db.rollback()
return _go(
client_id,
tally_guid=tally_guid,
error=str(exc),
)
finally:
db.close()
+13
View File
@@ -293,6 +293,19 @@ def _ensure_reviewed(tx):
if tx.posting_status == "posted": if tx.posting_status == "posted":
raise ValueError("This bank transaction is already posted.") raise ValueError("This bank transaction is already posted.")
# Phase 19: reconciliation is a duplicate-safety control. Rows already
# matched/probably matched to Tally must not be posted as new vouchers.
if getattr(tx, "reconciliation_status", "not_tested") in {
"matched",
"probable_match",
"duplicate_candidate",
}:
raise ValueError(
"Bank Reconciliation indicates that this transaction already has, "
"or probably has, a corresponding Tally voucher. Resolve the "
"reconciliation item instead of posting a duplicate entry."
)
def queue_preflight(db, *, tx_id, tenant_id, client_id, workstation_id, user_id): def queue_preflight(db, *, tx_id, tenant_id, client_id, workstation_id, user_id):
tx = db.get(AccountingBankTransaction, tx_id) tx = db.get(AccountingBankTransaction, tx_id)
@@ -17,6 +17,7 @@
</form> </form>
</section> </section>
<div class="mb-4 flex justify-end">{% if selected_client %}<a href="/tools/accounting/bank-reconciliation?client_id={{ selected_client.id }}" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800">Open Bank Reconciliation</a>{% endif %}</div>
{% if selected_client %} {% if selected_client %}
<section class="rounded-2xl bg-white shadow-soft"> <section class="rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold text-slate-900">{{ selected_client.client_name }}</h2><p class="text-sm text-slate-500">{{ total }} bank transaction(s). Matched multi-bank contra rows are visibly linked by pair ID.</p></div> <div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold text-slate-900">{{ selected_client.client_name }}</h2><p class="text-sm text-slate-500">{{ total }} bank transaction(s). Matched multi-bank contra rows are visibly linked by pair ID.</p></div>
@@ -25,7 +26,7 @@
{% set choice = choices.get(tx.id) %} {% set choice = choices.get(tx.id) %}
<article class="p-5"> <article class="p-5">
<div class="grid gap-4 xl:grid-cols-12"> <div class="grid gap-4 xl:grid-cols-12">
<div class="xl:col-span-3"><div class="font-semibold">{{ tx.transaction_date }} · {{ tx.bank_name }}</div><div class="mt-1 text-xs text-slate-500">{{ tx.account_number }}</div><div class="mt-2 text-sm">{{ tx.narration }}</div><div class="mt-2 font-semibold">₹{{ '%.2f'|format(tx.amount) }} · {{ tx.direction }}</div>{% if tx.contra_pair_id %}<div class="mt-2 rounded-lg bg-indigo-50 p-2 text-xs text-indigo-800"><strong>{{ tx.contra_pair_id }}</strong> · {{ tx.contra_counter_bank }} / {{ tx.contra_counter_account }} · {{ '%.0f'|format(tx.contra_confidence) }}%</div>{% endif %}</div> <div class="xl:col-span-3"><div class="font-semibold">{{ tx.transaction_date }} · {{ tx.bank_name }}</div><div class="mt-1 text-xs text-slate-500">{{ tx.account_number }}</div><div class="mt-2 text-sm">{{ tx.narration }}</div><div class="mt-2 font-semibold">₹{{ '%.2f'|format(tx.amount) }} · {{ tx.direction }}</div>{% if tx.reconciliation_status and tx.reconciliation_status != 'not_tested' %}<div class="mt-2 rounded-lg border border-slate-200 bg-slate-50 p-2 text-xs"><strong>Reconciliation:</strong> {{ tx.reconciliation_status|replace('_',' ')|title }}{% if tx.last_reconciliation_run_id %} · Run #{{ tx.last_reconciliation_run_id }}{% endif %}</div>{% endif %}{% if tx.contra_pair_id %}<div class="mt-2 rounded-lg bg-indigo-50 p-2 text-xs text-indigo-800"><strong>{{ tx.contra_pair_id }}</strong> · {{ tx.contra_counter_bank }} / {{ tx.contra_counter_account }} · {{ '%.0f'|format(tx.contra_confidence) }}%</div>{% endif %}</div>
<div class="xl:col-span-3"><div class="text-xs uppercase text-slate-500">Suggestion</div><div class="mt-1 font-semibold">{{ tx.suggested_voucher_type }}</div><div class="text-sm">{{ tx.auto_party or '—' }}</div><div class="text-sm">{{ tx.suggested_ledger_name or tx.analyzer_ledger or 'Review ledger' }}</div><div class="text-xs text-slate-500">Confidence {{ tx.suggested_confidence }}%</div></div> <div class="xl:col-span-3"><div class="text-xs uppercase text-slate-500">Suggestion</div><div class="mt-1 font-semibold">{{ tx.suggested_voucher_type }}</div><div class="text-sm">{{ tx.auto_party or '—' }}</div><div class="text-sm">{{ tx.suggested_ledger_name or tx.analyzer_ledger or 'Review ledger' }}</div><div class="text-xs text-slate-500">Confidence {{ tx.suggested_confidence }}%</div></div>
<div class="xl:col-span-6"> <div class="xl:col-span-6">
{% if tx.review_status != 'reviewed' %} {% if tx.review_status != 'reviewed' %}
@@ -0,0 +1,214 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting · Bank Reconciliation</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Bank Reconciliation</h1>
<p class="mt-1 max-w-5xl text-sm text-slate-500">
Compare the normalized transactions from a completed client-bound Bank Analyzer job with one selected Tally Bank ledger. This screen is read-only against Tally; unmatched bank rows can later be handled through the existing Accounting Bank Queue.
</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/bank-statement-analyzer" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Bank Analyzer</a>
<a href="/tools/accounting/bank-posting{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Accounting Bank Queue</a>
</div>
</div>
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
{% if error %}<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<label class="text-sm font-semibold">ERP Client
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
<option value="">Select client</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if selected_client and selected_client.id==client.id %}selected{% endif %}>{{ client.client_code }} · {{ client.client_name }}</option>
{% endfor %}
</select>
</label>
<label class="text-sm font-semibold">Tally Company
<select name="tally_guid" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
<option value="">Select mapped company</option>
{% for company in companies %}
<option value="{{ company.guid }}" {% if selected_tally_guid==company.guid %}selected{% endif %}>{{ company.name }}</option>
{% endfor %}
</select>
</label>
<div class="flex items-end xl:col-span-2">
<button class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-semibold text-white">Load Reconciliation Setup</button>
</div>
</form>
</section>
{% if selected_client and selected_tally_guid %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="mb-4">
<h2 class="font-semibold">Start a reconciliation run</h2>
<p class="text-sm text-slate-500">The Local Agent only reads vouchers from the selected Tally company/date range and filters them to the selected Bank ledger. No accounting entry is posted by this action.</p>
</div>
<form method="post" action="/tools/accounting/bank-reconciliation/run" class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
<label class="text-xs font-semibold xl:col-span-2">Completed Bank Analyzer Job
<select name="source_job_id" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select job</option>
{% for job in jobs %}
<option value="{{ job.id }}" {% if selected_job_id==job.id %}selected{% endif %}>
{{ job.completed_at_utc or job.submitted_at_utc }} · {{ job.file_count }} statement(s) · {{ job.purpose|replace('_',' ')|title }} · Import {{ job.accounting_import_status|replace('_',' ')|title }}
</option>
{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Tally Company Name
<select name="company_name" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
{% for company in companies %}
{% if company.guid==selected_tally_guid %}<option value="{{ company.name }}">{{ company.name }}</option>{% endif %}
{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Tally Bank Ledger
<select name="bank_ledger_name" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select bank ledger</option>
{% for ledger in bank_ledgers %}<option value="{{ ledger.name }}">{{ ledger.name }}</option>{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Workstation
<select name="workstation_id" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select connected workstation</option>
{% for ws in workstations %}<option value="{{ ws.id }}">{{ ws.machine_name }} · Agent {{ ws.agent_version or '-' }}</option>{% endfor %}
</select>
</label>
<div class="xl:col-span-5 flex justify-end">
<button class="rounded-xl bg-emerald-700 px-5 py-2 text-sm font-semibold text-white">Read Tally & Reconcile</button>
</div>
</form>
</section>
{% endif %}
{% if selected_client %}
<section class="rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4">
<h2 class="font-semibold">Recent Reconciliation Runs</h2>
<p class="text-sm text-slate-500">Runs preserve their results and never modify Tally.</p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead><tr class="bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-4 py-3">Run</th><th class="px-4 py-3">Bank Ledger</th><th class="px-4 py-3">Period</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Result</th><th class="px-4 py-3"></th></tr></thead>
<tbody>
{% for run in runs %}
{% set summary = run.summary_json|default('{}') %}
<tr class="border-t border-slate-100">
<td class="px-4 py-3 font-semibold">#{{ run.id }}</td>
<td class="px-4 py-3">{{ run.bank_ledger_name }}</td>
<td class="px-4 py-3">{{ run.date_from }} → {{ run.date_to }}</td>
<td class="px-4 py-3">{{ run.status|replace('_',' ')|title }}{% if run.last_error %}<div class="text-xs text-red-700">{{ run.last_error }}</div>{% endif %}</td>
<td class="px-4 py-3 text-xs">
{% if run.status=='completed' %}
{% set s = run.summary_json|from_json if false else none %}
<span class="text-slate-500">Open run to view matched / probable / bank-only / books-only detail.</span>
{% else %}—{% endif %}
</td>
<td class="px-4 py-3 text-right"><a href="?client_id={{ selected_client.id }}&tally_guid={{ run.tally_guid }}&run_id={{ run.id }}" class="font-semibold text-brand-700 hover:underline">Open</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No reconciliation runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
{% if selected_run %}
<section class="rounded-2xl bg-white shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-slate-200 px-5 py-4">
<div>
<h2 class="font-semibold">Run #{{ selected_run.id }} · {{ selected_run.bank_ledger_name }}</h2>
<p class="text-sm text-slate-500">{{ selected_run.date_from }} → {{ selected_run.date_to }} · Status {{ selected_run.status|replace('_',' ')|title }}</p>
</div>
{% if selected_run.status=='completed' %}
<form method="get" class="flex gap-2">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_run.tally_guid }}">
<input type="hidden" name="run_id" value="{{ selected_run.id }}">
<select name="status" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">All results</option>
{% for value,label in [('matched','Matched'),('probable_match','Probable Match'),('bank_only','Bank Only'),('books_only','Books Only'),('duplicate_candidate','Duplicate Candidate')] %}
<option value="{{ value }}" {% if status_filter==value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Filter</button>
</form>
{% endif %}
</div>
{% if selected_run.status in ['queued','extracting'] %}
<div class="p-8 text-center text-blue-700">The connected Local Agent is reading Tally vouchers. Refresh this page shortly.</div>
{% elif selected_run.status=='failed' %}
<div class="p-8 text-center text-red-700">{{ selected_run.last_error }}</div>
{% elif selected_run.status=='completed' %}
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead><tr class="bg-slate-50 text-left uppercase text-slate-500"><th class="px-3 py-2">Result</th><th class="px-3 py-2">Bank Statement</th><th class="px-3 py-2">Tally</th><th class="px-3 py-2">Why</th><th class="px-3 py-2">Action</th></tr></thead>
<tbody>
{% for item in items %}
<tr class="border-t border-slate-100 align-top">
<td class="px-3 py-3">
<span class="rounded-full px-2 py-1 font-semibold
{% if item.match_status=='matched' %}bg-emerald-100 text-emerald-800
{% elif item.match_status=='probable_match' %}bg-amber-100 text-amber-800
{% elif item.match_status=='bank_only' %}bg-blue-100 text-blue-800
{% elif item.match_status=='books_only' %}bg-violet-100 text-violet-800
{% else %}bg-red-100 text-red-800{% endif %}">
{{ item.match_status|replace('_',' ')|title }}
</span>
{% if item.confidence %}<div class="mt-1 text-slate-500">{{ item.confidence }}%</div>{% endif %}
</td>
<td class="px-3 py-3">
{% if item.bank_transaction_id %}
<div class="font-semibold">{{ item.bank_date }} · {{ item.bank_direction }} · ₹{{ '%.2f'|format(item.bank_amount) }}</div>
<div class="mt-1">{{ item.bank_reference or '-' }}</div>
<div class="mt-1 max-w-md text-slate-500">{{ item.bank_narration }}</div>
{% else %}—{% endif %}
</td>
<td class="px-3 py-3">
{% if item.tally_voucher_number or item.tally_guid %}
<div class="font-semibold">{{ item.tally_date }} · {{ item.tally_voucher_type }} · ₹{{ '%.2f'|format(item.tally_amount) }}</div>
<div class="mt-1">Voucher {{ item.tally_voucher_number or item.tally_guid }}</div>
<div class="mt-1">{{ item.tally_reference or '-' }}</div>
<div class="mt-1 max-w-md text-slate-500">{{ item.tally_narration }}</div>
{% else %}—{% endif %}
</td>
<td class="px-3 py-3 max-w-xs text-slate-600">{{ item.match_reason }}</td>
<td class="px-3 py-3">
{% if item.match_status=='bank_only' and item.bank_transaction_id %}
<a href="/tools/accounting/bank-posting?client_id={{ selected_client.id }}" class="font-semibold text-blue-700 hover:underline">Review in Accounting Queue</a>
{% elif item.match_status=='books_only' %}
<span class="text-slate-500">Investigate timing / statement coverage.</span>
{% else %}
<span class="text-slate-500">Review if needed.</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No reconciliation rows for this filter.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</section>
{% endif %}
</div>
{% endblock %}
@@ -41,6 +41,7 @@
<a href="/tools/accounting/purchase-review{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Purchase Review</a> <a href="/tools/accounting/purchase-review{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Purchase Review</a>
<a href="/tools/accounting/purchase-posting{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Purchase → Tally</a> <a href="/tools/accounting/purchase-posting{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Purchase → Tally</a>
<a href="/tools/accounting/bank-posting{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Bank Expenses / Payments</a> <a href="/tools/accounting/bank-posting{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Bank Expenses / Payments</a>
<a href="/tools/accounting/bank-reconciliation{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm font-semibold text-emerald-800">Bank Reconciliation</a>
<a href="/tools/tally/depreciation{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Depreciation (IT)</a> <a href="/tools/tally/depreciation{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Depreciation (IT)</a>
<a href="/tools/accounting/ai{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-800">Accounting AI</a> <a href="/tools/accounting/ai{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-800">Accounting AI</a>
<a href="/tools/accounting/internal-model" class="rounded-xl border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">Internal Model</a> <a href="/tools/accounting/internal-model" class="rounded-xl border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">Internal Model</a>
@@ -33,6 +33,12 @@ class BankStatementAnalysisJob(CommonBase):
account_override: Mapped[str | None] = mapped_column(String(100), nullable=True) account_override: Mapped[str | None] = mapped_column(String(100), nullable=True)
classification_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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) status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True)
progress_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=0) progress_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
file_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) file_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+50 -1
View File
@@ -162,7 +162,7 @@ def pending_count_for_user(user_id: int) -> int:
db.close() 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: if pending_count_for_user(int(user.id)) >= MAX_PENDING_PER_USER:
shutil.rmtree(job_dir, ignore_errors=True) 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.") 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, customer_override=(customer_override or "").strip() or None,
account_override=(account_override or "").strip() or None, account_override=(account_override or "").strip() or None,
classification_enabled=bool(classification_enabled), 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", status="queued",
progress_percent=0, progress_percent=0,
file_count=len(paths), file_count=len(paths),
@@ -333,6 +339,46 @@ def _process_job(job_id: str) -> None:
job.completed_at_utc = _now() job.completed_at_utc = _now()
job.expires_at_utc = _now() + timedelta(hours=COMPLETED_RETENTION_HOURS) job.expires_at_utc = _now() + timedelta(hours=COMPLETED_RETENTION_HOURS)
db.commit() 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: except Exception as exc:
db.rollback() db.rollback()
job = db.get(BankStatementAnalysisJob, job_id) job = db.get(BankStatementAnalysisJob, job_id)
@@ -481,6 +527,9 @@ def job_view(job: BankStatementAnalysisJob) -> dict:
"file_count": job.file_count, "file_count": job.file_count,
"selected_bank": job.selected_bank, "selected_bank": job.selected_bank,
"financial_year": job.financial_year or "", "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, "submitted_at": job.submitted_at_utc,
"completed_at": job.completed_at_utc, "completed_at": job.completed_at_utc,
"expires_at": job.expires_at_utc, "expires_at": job.expires_at_utc,
@@ -3,7 +3,7 @@
<div class="mx-auto max-w-6xl space-y-6"> <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="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-3"> <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> <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>
</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"> <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 }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="grid gap-5 md:grid-cols-2"> <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 %} {% if analyzer_clients %}
<div class="md:col-span-2 rounded-xl border border-indigo-200 bg-indigo-50 p-4"> <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"> <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> <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> {% 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 %} {% 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 %} {% 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 %} {% 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 %}
+32 -2
View File
@@ -86,6 +86,7 @@ def _ctx(request, db, user, **extra):
"selected_bank": "auto", "selected_bank": "auto",
"financial_year": "", "financial_year": "",
"classification_enabled": True, "classification_enabled": True,
"purpose": "analyze_only",
"analyzer_clients": [], "analyzer_clients": [],
"analyzer_engagements": [], "analyzer_engagements": [],
"selected_client_id": None, "selected_client_id": None,
@@ -137,7 +138,7 @@ def index(request: Request, job: str | None = None):
@router.post("/analyze") @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() db = CommonSessionLocal()
selected_bank = bank_selection if bank_selection in dict(BANK_OPTIONS) else "auto" selected_bank = bank_selection if bank_selection in dict(BANK_OPTIONS) else "auto"
classification_enabled = enable_classification == "1" classification_enabled = enable_classification == "1"
@@ -147,8 +148,16 @@ async def analyze(request: Request, csrf_token: str = Form(...), bank_selection:
if denied: if denied:
return denied return denied
validate_csrf(request, csrf_token) 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_client_id = int(client_id) if client_id.strip() else None
resolved_engagement_id = int(engagement_id) if engagement_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( validate_selected_client_and_engagement(
db, db,
request=request, request=request,
@@ -175,6 +184,7 @@ async def analyze(request: Request, csrf_token: str = Form(...), bank_selection:
client_id=resolved_client_id, client_id=resolved_client_id,
engagement_id=resolved_engagement_id, engagement_id=resolved_engagement_id,
ownership_confirmation=(confirm_same_client == "1"), ownership_confirmation=(confirm_same_client == "1"),
purpose=purpose,
) )
return RedirectResponse(f"/tools/bank-statement-analyzer?job={job_id}#analysis-status", status_code=303) return RedirectResponse(f"/tools/bank-statement-analyzer?job={job_id}#analysis-status", status_code=303)
except Exception as exc: 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) 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)] 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)) 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: finally:
db.close() db.close()
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.18.0" ERP_LOCAL_AGENT_VERSION = "1.19.0"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -1,2 +1,2 @@
__version__ = "1.18.0" __version__ = "1.19.0"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -48,6 +48,8 @@ class AgentCommandProcessor:
result = self._post_purchase_voucher(payload) result = self._post_purchase_voucher(payload)
elif action == "accounting_bank_posting_preflight": elif action == "accounting_bank_posting_preflight":
result = self._bank_posting_preflight(payload) result = self._bank_posting_preflight(payload)
elif action == "accounting_bank_reconciliation_extract":
result = self._bank_reconciliation_extract(payload)
elif action == "accounting_post_bank_voucher": elif action == "accounting_post_bank_voucher":
result = self._post_bank_voucher(payload) result = self._post_bank_voucher(payload)
elif action == "accounting_native_voucher_preflight": elif action == "accounting_native_voucher_preflight":
@@ -83,7 +85,7 @@ class AgentCommandProcessor:
return { return {
"name": "ERP Local Agent", "version": __version__, "name": "ERP Local Agent", "version": __version__,
"tally_capability": True, "accounting_act_capability": True, "tally_capability": True, "accounting_act_capability": True,
"tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True, "tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True, "bank_reconciliation_read_capability": True,
"tally_transaction_sync_capability": True, "tally_transaction_sync_capability": True,
"historical_learning_read_capability": True, "historical_learning_read_capability": True,
"purchase_posting_preflight_capability": True, "purchase_posting_preflight_capability": True,
@@ -492,6 +494,57 @@ class AgentCommandProcessor:
} }
def _bank_reconciliation_extract(self, payload: dict[str, Any]) -> dict[str, Any]:
company, company_name = self._resolve_open_company(payload)
bank_ledger_name = str(payload.get("bank_ledger_name") or "").strip()
if not bank_ledger_name:
raise ValueError("Bank ledger is required for reconciliation.")
ledgers = self.tally.bank_posting_ledgers(company_name)
bank_names = {
str(row.get("name") or "").strip().casefold()
for row in (ledgers.get("bank_ledgers") or [])
if str(row.get("name") or "").strip()
}
if bank_ledger_name.casefold() not in bank_names:
raise ValueError(
f"'{bank_ledger_name}' is not a Tally Bank Accounts ledger in the open company."
)
date_from = str(payload.get("date_from") or "").strip()
date_to = str(payload.get("date_to") or "").strip()
vouchers = self.tally.export_vouchers(company_name, date_from, date_to)
filtered = []
target = bank_ledger_name.casefold()
for voucher in vouchers:
entries = list(voucher.get("ledger_entries") or [])
if not any(
str(entry.get("ledger_name") or "").strip().casefold() == target
for entry in entries
):
continue
filtered.append(
{
key: value
for key, value in voucher.items()
if key != "raw_xml"
}
)
return {
"company_name": company_name,
"company_guid": str(company.get("guid") or ""),
"bank_ledger_name": bank_ledger_name,
"date_from": date_from,
"date_to": date_to,
"voucher_count": len(filtered),
"vouchers": filtered,
"read_only": True,
"agent": self._agent_info(),
}
def _bank_posting_preflight(self, payload: dict[str, Any]) -> dict[str, Any]: def _bank_posting_preflight(self, payload: dict[str, Any]) -> dict[str, Any]:
company, company_name = self._resolve_open_company(payload) company, company_name = self._resolve_open_company(payload)
ledgers = self.tally.bank_posting_ledgers(company_name) ledgers = self.tally.bank_posting_ledgers(company_name)
+2
View File
@@ -45,6 +45,7 @@ from app.modules.accounting.purchase_enrichment_ui import router as accounting_p
from app.modules.accounting.purchase_review_ui import router as accounting_purchase_review_ui_router from app.modules.accounting.purchase_review_ui import router as accounting_purchase_review_ui_router
from app.modules.accounting.purchase_posting_ui import router as accounting_purchase_posting_ui_router from app.modules.accounting.purchase_posting_ui import router as accounting_purchase_posting_ui_router
from app.modules.accounting.bank_ui import router as accounting_bank_ui_router from app.modules.accounting.bank_ui import router as accounting_bank_ui_router
from app.modules.accounting.bank_reconciliation_ui import router as accounting_bank_reconciliation_ui_router
from app.modules.accounting.ai_ui import router as accounting_ai_ui_router from app.modules.accounting.ai_ui import router as accounting_ai_ui_router
from app.modules.accounting.internal_model_ui import router as accounting_internal_model_ui_router from app.modules.accounting.internal_model_ui import router as accounting_internal_model_ui_router
from app.modules.accounting.sales_ui import router as accounting_sales_income_ui_router from app.modules.accounting.sales_ui import router as accounting_sales_income_ui_router
@@ -84,6 +85,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(accounting_purchase_review_ui_router) app.include_router(accounting_purchase_review_ui_router)
app.include_router(accounting_purchase_posting_ui_router) app.include_router(accounting_purchase_posting_ui_router)
app.include_router(accounting_bank_ui_router) app.include_router(accounting_bank_ui_router)
app.include_router(accounting_bank_reconciliation_ui_router)
app.include_router(accounting_ai_ui_router) app.include_router(accounting_ai_ui_router)
app.include_router(accounting_internal_model_ui_router) app.include_router(accounting_internal_model_ui_router)
app.include_router(accounting_sales_income_ui_router) app.include_router(accounting_sales_income_ui_router)