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
+10
View File
@@ -69,6 +69,16 @@ class AccountingBankTransaction(CommonBase):
preflight_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)
# 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)
tally_voucher_id: Mapped[str] = mapped_column(String(120), 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":
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):
tx = db.get(AccountingBankTransaction, tx_id)
@@ -17,7 +17,8 @@
</form>
</section>
{% if selected_client %}
<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 %}
<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="divide-y divide-slate-100">
@@ -25,7 +26,7 @@
{% set choice = choices.get(tx.id) %}
<article class="p-5">
<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-6">
{% 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-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-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/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>