Add Phase 11 bank to Tally with multi-bank contra intelligence
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class AccountingBankTransaction(CommonBase):
|
||||
__tablename__ = "accounting_bank_transactions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "client_id", "fingerprint", name="uq_accounting_bank_tx_fingerprint"),
|
||||
)
|
||||
|
||||
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 | None] = mapped_column(ForeignKey("bank_statement_analysis_jobs.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
fingerprint: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
|
||||
statement_id: Mapped[str] = mapped_column(String(40), nullable=False, default="")
|
||||
bank_name: Mapped[str] = mapped_column(String(160), nullable=False, default="")
|
||||
account_number: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||
customer_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
transaction_date: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True)
|
||||
value_date: Mapped[str] = mapped_column(String(20), nullable=False, default="")
|
||||
narration: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
reference_no: Mapped[str] = mapped_column(String(180), nullable=False, default="")
|
||||
transfer_reference: Mapped[str] = mapped_column(String(180), nullable=False, default="")
|
||||
direction: Mapped[str] = mapped_column(String(10), nullable=False, default="")
|
||||
debit: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
credit: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
|
||||
auto_party: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
analyzer_category: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
analyzer_nature: Mapped[str] = mapped_column(String(160), nullable=False, default="")
|
||||
analyzer_ledger: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
|
||||
contra_pair_id: Mapped[str] = mapped_column(String(60), nullable=False, default="", index=True)
|
||||
contra_counter_bank: Mapped[str] = mapped_column(String(160), nullable=False, default="")
|
||||
contra_counter_account: Mapped[str] = mapped_column(String(100), nullable=False, default="")
|
||||
contra_confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
contra_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||
suggested_nature_id: Mapped[int | None] = mapped_column(ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True)
|
||||
suggested_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
suggested_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
suggestion_reason_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
suggested_voucher_type: Mapped[str] = mapped_column(String(20), nullable=False, default="")
|
||||
|
||||
review_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", index=True)
|
||||
final_nature_id: Mapped[int | None] = mapped_column(ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True)
|
||||
final_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
final_party_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
final_bank_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
final_other_bank_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
final_voucher_type: Mapped[str] = mapped_column(String(20), nullable=False, default="")
|
||||
review_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
workstation_agent_id: Mapped[int | None] = mapped_column(ForeignKey("erp_workstation_agents.id", ondelete="SET NULL"), nullable=True)
|
||||
preflight_job_id: Mapped[int | None] = mapped_column(ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True)
|
||||
posting_job_id: Mapped[int | None] = mapped_column(ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), 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_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_started", index=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tally_voucher_id: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||
tally_voucher_number: Mapped[str] = mapped_column(String(160), nullable=False, default="")
|
||||
posted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
posted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), index=True)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
@@ -0,0 +1,412 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.modules.accounting.bank_models import AccountingBankTransaction
|
||||
from app.modules.accounting.ledger_learning_service import (
|
||||
active_natures,
|
||||
available_tally_guids,
|
||||
rank_suggestions,
|
||||
record_review,
|
||||
)
|
||||
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
|
||||
|
||||
PREFLIGHT_ACTION = "accounting_bank_posting_preflight"
|
||||
POST_ACTION = "accounting_post_bank_voucher"
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _s(value):
|
||||
if value is None:
|
||||
return ""
|
||||
try:
|
||||
if pd.isna(value):
|
||||
return ""
|
||||
except Exception:
|
||||
pass
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _f(value):
|
||||
try:
|
||||
if pd.isna(value):
|
||||
return 0.0
|
||||
return round(float(value or 0), 2)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _date(value):
|
||||
dt = pd.to_datetime(value, errors="coerce")
|
||||
return "" if pd.isna(dt) else dt.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _fingerprint(client_id, row):
|
||||
raw = "|".join([
|
||||
str(client_id),
|
||||
_s(row.get("bank_name")),
|
||||
_s(row.get("account_number")),
|
||||
_date(row.get("transaction_date")),
|
||||
"D" if _f(row.get("debit")) > 0 else "C",
|
||||
f"{max(_f(row.get('debit')), _f(row.get('credit'))):.2f}",
|
||||
_s(row.get("reference_no")),
|
||||
_s(row.get("transfer_reference")),
|
||||
_s(row.get("narration")).upper(),
|
||||
])
|
||||
return hashlib.sha256(raw.encode("utf-8", "ignore")).hexdigest()
|
||||
|
||||
|
||||
def _voucher_type(row):
|
||||
if _s(row.get("contra_pair_id")):
|
||||
return "Contra"
|
||||
return "Payment" if _f(row.get("debit")) > 0 else "Receipt"
|
||||
|
||||
|
||||
def import_completed_job(db, *, tenant_id, client_id, job_id, user_id):
|
||||
job = db.get(BankStatementAnalysisJob, job_id)
|
||||
if not job or job.status != "completed" or not job.output_file or not Path(job.output_file).is_file():
|
||||
raise ValueError("Completed Bank Analyzer workbook was not found.")
|
||||
if job.tenant_id not in (None, tenant_id):
|
||||
raise ValueError("Bank Analyzer job belongs to a different firm.")
|
||||
|
||||
frame = pd.read_excel(job.output_file, sheet_name="Transaction Classification")
|
||||
tally_options = available_tally_guids(db, tenant_id, client_id)
|
||||
tally_guid = tally_options[0][0] if len(tally_options) == 1 else ""
|
||||
|
||||
inserted = 0
|
||||
skipped = 0
|
||||
for _, row in frame.iterrows():
|
||||
fp = _fingerprint(client_id, row)
|
||||
exists = db.execute(select(AccountingBankTransaction.id).where(
|
||||
AccountingBankTransaction.tenant_id == tenant_id,
|
||||
AccountingBankTransaction.client_id == client_id,
|
||||
AccountingBankTransaction.fingerprint == fp,
|
||||
)).scalar_one_or_none()
|
||||
if exists:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
contra_pair = _s(row.get("contra_pair_id"))
|
||||
suggestions = []
|
||||
if not contra_pair:
|
||||
suggestions = rank_suggestions(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
tally_guid=tally_guid,
|
||||
supplier_name=_s(row.get("auto_party")),
|
||||
supplier_gstin="",
|
||||
hsn_code="",
|
||||
description=_s(row.get("narration")),
|
||||
amount=max(_f(row.get("debit")), _f(row.get("credit"))),
|
||||
)
|
||||
top = suggestions[0] if suggestions else None
|
||||
|
||||
tx = AccountingBankTransaction(
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
source_job_id=job.id,
|
||||
fingerprint=fp,
|
||||
statement_id=_s(row.get("statement_id")),
|
||||
bank_name=_s(row.get("bank_name")),
|
||||
account_number=_s(row.get("account_number")),
|
||||
customer_name=_s(row.get("customer_name")),
|
||||
transaction_date=_date(row.get("transaction_date")),
|
||||
value_date=_date(row.get("value_date")),
|
||||
narration=_s(row.get("narration")),
|
||||
reference_no=_s(row.get("reference_no")),
|
||||
transfer_reference=_s(row.get("transfer_reference")),
|
||||
direction=_s(row.get("direction")),
|
||||
debit=_f(row.get("debit")),
|
||||
credit=_f(row.get("credit")),
|
||||
amount=max(_f(row.get("debit")), _f(row.get("credit"))),
|
||||
auto_party=_s(row.get("auto_party")),
|
||||
analyzer_category=_s(row.get("auto_category")),
|
||||
analyzer_nature=_s(row.get("auto_nature")),
|
||||
analyzer_ledger=_s(row.get("suggested_ledger")),
|
||||
contra_pair_id=contra_pair,
|
||||
contra_counter_bank=_s(row.get("contra_counter_bank")),
|
||||
contra_counter_account=_s(row.get("contra_counter_account")),
|
||||
contra_confidence=_f(row.get("contra_confidence")),
|
||||
contra_reason=_s(row.get("contra_reason")) or None,
|
||||
tally_guid=tally_guid,
|
||||
suggested_nature_id=(top["nature"].id if top else None),
|
||||
suggested_ledger_name=(top["suggested_ledger"] if top else ""),
|
||||
suggested_confidence=(int(top["confidence"]) if top else int(_f(row.get("contra_confidence")))),
|
||||
suggestion_reason_json=json.dumps(top["reasons"] if top else ([f"Matched multi-bank contra pair {contra_pair}."] if contra_pair else []), ensure_ascii=False),
|
||||
suggested_voucher_type=_voucher_type(row),
|
||||
final_voucher_type=_voucher_type(row),
|
||||
)
|
||||
db.add(tx)
|
||||
inserted += 1
|
||||
|
||||
db.commit()
|
||||
return inserted, skipped
|
||||
|
||||
|
||||
def queue_rows(db, *, tenant_id, client_id, status="", page=1, per_page=25):
|
||||
stmt = select(AccountingBankTransaction).where(
|
||||
AccountingBankTransaction.tenant_id == tenant_id,
|
||||
AccountingBankTransaction.client_id == client_id,
|
||||
)
|
||||
count_stmt = select(func.count()).select_from(AccountingBankTransaction).where(
|
||||
AccountingBankTransaction.tenant_id == tenant_id,
|
||||
AccountingBankTransaction.client_id == client_id,
|
||||
)
|
||||
if status:
|
||||
stmt = stmt.where(AccountingBankTransaction.review_status == status)
|
||||
count_stmt = count_stmt.where(AccountingBankTransaction.review_status == status)
|
||||
total = int(db.scalar(count_stmt) or 0)
|
||||
pages = max(1, (total + per_page - 1) // per_page)
|
||||
page = max(1, min(page, pages))
|
||||
rows = list(db.execute(
|
||||
stmt.order_by(AccountingBankTransaction.transaction_date.desc(), AccountingBankTransaction.id.desc())
|
||||
.offset((page - 1) * per_page).limit(per_page)
|
||||
).scalars().all())
|
||||
return rows, total, page, pages
|
||||
|
||||
|
||||
def confirm_review(db, *, tx_id, tenant_id, client_id, nature_id, ledger_name, voucher_type, user_id, note=""):
|
||||
tx = db.get(AccountingBankTransaction, tx_id)
|
||||
if not tx or tx.tenant_id != tenant_id or tx.client_id != client_id:
|
||||
raise ValueError("Bank transaction was not found.")
|
||||
|
||||
voucher_type = (voucher_type or tx.suggested_voucher_type or "").title()
|
||||
if voucher_type not in {"Payment", "Receipt", "Contra"}:
|
||||
raise ValueError("Voucher type must be Payment, Receipt or Contra.")
|
||||
|
||||
tx.final_voucher_type = voucher_type
|
||||
tx.review_note = (note or "").strip() or None
|
||||
tx.reviewed_by_user_id = user_id
|
||||
tx.reviewed_at_utc = _utcnow()
|
||||
tx.review_status = "reviewed"
|
||||
|
||||
if voucher_type == "Contra":
|
||||
if not tx.contra_pair_id:
|
||||
raise ValueError("Contra treatment requires a matched multi-bank contra pair.")
|
||||
tx.final_nature_id = None
|
||||
tx.final_ledger_name = ""
|
||||
else:
|
||||
if not nature_id:
|
||||
raise ValueError("Select the final accounting nature.")
|
||||
tx.final_nature_id = int(nature_id)
|
||||
tx.final_ledger_name = (ledger_name or "").strip()
|
||||
record_review(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
tally_guid=tx.tally_guid or "",
|
||||
supplier_name=tx.auto_party,
|
||||
supplier_gstin="",
|
||||
hsn_code="",
|
||||
description=tx.narration,
|
||||
amount=tx.amount,
|
||||
suggested_nature_id=tx.suggested_nature_id,
|
||||
suggested_ledger_name=tx.suggested_ledger_name,
|
||||
suggested_confidence=tx.suggested_confidence,
|
||||
final_nature_id=int(nature_id),
|
||||
final_ledger_name=tx.final_ledger_name,
|
||||
user_id=user_id,
|
||||
explanation=json.loads(tx.suggestion_reason_json or "[]"),
|
||||
)
|
||||
# record_review commits; refresh before final mutation persistence
|
||||
tx = db.get(AccountingBankTransaction, tx_id)
|
||||
tx.final_voucher_type = voucher_type
|
||||
tx.review_note = (note or "").strip() or None
|
||||
tx.reviewed_by_user_id = user_id
|
||||
tx.reviewed_at_utc = _utcnow()
|
||||
tx.review_status = "reviewed"
|
||||
tx.final_nature_id = int(nature_id)
|
||||
tx.final_ledger_name = (ledger_name or "").strip()
|
||||
|
||||
db.add(tx)
|
||||
db.commit()
|
||||
return tx
|
||||
|
||||
|
||||
def visible_workstations(db, tenant_id, branch_id=None):
|
||||
stmt = select(ERPWorkstationAgent).where(
|
||||
ERPWorkstationAgent.tenant_id == tenant_id,
|
||||
ERPWorkstationAgent.is_active.is_(True),
|
||||
)
|
||||
if branch_id is not None:
|
||||
stmt = stmt.where(ERPWorkstationAgent.branch_id == branch_id)
|
||||
return list(db.execute(stmt.order_by(ERPWorkstationAgent.tally_connected.desc(), ERPWorkstationAgent.id)).scalars().all())
|
||||
|
||||
|
||||
def _ensure_reviewed(tx):
|
||||
if tx.review_status != "reviewed":
|
||||
raise ValueError("Review the bank transaction before Tally posting.")
|
||||
if tx.posting_status == "posted":
|
||||
raise ValueError("This bank transaction is already posted.")
|
||||
|
||||
|
||||
def queue_preflight(db, *, tx_id, tenant_id, client_id, workstation_id, user_id):
|
||||
tx = db.get(AccountingBankTransaction, tx_id)
|
||||
if not tx or tx.tenant_id != tenant_id or tx.client_id != client_id:
|
||||
raise ValueError("Bank transaction was not found.")
|
||||
_ensure_reviewed(tx)
|
||||
ws = db.get(ERPWorkstationAgent, int(workstation_id))
|
||||
if not ws or ws.tenant_id != tenant_id or not ws.is_active or not ws.tally_connected:
|
||||
raise ValueError("Selected workstation/Tally connection is unavailable.")
|
||||
if not tx.tally_guid:
|
||||
raise ValueError("Map the client to a Tally company before bank posting.")
|
||||
|
||||
payload = {
|
||||
"tenant_id": tenant_id,
|
||||
"client_id": client_id,
|
||||
"tally_guid": tx.tally_guid,
|
||||
"bank_name": tx.bank_name,
|
||||
"account_number": tx.account_number,
|
||||
"counter_bank": tx.contra_counter_bank,
|
||||
"counter_account": tx.contra_counter_account,
|
||||
"party_hint": tx.auto_party,
|
||||
"counter_ledger_hint": tx.final_ledger_name,
|
||||
"voucher_type": tx.final_voucher_type,
|
||||
"transaction_date": tx.transaction_date,
|
||||
"amount": tx.amount,
|
||||
"reference": tx.transfer_reference or tx.reference_no,
|
||||
"erp_bank_transaction_id": tx.id,
|
||||
}
|
||||
job = enqueue_agent_job(
|
||||
db,
|
||||
workstation_agent_id=ws.id,
|
||||
action=PREFLIGHT_ACTION,
|
||||
payload=payload,
|
||||
idempotency_key=f"bank:{client_id}:{tx.id}:preflight:{ws.id}",
|
||||
priority=9,
|
||||
max_attempts=2,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
tx.workstation_agent_id = ws.id
|
||||
tx.preflight_job_id = job.id
|
||||
tx.posting_status = "preflight_queued"
|
||||
tx.last_error = None
|
||||
db.add(tx)
|
||||
db.commit()
|
||||
return tx
|
||||
|
||||
|
||||
def _loads(value):
|
||||
try:
|
||||
return json.loads(value or "{}")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def sync_posting(db, tx):
|
||||
changed = False
|
||||
if tx.preflight_job_id and tx.posting_status.startswith("preflight"):
|
||||
job = db.get(ERPAgentJob, tx.preflight_job_id)
|
||||
if job:
|
||||
if job.status == "claimed":
|
||||
tx.posting_status = "preflight_claimed"; changed = True
|
||||
elif job.status == "succeeded":
|
||||
tx.preflight_result_json = job.result_json
|
||||
tx.posting_status = "preflight_ready"; tx.last_error = None; changed = True
|
||||
elif job.status in {"failed", "cancelled"}:
|
||||
tx.posting_status = "preflight_failed"; tx.last_error = job.last_error or job.status; changed = True
|
||||
|
||||
if tx.posting_job_id and tx.posting_status.startswith("posting"):
|
||||
job = db.get(ERPAgentJob, tx.posting_job_id)
|
||||
if job:
|
||||
if job.status == "claimed":
|
||||
tx.posting_status = "posting_claimed"; changed = True
|
||||
elif job.status == "succeeded":
|
||||
result = _loads(job.result_json)
|
||||
tally = result.get("tally_result") or result
|
||||
tx.posting_result_json = job.result_json
|
||||
tx.tally_voucher_id = str(tally.get("last_voucher_id") or tally.get("voucher_id") or "")
|
||||
tx.tally_voucher_number = str(tally.get("voucher_number") or tally.get("last_voucher_id") or "")
|
||||
tx.posting_status = "posted"
|
||||
tx.posted_at_utc = job.completed_at_utc or _utcnow()
|
||||
tx.last_error = None
|
||||
changed = True
|
||||
elif job.status == "failed":
|
||||
err = job.last_error or "Tally bank posting failed."
|
||||
tx.posting_status = "posting_indeterminate" if "Verify Tally before retrying" in err else "posting_failed"
|
||||
tx.last_error = err; changed = True
|
||||
elif job.status == "cancelled":
|
||||
tx.posting_status = "posting_failed"; tx.last_error = "Posting job cancelled."; changed = True
|
||||
if changed:
|
||||
tx.updated_at_utc = _utcnow()
|
||||
db.add(tx); db.commit()
|
||||
return tx
|
||||
|
||||
|
||||
def preflight_choices(tx):
|
||||
return _loads(tx.preflight_result_json)
|
||||
|
||||
|
||||
def queue_post(db, *, tx_id, tenant_id, client_id, bank_ledger_name, counter_ledger_name, other_bank_ledger_name, user_id):
|
||||
tx = db.get(AccountingBankTransaction, tx_id)
|
||||
if not tx or tx.tenant_id != tenant_id or tx.client_id != client_id:
|
||||
raise ValueError("Bank transaction was not found.")
|
||||
tx = sync_posting(db, tx)
|
||||
if tx.posting_status != "preflight_ready":
|
||||
raise ValueError("Successful workstation preflight is required.")
|
||||
choices = preflight_choices(tx)
|
||||
banks = {str(x.get("name") or "") for x in choices.get("bank_ledgers") or []}
|
||||
ledgers = {str(x.get("name") or "") for x in choices.get("all_ledgers") or []}
|
||||
|
||||
bank_ledger_name = (bank_ledger_name or "").strip()
|
||||
counter_ledger_name = (counter_ledger_name or "").strip()
|
||||
other_bank_ledger_name = (other_bank_ledger_name or "").strip()
|
||||
|
||||
if bank_ledger_name not in banks:
|
||||
raise ValueError("Select a bank ledger returned by the current Tally preflight.")
|
||||
if tx.final_voucher_type == "Contra":
|
||||
if other_bank_ledger_name not in banks or other_bank_ledger_name == bank_ledger_name:
|
||||
raise ValueError("Select the other bank ledger for this contra pair.")
|
||||
else:
|
||||
if counter_ledger_name not in ledgers:
|
||||
raise ValueError("Select a valid Tally counter ledger.")
|
||||
|
||||
if choices.get("duplicate_candidates"):
|
||||
raise ValueError("Possible duplicate bank voucher exists in Tally. Verify before posting.")
|
||||
|
||||
payload = {
|
||||
"tenant_id": tenant_id,
|
||||
"client_id": client_id,
|
||||
"tally_guid": tx.tally_guid,
|
||||
"erp_bank_transaction_id": tx.id,
|
||||
"voucher_type": tx.final_voucher_type,
|
||||
"transaction_date": tx.transaction_date,
|
||||
"bank_ledger_name": bank_ledger_name,
|
||||
"counter_ledger_name": counter_ledger_name,
|
||||
"other_bank_ledger_name": other_bank_ledger_name,
|
||||
"direction": tx.direction,
|
||||
"amount": tx.amount,
|
||||
"reference": tx.transfer_reference or tx.reference_no or f"ERP-BANK-{tx.id}",
|
||||
"narration": f"ERP Bank Analyzer #{tx.id}: {tx.narration}"[:1000],
|
||||
}
|
||||
ws = db.get(ERPWorkstationAgent, tx.workstation_agent_id)
|
||||
job = enqueue_agent_job(
|
||||
db,
|
||||
workstation_agent_id=ws.id,
|
||||
action=POST_ACTION,
|
||||
payload=payload,
|
||||
idempotency_key=f"bank:{client_id}:{tx.id}:post:{ws.id}",
|
||||
priority=10,
|
||||
max_attempts=1,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
tx.final_bank_ledger_name = bank_ledger_name
|
||||
tx.final_party_ledger_name = counter_ledger_name
|
||||
tx.final_other_bank_ledger_name = other_bank_ledger_name
|
||||
tx.posting_job_id = job.id
|
||||
tx.posted_by_user_id = user_id
|
||||
tx.posting_status = "posting_queued"
|
||||
db.add(tx); db.commit()
|
||||
return tx
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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_service import (
|
||||
confirm_review, import_completed_job, preflight_choices, queue_post, queue_preflight,
|
||||
queue_rows, sync_posting, visible_workstations,
|
||||
)
|
||||
from app.modules.accounting.ledger_learning_service import active_natures
|
||||
from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients
|
||||
from app.modules.bank_statement_analyzer.models import BankStatementAnalysisJob
|
||||
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
||||
from sqlalchemy import select
|
||||
|
||||
router = APIRouter(prefix="/tools/accounting/bank-posting", tags=["accounting-bank-posting-ui"])
|
||||
|
||||
|
||||
def _go(client_id=0, message="", error=""):
|
||||
q = {"client_id": client_id} if client_id else {}
|
||||
if message: q["message"] = message[:250]
|
||||
if error: q["error"] = error[:250]
|
||||
return RedirectResponse("/tools/accounting/bank-posting" + ("?" + urlencode(q) if q else ""), status_code=303)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def page(request: Request, client_id: int | None = None, status: str = "", page: int = 1, per_page: int = 25, 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)
|
||||
rows = []; total = 0; pages = 1
|
||||
choices = {}; workstations = []
|
||||
if selected:
|
||||
rows, total, page, pages = queue_rows(db, tenant_id=scope.tenant_id, client_id=selected.id, status=status, page=page, per_page=per_page)
|
||||
for row in rows:
|
||||
sync_posting(db, row)
|
||||
choices = {r.id: preflight_choices(r) for r in rows if r.preflight_result_json}
|
||||
workstations = visible_workstations(db, scope.tenant_id, getattr(user, "branch_id", None))
|
||||
jobs = list(db.execute(select(BankStatementAnalysisJob).where(
|
||||
BankStatementAnalysisJob.user_id == user.id,
|
||||
BankStatementAnalysisJob.status == "completed",
|
||||
).order_by(BankStatementAnalysisJob.completed_at_utc.desc()).limit(30)).scalars().all())
|
||||
return templates.TemplateResponse("modules/accounting/templates/accounting/bank_posting.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 to Tally", "clients": clients, "selected_client": selected,
|
||||
"rows": rows, "total": total, "page": page, "pages": pages, "per_page": per_page,
|
||||
"status_filter": status, "jobs": jobs, "natures": active_natures(db, scope.tenant_id),
|
||||
"choices": choices, "workstations": workstations, "message": message, "error": error,
|
||||
})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
def import_job(request: Request, client_id: int = Form(...), job_id: str = 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, _, scope = _find_visible_client(db, request, user, client_id)
|
||||
if not client: return _go(error="Client is not visible.")
|
||||
added, skipped = import_completed_job(db, tenant_id=scope.tenant_id, client_id=client.id, job_id=job_id, user_id=user.id)
|
||||
return _go(client.id, message=f"Imported {added} bank transaction(s); {skipped} duplicate fingerprint(s) skipped.")
|
||||
except Exception as exc:
|
||||
db.rollback(); return _go(client_id, error=str(exc))
|
||||
finally: db.close()
|
||||
|
||||
|
||||
@router.post("/{tx_id}/review")
|
||||
def review(request: Request, tx_id: int, client_id: int = Form(...), nature_id: int | None = Form(None), ledger_name: str = Form(""), voucher_type: str = Form(...), review_note: str = 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, _, scope = _find_visible_client(db, request, user, client_id)
|
||||
confirm_review(db, tx_id=tx_id, tenant_id=scope.tenant_id, client_id=client.id, nature_id=nature_id, ledger_name=ledger_name, voucher_type=voucher_type, user_id=user.id, note=review_note)
|
||||
return _go(client.id, message="Bank transaction review saved.")
|
||||
except Exception as exc:
|
||||
db.rollback(); return _go(client_id, error=str(exc))
|
||||
finally: db.close()
|
||||
|
||||
|
||||
@router.post("/{tx_id}/preflight")
|
||||
def preflight(request: Request, tx_id: int, client_id: int = 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, _, scope = _find_visible_client(db, request, user, client_id)
|
||||
queue_preflight(db, tx_id=tx_id, tenant_id=scope.tenant_id, client_id=client.id, workstation_id=workstation_id, user_id=user.id)
|
||||
return _go(client.id, message="Bank posting preflight queued.")
|
||||
except Exception as exc:
|
||||
db.rollback(); return _go(client_id, error=str(exc))
|
||||
finally: db.close()
|
||||
|
||||
|
||||
@router.post("/{tx_id}/post")
|
||||
def post(request: Request, tx_id: int, client_id: int = Form(...), bank_ledger_name: str = Form(...), counter_ledger_name: str = Form(""), other_bank_ledger_name: str = 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, _, scope = _find_visible_client(db, request, user, client_id)
|
||||
queue_post(db, tx_id=tx_id, tenant_id=scope.tenant_id, client_id=client.id, bank_ledger_name=bank_ledger_name, counter_ledger_name=counter_ledger_name, other_bank_ledger_name=other_bank_ledger_name, user_id=user.id)
|
||||
return _go(client.id, message="Controlled bank voucher job queued.")
|
||||
except Exception as exc:
|
||||
db.rollback(); return _go(client_id, error=str(exc))
|
||||
finally: db.close()
|
||||
@@ -0,0 +1,76 @@
|
||||
{% 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 Intelligence · Phase 11</p><h1 class="mt-1 text-2xl font-semibold text-slate-900">Bank → Tally Review</h1><p class="mt-1 max-w-4xl text-sm text-slate-500">Import a completed Bank Analyzer job, reuse the common ledger-learning engine for non-contra transactions, and post only reviewed Payment / Receipt / Contra vouchers through the durable Local Agent job system.</p></div>
|
||||
<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>
|
||||
</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="post" action="/tools/accounting/bank-posting/import" class="grid gap-4 md:grid-cols-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="text-sm font-medium">Client<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">Select client</option>{% for c in clients %}<option value="{{ c.id }}" {% if selected_client and c.id==selected_client.id %}selected{% endif %}>{{ c.client_name }}</option>{% endfor %}</select></label>
|
||||
<label class="text-sm font-medium">Completed Bank Analyzer job<select name="job_id" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">Select analysis</option>{% for j in jobs %}<option value="{{ j.id }}">{{ j.completed_at_utc or j.submitted_at_utc }} · {{ j.selected_bank|replace('_',' ')|title }} · {{ j.file_count }} file(s)</option>{% endfor %}</select></label>
|
||||
<div class="flex items-end"><button class="w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Import to Accounting Engine</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if selected_client %}
|
||||
<section class="rounded-2xl bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold text-slate-900">{{ selected_client.client_name }}</h2><p class="text-sm text-slate-500">{{ total }} bank transaction(s). Matched multi-bank contra rows are visibly linked by pair ID.</p></div>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{% for tx in rows %}
|
||||
{% set choice = choices.get(tx.id) %}
|
||||
<article class="p-5">
|
||||
<div class="grid gap-4 xl:grid-cols-12">
|
||||
<div class="xl:col-span-3"><div class="font-semibold">{{ tx.transaction_date }} · {{ tx.bank_name }}</div><div class="mt-1 text-xs text-slate-500">{{ tx.account_number }}</div><div class="mt-2 text-sm">{{ tx.narration }}</div><div class="mt-2 font-semibold">₹{{ '%.2f'|format(tx.amount) }} · {{ tx.direction }}</div>{% if tx.contra_pair_id %}<div class="mt-2 rounded-lg bg-indigo-50 p-2 text-xs text-indigo-800"><strong>{{ tx.contra_pair_id }}</strong> · {{ tx.contra_counter_bank }} / {{ tx.contra_counter_account }} · {{ '%.0f'|format(tx.contra_confidence) }}%</div>{% endif %}</div>
|
||||
<div class="xl:col-span-3"><div class="text-xs uppercase text-slate-500">Suggestion</div><div class="mt-1 font-semibold">{{ tx.suggested_voucher_type }}</div><div class="text-sm">{{ tx.auto_party or '—' }}</div><div class="text-sm">{{ tx.suggested_ledger_name or tx.analyzer_ledger or 'Review ledger' }}</div><div class="text-xs text-slate-500">Confidence {{ tx.suggested_confidence }}%</div></div>
|
||||
<div class="xl:col-span-6">
|
||||
{% if tx.review_status != 'reviewed' %}
|
||||
<form method="post" action="/tools/accounting/bank-posting/{{ tx.id }}/review" class="grid gap-2 md:grid-cols-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<label class="text-xs font-semibold">Voucher Type<select name="voucher_type" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">{% for v in ['Payment','Receipt','Contra'] %}<option {% if tx.suggested_voucher_type==v %}selected{% endif %}>{{ v }}</option>{% endfor %}</select></label>
|
||||
<label class="text-xs font-semibold">Nature<select name="nature_id" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2"><option value="">Not required for Contra</option>{% for n in natures %}<option value="{{ n.id }}" {% if tx.suggested_nature_id==n.id %}selected{% endif %}>{{ n.name }}</option>{% endfor %}</select></label>
|
||||
<label class="text-xs font-semibold md:col-span-2">Mapped Tally Counter Ledger<input name="ledger_name" value="{{ tx.suggested_ledger_name }}" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2" placeholder="Required for Payment / Receipt"></label>
|
||||
<label class="text-xs font-semibold md:col-span-2">Review note<input name="review_note" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2"></label>
|
||||
<button class="rounded-lg bg-slate-900 px-3 py-2 text-sm font-semibold text-white md:col-span-2">Confirm Accounting Treatment</button>
|
||||
</form>
|
||||
{% elif tx.posting_status in ['not_started','preflight_failed','posting_failed'] %}
|
||||
<form method="post" action="/tools/accounting/bank-posting/{{ tx.id }}/preflight" class="rounded-xl border border-slate-200 p-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<div class="text-sm font-semibold">Reviewed: {{ tx.final_voucher_type }}{% if tx.final_ledger_name %} · {{ tx.final_ledger_name }}{% endif %}</div>
|
||||
<select name="workstation_id" required class="mt-2 w-full rounded-lg border border-slate-300 px-2 py-2"><option value="">Select workstation with correct Tally company</option>{% for ws in workstations %}<option value="{{ ws.id }}">{{ ws.machine_name }} · {{ 'Tally connected' if ws.tally_connected else 'offline' }}</option>{% endfor %}</select>
|
||||
<button class="mt-2 rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-white">Run Tally Preflight</button>
|
||||
{% if tx.last_error %}<div class="mt-2 text-xs text-red-700">{{ tx.last_error }}</div>{% endif %}
|
||||
</form>
|
||||
{% elif tx.posting_status in ['preflight_queued','preflight_claimed','posting_queued','posting_claimed'] %}
|
||||
<div class="rounded-xl bg-blue-50 p-3 text-sm text-blue-800">{{ tx.posting_status|replace('_',' ')|title }}. Refresh the page after the workstation processes the durable job.</div>
|
||||
{% elif tx.posting_status == 'preflight_ready' and choice %}
|
||||
<form method="post" action="/tools/accounting/bank-posting/{{ tx.id }}/post" class="rounded-xl border border-emerald-200 bg-emerald-50 p-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
{% if choice.duplicate_candidates %}<div class="mb-2 text-sm font-semibold text-red-700">Possible duplicate exists in Tally. Posting is blocked until resolved.</div>{% endif %}
|
||||
<label class="text-xs font-semibold">Bank Ledger<select name="bank_ledger_name" required class="mt-1 w-full rounded-lg border border-emerald-300 bg-white px-2 py-2"><option value="">Select</option>{% for x in choice.bank_ledgers or [] %}<option value="{{ x.name }}">{{ x.name }}</option>{% endfor %}</select></label>
|
||||
{% if tx.final_voucher_type == 'Contra' %}
|
||||
<label class="mt-2 block text-xs font-semibold">Other Bank Ledger<select name="other_bank_ledger_name" required class="mt-1 w-full rounded-lg border border-emerald-300 bg-white px-2 py-2"><option value="">Select</option>{% for x in choice.bank_ledgers or [] %}<option value="{{ x.name }}">{{ x.name }}</option>{% endfor %}</select></label><input type="hidden" name="counter_ledger_name" value="">
|
||||
{% else %}
|
||||
<label class="mt-2 block text-xs font-semibold">Counter Ledger<select name="counter_ledger_name" required class="mt-1 w-full rounded-lg border border-emerald-300 bg-white px-2 py-2"><option value="">Select</option>{% for x in choice.all_ledgers or [] %}<option value="{{ x.name }}" {% if x.name==tx.final_ledger_name %}selected{% endif %}>{{ x.name }}</option>{% endfor %}</select></label><input type="hidden" name="other_bank_ledger_name" value="">
|
||||
{% endif %}
|
||||
<button class="mt-3 rounded-lg bg-emerald-700 px-3 py-2 text-sm font-semibold text-white" {% if choice.duplicate_candidates %}disabled{% endif %}>Post to Tally</button>
|
||||
</form>
|
||||
{% elif tx.posting_status == 'posted' %}
|
||||
<div class="rounded-xl bg-emerald-50 p-3 text-sm text-emerald-800"><strong>Posted.</strong> Voucher {{ tx.tally_voucher_number or tx.tally_voucher_id or 'created' }}.</div>
|
||||
{% elif tx.posting_status == 'posting_indeterminate' %}
|
||||
<div class="rounded-xl bg-red-50 p-3 text-sm text-red-800"><strong>Manual verification required — do not retry.</strong> {{ tx.last_error }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}<div class="p-10 text-center text-sm text-slate-500">No imported bank transactions.</div>{% endfor %}
|
||||
</div>
|
||||
{% if pages > 1 %}<div class="flex justify-between border-t p-4 text-sm"><span>Page {{ page }} of {{ pages }}</span><div class="flex gap-2">{% if page>1 %}<a href="?client_id={{ selected_client.id }}&page={{ page-1 }}&per_page={{ per_page }}" class="rounded border px-3 py-1">Previous</a>{% endif %}{% if page<pages %}<a href="?client_id={{ selected_client.id }}&page={{ page+1 }}&per_page={{ per_page }}" class="rounded border px-3 py-1">Next</a>{% endif %}</div></div>{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -20,6 +20,7 @@
|
||||
{% if selected_client %}<a href="/tools/accounting/purchase-enrichment?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">E-Invoice / E-Way Bill</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/purchase-review?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Purchase Review</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/purchase-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Post to Tally</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/accounting/bank-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Bank → Tally</a>{% endif %}
|
||||
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %}
|
||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,8 @@ ANALYSIS_COLUMNS = [
|
||||
"auto_category", "auto_nature", "auto_group", "matched_rule_id",
|
||||
"matched_keyword", "suggested_ledger", "rule_confidence",
|
||||
"review_required", "review_note",
|
||||
"contra_pair_id", "contra_status", "contra_counter_statement_id",
|
||||
"contra_counter_bank", "contra_counter_account", "contra_confidence", "contra_reason",
|
||||
"category", "counterparty",
|
||||
]
|
||||
|
||||
@@ -428,6 +430,186 @@ def enrich(df, classification_enabled: bool = True):
|
||||
return _ensure_analysis_columns(x)
|
||||
|
||||
|
||||
|
||||
def _contra_normalized_ref(row) -> str:
|
||||
values = [
|
||||
row.get("transfer_reference"),
|
||||
row.get("reference_no"),
|
||||
]
|
||||
for value in values:
|
||||
value = re.sub(r"[^A-Z0-9]", "", _text(value).upper())
|
||||
if len(value) >= 6:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _account_tail(value: str) -> str:
|
||||
digits = re.sub(r"\D", "", _text(value))
|
||||
return digits[-6:] if len(digits) >= 4 else digits
|
||||
|
||||
|
||||
def _contra_candidate_score(left, right) -> tuple[int, list[str]]:
|
||||
if _direction(left) == _direction(right):
|
||||
return 0, []
|
||||
left_account = _text(left.get("account_number"))
|
||||
right_account = _text(right.get("account_number"))
|
||||
if not left_account or not right_account or clean_key(left_account) == clean_key(right_account):
|
||||
return 0, []
|
||||
|
||||
left_amount = round(_amount(left), 2)
|
||||
right_amount = round(_amount(right), 2)
|
||||
if left_amount <= 0 or abs(left_amount - right_amount) > 0.01:
|
||||
return 0, []
|
||||
|
||||
left_date = pd.to_datetime(left.get("transaction_date"), errors="coerce")
|
||||
right_date = pd.to_datetime(right.get("transaction_date"), errors="coerce")
|
||||
if pd.isna(left_date) or pd.isna(right_date):
|
||||
return 0, []
|
||||
days = abs((left_date.normalize() - right_date.normalize()).days)
|
||||
if days > 2:
|
||||
return 0, []
|
||||
|
||||
score = 55
|
||||
reasons = ["equal and opposite amount across different bank accounts"]
|
||||
if days == 0:
|
||||
score += 18
|
||||
reasons.append("same transaction date")
|
||||
elif days == 1:
|
||||
score += 13
|
||||
reasons.append("one-day settlement difference")
|
||||
else:
|
||||
score += 8
|
||||
reasons.append("two-day settlement difference")
|
||||
|
||||
lref = _contra_normalized_ref(left)
|
||||
rref = _contra_normalized_ref(right)
|
||||
if lref and rref and lref == rref:
|
||||
score += 30
|
||||
reasons.append("same bank transfer reference")
|
||||
|
||||
ln = _text(left.get("narration")).upper()
|
||||
rn = _text(right.get("narration")).upper()
|
||||
own_words = ("SELF", "OWN ACCOUNT", "OWN A/C", "TRANSFER TO", "TRANSFER FROM", "INTERNAL TRANSFER")
|
||||
if any(word in ln for word in own_words) or any(word in rn for word in own_words):
|
||||
score += 10
|
||||
reasons.append("own-account transfer wording")
|
||||
|
||||
ltail = _account_tail(left_account)
|
||||
rtail = _account_tail(right_account)
|
||||
if (ltail and ltail in rn) or (rtail and rtail in ln):
|
||||
score += 18
|
||||
reasons.append("counter bank account suffix appears in narration")
|
||||
|
||||
# Without a transfer reference or direct account evidence, do not call a generic
|
||||
# same-amount movement contra merely because dates happen to align.
|
||||
strong_identity = bool((lref and rref and lref == rref) or ((ltail and ltail in rn) or (rtail and rtail in ln)))
|
||||
if not strong_identity and not any(word in ln for word in own_words) and not any(word in rn for word in own_words):
|
||||
return 0, []
|
||||
|
||||
return min(100, score), reasons
|
||||
|
||||
|
||||
def detect_interbank_contra_pairs(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Conservatively pair transfers between different uploaded accounts.
|
||||
|
||||
The function only pairs equal-and-opposite movements across distinct account
|
||||
numbers within two days and requires transfer-reference, account-suffix or
|
||||
explicit own-account wording evidence. Each transaction is used in at most
|
||||
one pair.
|
||||
"""
|
||||
x = _ensure_analysis_columns(df)
|
||||
if x.empty:
|
||||
return x
|
||||
|
||||
for col, default in (
|
||||
("contra_pair_id", ""),
|
||||
("contra_status", ""),
|
||||
("contra_counter_statement_id", ""),
|
||||
("contra_counter_bank", ""),
|
||||
("contra_counter_account", ""),
|
||||
("contra_confidence", 0.0),
|
||||
("contra_reason", ""),
|
||||
):
|
||||
if col not in x.columns:
|
||||
x[col] = default
|
||||
|
||||
candidates = []
|
||||
rows = list(x.iterrows())
|
||||
for pos, (li, left) in enumerate(rows):
|
||||
for ri, right in rows[pos + 1:]:
|
||||
score, reasons = _contra_candidate_score(left, right)
|
||||
if score >= 85:
|
||||
candidates.append((score, li, ri, reasons))
|
||||
|
||||
candidates.sort(key=lambda item: (-item[0], item[1], item[2]))
|
||||
used = set()
|
||||
pair_no = 0
|
||||
|
||||
for score, li, ri, reasons in candidates:
|
||||
if li in used or ri in used:
|
||||
continue
|
||||
used.add(li)
|
||||
used.add(ri)
|
||||
pair_no += 1
|
||||
pair_id = f"CONTRA-{pair_no:04d}"
|
||||
|
||||
for current, other in ((li, ri), (ri, li)):
|
||||
x.at[current, "contra_pair_id"] = pair_id
|
||||
x.at[current, "contra_status"] = "Matched"
|
||||
x.at[current, "contra_counter_statement_id"] = _text(x.at[other, "statement_id"])
|
||||
x.at[current, "contra_counter_bank"] = _text(x.at[other, "bank_name"])
|
||||
x.at[current, "contra_counter_account"] = _text(x.at[other, "account_number"])
|
||||
x.at[current, "contra_confidence"] = float(score)
|
||||
x.at[current, "contra_reason"] = "; ".join(reasons)
|
||||
|
||||
x.at[current, "auto_party"] = "Own Bank Transfer"
|
||||
x.at[current, "auto_category"] = "Self Transfer / Contra"
|
||||
x.at[current, "auto_nature"] = "Contra"
|
||||
x.at[current, "auto_group"] = "Contra / Balance Sheet"
|
||||
x.at[current, "suggested_ledger"] = "Other Bank Account"
|
||||
x.at[current, "rule_confidence"] = float(score)
|
||||
if score >= 95:
|
||||
x.at[current, "review_required"] = False
|
||||
x.at[current, "review_note"] = ""
|
||||
else:
|
||||
x.at[current, "review_required"] = True
|
||||
x.at[current, "review_note"] = "Probable inter-bank contra; verify both bank accounts before posting."
|
||||
|
||||
return x
|
||||
|
||||
|
||||
def interbank_contra_summary(df: pd.DataFrame) -> pd.DataFrame:
|
||||
if df is None or df.empty or "contra_pair_id" not in df.columns:
|
||||
return pd.DataFrame(columns=[
|
||||
"contra_pair_id", "confidence", "debit_bank", "debit_account",
|
||||
"credit_bank", "credit_account", "amount", "date_from", "date_to", "reason",
|
||||
])
|
||||
matched = df[df["contra_pair_id"].fillna("").ne("")].copy()
|
||||
rows = []
|
||||
for pair_id, part in matched.groupby("contra_pair_id", sort=True):
|
||||
if len(part) != 2:
|
||||
continue
|
||||
debit = part[pd.to_numeric(part["debit"], errors="coerce").fillna(0).gt(0)]
|
||||
credit = part[pd.to_numeric(part["credit"], errors="coerce").fillna(0).gt(0)]
|
||||
if debit.empty or credit.empty:
|
||||
continue
|
||||
d = debit.iloc[0]
|
||||
c = credit.iloc[0]
|
||||
dates = pd.to_datetime(part["transaction_date"], errors="coerce").dropna()
|
||||
rows.append({
|
||||
"contra_pair_id": pair_id,
|
||||
"confidence": float(part["contra_confidence"].max() or 0),
|
||||
"debit_bank": d.get("bank_name", ""),
|
||||
"debit_account": d.get("account_number", ""),
|
||||
"credit_bank": c.get("bank_name", ""),
|
||||
"credit_account": c.get("account_number", ""),
|
||||
"amount": round(float(d.get("debit") or 0), 2),
|
||||
"date_from": dates.min() if not dates.empty else None,
|
||||
"date_to": dates.max() if not dates.empty else None,
|
||||
"reason": d.get("contra_reason", ""),
|
||||
})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
def analyze_files(paths, customer_override="", account_override="", bank_hint="auto", classification_enabled=True):
|
||||
metas = []
|
||||
frames = []
|
||||
@@ -447,6 +629,7 @@ def analyze_files(paths, customer_override="", account_override="", bank_hint="a
|
||||
frames.append(df)
|
||||
combined = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
||||
all_df = _ensure_analysis_columns(enrich(combined, classification_enabled))
|
||||
all_df = detect_interbank_contra_pairs(all_df)
|
||||
|
||||
# IMPORTANT ACCOUNTING CONTROL:
|
||||
# Duplicate detection is advisory only. A bank may legitimately contain two
|
||||
@@ -598,7 +781,10 @@ def _workbook_columns(df):
|
||||
"statement_id", "transaction_date", "value_date", "narration", "transfer_bank_code", "transfer_reference",
|
||||
"transfer_comment", "auto_party", "party_match_method", "party_match_confidence",
|
||||
"auto_category", "auto_nature", "auto_group", "matched_rule_id", "matched_keyword",
|
||||
"suggested_ledger", "rule_confidence", "review_required", "mode", "direction", "debit", "credit",
|
||||
"suggested_ledger", "rule_confidence", "review_required",
|
||||
"contra_pair_id", "contra_status", "contra_counter_statement_id", "contra_counter_bank",
|
||||
"contra_counter_account", "contra_confidence", "contra_reason",
|
||||
"mode", "direction", "debit", "credit",
|
||||
"balance", "reference_no", "bank_name", "customer_name", "account_number", "source_file",
|
||||
"source_page", "parser_name", "exact_duplicate", "possible_duplicate", "duplicate_group_id",
|
||||
"duplicate_reason", "duplicate_confidence", "review_note",
|
||||
@@ -900,6 +1086,7 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b
|
||||
exact_export.to_excel(writer, sheet_name="Exact Duplicates", index=False)
|
||||
possible_export.to_excel(writer, sheet_name="Possible Duplicates", index=False)
|
||||
duplicate_summary(all_df).to_excel(writer, sheet_name="Duplicate Summary", index=False)
|
||||
interbank_contra_summary(unique_df).to_excel(writer, sheet_name="Interbank Contra Matches", index=False)
|
||||
|
||||
# Masters first so validation ranges exist.
|
||||
max_master = max(len(categories), len(parties), len(natures), len(groups), 1)
|
||||
|
||||
@@ -265,6 +265,7 @@ def _process_job(job_id: str) -> None:
|
||||
"exact_duplicate_rows": int(all_df["exact_duplicate"].fillna(False).astype(bool).sum()) if not all_df.empty and "exact_duplicate" in all_df.columns else 0,
|
||||
"possible_duplicate_rows": int(all_df["possible_duplicate"].fillna(False).astype(bool).sum()) if not all_df.empty and "possible_duplicate" in all_df.columns else 0,
|
||||
"review_items": int(unique_df.review_note.fillna("").ne("").sum()) if not unique_df.empty and "review_note" in unique_df.columns else 0,
|
||||
"contra_pairs": len({str(v) for v in unique_df["contra_pair_id"].fillna("") if str(v).strip()}) if not unique_df.empty and "contra_pair_id" in unique_df.columns else 0,
|
||||
"categories": int(unique_df.category.nunique()) if not unique_df.empty and "category" in unique_df.columns else 0,
|
||||
"banks": sorted({meta.bank_name for meta in metas}),
|
||||
"customer_name": next((meta.customer_name for meta in metas if meta.customer_name), ""),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="mx-auto max-w-6xl space-y-6">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><h1 class="text-2xl font-bold text-slate-900">Bank Statement Analyzer</h1><p class="mt-2 text-sm text-slate-600">Upload supported PDF statements. 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 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>
|
||||
<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>
|
||||
@@ -19,7 +19,7 @@
|
||||
<div><label class="mb-1 block text-sm font-semibold text-slate-700">Account number override <span class="font-normal text-slate-400">(optional)</span></label><input name="account_number" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Use only when statement extraction needs correction"></div>
|
||||
</div>
|
||||
<div class="mt-5 rounded-xl border border-slate-200 bg-slate-50 p-4"><label class="flex items-start gap-3"><input type="checkbox" name="enable_classification" value="1" {% if classification_enabled %}checked{% endif %} class="mt-1 h-4 w-4 rounded border-slate-300"><span><span class="block text-sm font-semibold text-slate-800">Enable narration-based transaction classification</span><span class="mt-1 block text-xs text-slate-500">Adds category, party, review and draft financial helper sheets. Final classification must be verified with books and supporting records.</span></span></label></div>
|
||||
<div class="mt-5"><label class="mb-1 block text-sm font-semibold text-slate-700">PDF bank statements</label><input type="file" name="statements" accept="application/pdf,.pdf" multiple required class="block w-full rounded-xl border border-slate-300 bg-white px-3 py-3 text-sm"></div>
|
||||
<div class="mt-5 rounded-xl border border-brand-200 bg-brand-50 p-4 text-sm text-slate-700"><strong>Multi-bank contra detection:</strong> when the client has more than one bank account, upload all relevant statements in the same analysis job. The analyzer pairs only conservative equal-and-opposite transfers across different account numbers and keeps every pair reviewable.</div><div class="mt-5"><label class="mb-1 block text-sm font-semibold text-slate-700">PDF bank statements</label><input type="file" name="statements" accept="application/pdf,.pdf" multiple required class="block w-full rounded-xl border border-slate-300 bg-white px-3 py-3 text-sm"></div>
|
||||
<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-600"><div class="font-semibold text-slate-800">Queue limits</div><div class="mt-1">Maximum three processing jobs across all users. Each user may have up to three queued or processing jobs. Completed workbooks and original uploaded statements remain available for 24 hours. Failed-job statements are also retained for 24 hours for debugging.</div></div>
|
||||
<div class="mt-6 flex flex-wrap gap-3"><button class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Submit Analysis</button></div>
|
||||
</form>
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Files</div><div class="mt-1 text-lg font-bold">{{ active_job.file_count }}</div></div><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Queue position</div><div id="queue-position" class="mt-1 text-lg font-bold">{{ active_job.queue_position or '—' }}</div></div><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Estimated wait</div><div id="estimated-wait" class="mt-1 text-sm font-bold">{{ active_job.estimated_wait or '—' }}</div></div><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Progress</div><div id="progress-text" class="mt-1 text-lg font-bold">{{ active_job.progress_percent }}%</div></div></div>
|
||||
<div class="mt-4 h-2 overflow-hidden rounded-full bg-slate-200"><div id="progress-bar" class="h-full bg-brand-600 transition-all" style="width: {{ active_job.progress_percent }}%"></div></div>
|
||||
{% if active_job.status == 'completed' %}
|
||||
<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="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>
|
||||
{% 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>
|
||||
|
||||
@@ -4,7 +4,7 @@ import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
ERP_LOCAL_AGENT_VERSION = "1.13.1"
|
||||
ERP_LOCAL_AGENT_VERSION = "1.14.0"
|
||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||
|
||||
@@ -35,42 +35,24 @@ def _popup(title: str, message: str, error: bool = False) -> None:
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
handle = kernel32.OpenProcess(
|
||||
PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
False,
|
||||
int(pid),
|
||||
)
|
||||
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
|
||||
if not handle:
|
||||
return False
|
||||
|
||||
try:
|
||||
exit_code = ctypes.c_ulong()
|
||||
|
||||
if not kernel32.GetExitCodeProcess(
|
||||
handle,
|
||||
ctypes.byref(exit_code),
|
||||
):
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
|
||||
@@ -15,9 +15,9 @@ If Not fso.FileExists(launcher) Then
|
||||
End If
|
||||
|
||||
If fso.FileExists(pyw) Then
|
||||
cmd = Chr(34) & pyw & Chr(34) & " " & Chr(34) & launcher & Chr(34)
|
||||
cmd = """" & pyw & """ """ & launcher & """"
|
||||
ElseIf fso.FileExists(py) Then
|
||||
cmd = Chr(34) & py & Chr(34) & " " & Chr(34) & launcher & Chr(34)
|
||||
cmd = """" & py & """ """ & launcher & """"
|
||||
Else
|
||||
MsgBox "ERP Local Agent Python environment was not found.", 16, "ERP Local Agent"
|
||||
WScript.Quit
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.13.1"
|
||||
__version__ = "1.14.0"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -43,6 +43,10 @@ class AgentCommandProcessor:
|
||||
result = self._purchase_posting_preflight(payload)
|
||||
elif action == "accounting_post_purchase_voucher":
|
||||
result = self._post_purchase_voucher(payload)
|
||||
elif action == "accounting_bank_posting_preflight":
|
||||
result = self._bank_posting_preflight(payload)
|
||||
elif action == "accounting_post_bank_voucher":
|
||||
result = self._post_bank_voucher(payload)
|
||||
elif action == "accounting_depreciation_preview":
|
||||
result = self._depreciation_preview(payload)
|
||||
elif action == "accounting_calculate_it_depreciation":
|
||||
@@ -369,6 +373,75 @@ class AgentCommandProcessor:
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
|
||||
def _bank_posting_preflight(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
company, company_name = self._resolve_open_company(payload)
|
||||
ledgers = self.tally.bank_posting_ledgers(company_name)
|
||||
duplicates = self.tally.find_bank_voucher_duplicates(
|
||||
company_name,
|
||||
voucher_date=str(payload.get("transaction_date") or ""),
|
||||
voucher_type=str(payload.get("voucher_type") or ""),
|
||||
reference=str(payload.get("reference") or ""),
|
||||
amount=float(payload.get("amount") or 0),
|
||||
)
|
||||
return {
|
||||
**ledgers,
|
||||
"duplicate_candidates": duplicates,
|
||||
"company_name": company_name,
|
||||
"company_guid": str(company.get("guid") or ""),
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
def _post_bank_voucher(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
company, company_name = self._resolve_open_company(payload)
|
||||
ledgers = self.tally.bank_posting_ledgers(company_name)
|
||||
all_names = {str(x.get("name") or "").strip().casefold() for x in ledgers.get("all_ledgers") or []}
|
||||
bank_names = {str(x.get("name") or "").strip().casefold() for x in ledgers.get("bank_ledgers") or []}
|
||||
|
||||
bank = str(payload.get("bank_ledger_name") or "").strip()
|
||||
counter = str(payload.get("counter_ledger_name") or "").strip()
|
||||
other = str(payload.get("other_bank_ledger_name") or "").strip()
|
||||
vtype = str(payload.get("voucher_type") or "").title()
|
||||
|
||||
if bank.casefold() not in bank_names:
|
||||
raise ValueError("Selected bank ledger is not present under Tally bank accounts.")
|
||||
if vtype == "Contra":
|
||||
if other.casefold() not in bank_names or other.casefold() == bank.casefold():
|
||||
raise ValueError("The other bank ledger is invalid for Contra.")
|
||||
elif counter.casefold() not in all_names:
|
||||
raise ValueError("Selected counter ledger is not present in Tally.")
|
||||
|
||||
duplicates = self.tally.find_bank_voucher_duplicates(
|
||||
company_name,
|
||||
voucher_date=str(payload.get("transaction_date") or ""),
|
||||
voucher_type=vtype,
|
||||
reference=str(payload.get("reference") or ""),
|
||||
amount=float(payload.get("amount") or 0),
|
||||
)
|
||||
if duplicates:
|
||||
raise ValueError("Possible duplicate bank voucher already exists in Tally. Posting blocked.")
|
||||
|
||||
result = self.tally.post_bank_voucher(
|
||||
company_name,
|
||||
voucher_type=vtype,
|
||||
voucher_date=str(payload.get("transaction_date") or ""),
|
||||
bank_ledger_name=bank,
|
||||
counter_ledger_name=counter,
|
||||
other_bank_ledger_name=other,
|
||||
direction=str(payload.get("direction") or ""),
|
||||
amount=float(payload.get("amount") or 0),
|
||||
narration=str(payload.get("narration") or ""),
|
||||
reference=str(payload.get("reference") or ""),
|
||||
)
|
||||
return {
|
||||
"posted": True,
|
||||
"company_name": company_name,
|
||||
"company_guid": str(company.get("guid") or ""),
|
||||
"reference": str(payload.get("reference") or ""),
|
||||
"tally_result": result,
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
def _depreciation_preview(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
client_id = int(payload.get("client_id"))
|
||||
tally_guid = str(payload.get("tally_guid") or "").strip()
|
||||
|
||||
@@ -838,6 +838,101 @@ class TallyLiveConnector:
|
||||
"request_xml": request_xml,
|
||||
}
|
||||
|
||||
|
||||
def bank_posting_ledgers(self, company_name: str) -> dict:
|
||||
rows = self.purchase_posting_ledgers(company_name)
|
||||
bank_rows = [
|
||||
row for row in rows
|
||||
if "BANK" in str(row.get("parent") or "").upper()
|
||||
or "BANK ACCOUNT" in str(row.get("parent") or "").upper()
|
||||
]
|
||||
return {"bank_ledgers": bank_rows, "all_ledgers": rows}
|
||||
|
||||
def find_bank_voucher_duplicates(self, company_name: str, *, voucher_date: str, voucher_type: str, reference: str, amount: float) -> list[dict]:
|
||||
target_ref = re.sub(r"[^A-Z0-9]", "", str(reference or "").upper())
|
||||
expected = round(abs(float(amount or 0)), 2)
|
||||
result = []
|
||||
for voucher in self.export_vouchers(company_name, voucher_date, voucher_date):
|
||||
if str(voucher.get("voucher_type_name") or "").strip().casefold() != str(voucher_type or "").strip().casefold():
|
||||
continue
|
||||
ref = re.sub(r"[^A-Z0-9]", "", str(voucher.get("reference") or "").upper())
|
||||
if target_ref and ref and ref != target_ref:
|
||||
continue
|
||||
values = [abs(float(x.get("amount") or 0)) for x in voucher.get("ledger_entries") or []]
|
||||
if expected and values and all(abs(v-expected) > max(1.0, expected*0.002) for v in values):
|
||||
continue
|
||||
result.append({
|
||||
"guid": voucher.get("guid") or "",
|
||||
"voucher_number": voucher.get("voucher_number") or "",
|
||||
"reference": voucher.get("reference") or "",
|
||||
"date": voucher.get("date") or "",
|
||||
})
|
||||
return result[:20]
|
||||
|
||||
def build_bank_voucher_xml(self, company_name: str, *, voucher_type: str, voucher_date: str, bank_ledger_name: str, counter_ledger_name: str = "", other_bank_ledger_name: str = "", direction: str = "", amount: float, narration: str = "", reference: str = "") -> str:
|
||||
vtype = str(voucher_type or "").title()
|
||||
if vtype not in {"Payment", "Receipt", "Contra"}:
|
||||
raise ValueError("Unsupported bank voucher type.")
|
||||
company = str(company_name or "").strip()
|
||||
bank = str(bank_ledger_name or "").strip()
|
||||
counter = str(counter_ledger_name or "").strip()
|
||||
other_bank = str(other_bank_ledger_name or "").strip()
|
||||
value = round(abs(float(amount or 0)), 2)
|
||||
if not company or not bank or value <= 0:
|
||||
raise ValueError("Company, bank ledger and positive amount are required.")
|
||||
if vtype == "Contra" and not other_bank:
|
||||
raise ValueError("Other bank ledger is required for Contra.")
|
||||
if vtype != "Contra" and not counter:
|
||||
raise ValueError("Counter ledger is required for Payment/Receipt.")
|
||||
|
||||
if vtype == "Payment":
|
||||
debit_ledger, credit_ledger = counter, bank
|
||||
elif vtype == "Receipt":
|
||||
debit_ledger, credit_ledger = bank, counter
|
||||
else:
|
||||
# Row direction describes movement in the uploaded bank account.
|
||||
if str(direction or "").lower() == "debit":
|
||||
debit_ledger, credit_ledger = other_bank, bank
|
||||
else:
|
||||
debit_ledger, credit_ledger = bank, other_bank
|
||||
|
||||
ref_xml = f"<REFERENCE>{self._xml_escape(reference)}</REFERENCE>" if reference else ""
|
||||
return f"""<ENVELOPE>
|
||||
<HEADER><VERSION>1</VERSION><TALLYREQUEST>Import</TALLYREQUEST><TYPE>Data</TYPE><ID>Vouchers</ID></HEADER>
|
||||
<BODY><DESC><STATICVARIABLES><SVCURRENTCOMPANY>{self._xml_escape(company)}</SVCURRENTCOMPANY></STATICVARIABLES></DESC>
|
||||
<DATA><TALLYMESSAGE xmlns:UDF="TallyUDF">
|
||||
<VOUCHER VCHTYPE="{vtype}" ACTION="Create" OBJVIEW="Accounting Voucher View">
|
||||
<DATE>{_tally_date(voucher_date)}</DATE>
|
||||
<VOUCHERTYPENAME>{vtype}</VOUCHERTYPENAME>
|
||||
<PERSISTEDVIEW>Accounting Voucher View</PERSISTEDVIEW>
|
||||
{ref_xml}
|
||||
<NARRATION>{self._xml_escape(narration)}</NARRATION>
|
||||
<ALLLEDGERENTRIES.LIST><LEDGERNAME>{self._xml_escape(debit_ledger)}</LEDGERNAME><ISDEEMEDPOSITIVE>Yes</ISDEEMEDPOSITIVE><AMOUNT>-{value:.2f}</AMOUNT></ALLLEDGERENTRIES.LIST>
|
||||
<ALLLEDGERENTRIES.LIST><LEDGERNAME>{self._xml_escape(credit_ledger)}</LEDGERNAME><ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE><AMOUNT>{value:.2f}</AMOUNT></ALLLEDGERENTRIES.LIST>
|
||||
</VOUCHER>
|
||||
</TALLYMESSAGE></DATA></BODY>
|
||||
</ENVELOPE>"""
|
||||
|
||||
def post_bank_voucher(self, company_name: str, **kwargs) -> dict:
|
||||
request_xml = self.build_bank_voucher_xml(company_name, **kwargs)
|
||||
response_xml = self._post_xml(request_xml)
|
||||
root = ET.fromstring(_clean_xml_response(response_xml).encode("utf-8"))
|
||||
def _i(tag):
|
||||
try: return int(float(_first_text(root, [tag]) or 0))
|
||||
except Exception: return 0
|
||||
errors = _i("ERRORS")
|
||||
line_error = _first_text(root, ["LINEERROR"])
|
||||
created = _i("CREATED")
|
||||
if errors or line_error:
|
||||
raise ValueError(line_error or f"Tally reported {errors} bank voucher error(s).")
|
||||
if created < 1 and _i("ALTERED") < 1:
|
||||
raise ValueError("Tally did not confirm creation of the bank voucher.")
|
||||
return {
|
||||
"created": created,
|
||||
"errors": errors,
|
||||
"last_voucher_id": str(_first_text(root, ["LASTVCHID","LASTMID","LASTVOUCHERID"]) or ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_vouchers(xml_text: str) -> list[dict]:
|
||||
cleaned = _clean_xml_response(xml_text)
|
||||
|
||||
Reference in New Issue
Block a user