Add Phase 19 bank accounting and reconciliation workflow
This commit is contained in:
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user