Add Phase 20 stored bank reuse and richer reconciliation
This commit is contained in:
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
@@ -22,8 +22,10 @@ class BankReconciliationRun(CommonBase):
|
||||
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)
|
||||
account_number: Mapped[str] = mapped_column(String(100), nullable=False, default="", index=True)
|
||||
date_from: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
date_to: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
date_tolerance_days: Mapped[int] = mapped_column(Integer, nullable=False, default=15)
|
||||
|
||||
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)
|
||||
@@ -62,3 +64,34 @@ class BankReconciliationItem(CommonBase):
|
||||
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="")
|
||||
|
||||
resolution_status: Mapped[str] = mapped_column(String(30), nullable=False, default="unresolved", index=True)
|
||||
resolution_note: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
resolved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
resolved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class AccountingBankLedgerMapping(CommonBase):
|
||||
__tablename__ = "accounting_bank_ledger_mappings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"client_id",
|
||||
"account_number",
|
||||
"tally_guid",
|
||||
name="uq_accounting_bank_ledger_mapping",
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
account_number: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
bank_name: Mapped[str] = mapped_column(String(160), nullable=False, default="")
|
||||
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)
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
updated_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)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow)
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import delete, func, select
|
||||
|
||||
from app.modules.accounting.bank_models import AccountingBankTransaction
|
||||
from app.modules.accounting.bank_reconciliation_models import (
|
||||
AccountingBankLedgerMapping,
|
||||
BankReconciliationItem,
|
||||
BankReconciliationRun,
|
||||
)
|
||||
@@ -109,22 +110,131 @@ def completed_client_jobs(db, *, tenant_id: int, client_id: int, limit: int = 50
|
||||
)
|
||||
|
||||
|
||||
def _job_period(db, *, tenant_id: int, client_id: int, job_id: str):
|
||||
values = list(
|
||||
def source_accounts(db, *, tenant_id: int, client_id: int, job_id: str):
|
||||
rows = list(
|
||||
db.execute(
|
||||
select(AccountingBankTransaction.transaction_date)
|
||||
select(
|
||||
AccountingBankTransaction.account_number,
|
||||
AccountingBankTransaction.bank_name,
|
||||
func.count(AccountingBankTransaction.id),
|
||||
)
|
||||
.where(
|
||||
AccountingBankTransaction.tenant_id == int(tenant_id),
|
||||
AccountingBankTransaction.client_id == int(client_id),
|
||||
AccountingBankTransaction.source_job_id == job_id,
|
||||
)
|
||||
.order_by(AccountingBankTransaction.transaction_date)
|
||||
.group_by(
|
||||
AccountingBankTransaction.account_number,
|
||||
AccountingBankTransaction.bank_name,
|
||||
)
|
||||
.order_by(
|
||||
AccountingBankTransaction.bank_name,
|
||||
AccountingBankTransaction.account_number,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"account_number": _s(account),
|
||||
"bank_name": _s(bank),
|
||||
"transaction_count": int(count or 0),
|
||||
}
|
||||
for account, bank, count in rows
|
||||
if _s(account)
|
||||
]
|
||||
|
||||
|
||||
def list_bank_mappings(db, *, tenant_id: int, client_id: int, tally_guid: str = ""):
|
||||
stmt = select(AccountingBankLedgerMapping).where(
|
||||
AccountingBankLedgerMapping.tenant_id == int(tenant_id),
|
||||
AccountingBankLedgerMapping.client_id == int(client_id),
|
||||
)
|
||||
if _s(tally_guid):
|
||||
stmt = stmt.where(AccountingBankLedgerMapping.tally_guid == _s(tally_guid))
|
||||
return list(
|
||||
db.execute(
|
||||
stmt.order_by(
|
||||
AccountingBankLedgerMapping.bank_name,
|
||||
AccountingBankLedgerMapping.account_number,
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
|
||||
def save_bank_mapping(
|
||||
db,
|
||||
*,
|
||||
tenant_id: int,
|
||||
client_id: int,
|
||||
account_number: str,
|
||||
bank_name: str,
|
||||
tally_guid: str,
|
||||
company_name: str,
|
||||
bank_ledger_name: str,
|
||||
user_id: int,
|
||||
):
|
||||
account_number = _s(account_number)
|
||||
if not account_number:
|
||||
raise ValueError("Bank account number is required.")
|
||||
|
||||
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 valid Tally Bank ledger from Chart of Accounts.")
|
||||
|
||||
mapping = db.execute(
|
||||
select(AccountingBankLedgerMapping).where(
|
||||
AccountingBankLedgerMapping.tenant_id == int(tenant_id),
|
||||
AccountingBankLedgerMapping.client_id == int(client_id),
|
||||
AccountingBankLedgerMapping.account_number == account_number,
|
||||
AccountingBankLedgerMapping.tally_guid == _s(tally_guid),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if mapping is None:
|
||||
mapping = AccountingBankLedgerMapping(
|
||||
tenant_id=int(tenant_id),
|
||||
client_id=int(client_id),
|
||||
account_number=account_number,
|
||||
tally_guid=_s(tally_guid),
|
||||
created_by_user_id=int(user_id),
|
||||
)
|
||||
|
||||
mapping.bank_name = _s(bank_name)
|
||||
mapping.company_name = _s(company_name)
|
||||
mapping.bank_ledger_name = _s(bank_ledger_name)
|
||||
mapping.updated_by_user_id = int(user_id)
|
||||
mapping.updated_at_utc = _utcnow()
|
||||
db.add(mapping)
|
||||
db.commit()
|
||||
db.refresh(mapping)
|
||||
return mapping
|
||||
|
||||
|
||||
def _job_period(db, *, tenant_id: int, client_id: int, job_id: str, account_number: str = ""):
|
||||
stmt = select(AccountingBankTransaction.transaction_date).where(
|
||||
AccountingBankTransaction.tenant_id == int(tenant_id),
|
||||
AccountingBankTransaction.client_id == int(client_id),
|
||||
AccountingBankTransaction.source_job_id == job_id,
|
||||
)
|
||||
if _s(account_number):
|
||||
stmt = stmt.where(AccountingBankTransaction.account_number == _s(account_number))
|
||||
values = list(
|
||||
db.execute(
|
||||
stmt.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. "
|
||||
"No imported bank transactions were found for this Bank Analyzer job/account. "
|
||||
"Use a Bank Reconciliation purpose job or import the completed job into Accounting first."
|
||||
)
|
||||
return values[0][:10], values[-1][:10]
|
||||
@@ -141,6 +251,8 @@ def queue_reconciliation(
|
||||
bank_ledger_name: str,
|
||||
workstation_id: int,
|
||||
user_id: int,
|
||||
account_number: str = "",
|
||||
date_tolerance_days: int = 15,
|
||||
):
|
||||
source_job = db.get(BankStatementAnalysisJob, source_job_id)
|
||||
if (
|
||||
@@ -157,11 +269,15 @@ def queue_reconciliation(
|
||||
"The selected Bank Analyzer job has not been imported successfully into the Accounting bank queue."
|
||||
)
|
||||
|
||||
account_number = _s(account_number)
|
||||
date_tolerance_days = max(0, min(90, int(date_tolerance_days or 15)))
|
||||
|
||||
date_from, date_to = _job_period(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
job_id=source_job_id,
|
||||
account_number=account_number,
|
||||
)
|
||||
|
||||
allowed = {
|
||||
@@ -192,8 +308,10 @@ def queue_reconciliation(
|
||||
tally_guid=_s(tally_guid),
|
||||
company_name=_s(company_name),
|
||||
bank_ledger_name=bank_ledger_name,
|
||||
account_number=account_number,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
date_tolerance_days=date_tolerance_days,
|
||||
workstation_agent_id=ws.id,
|
||||
status="queued",
|
||||
created_by_user_id=int(user_id),
|
||||
@@ -212,11 +330,12 @@ def queue_reconciliation(
|
||||
"tally_guid": _s(tally_guid),
|
||||
"company_name": _s(company_name),
|
||||
"bank_ledger_name": bank_ledger_name,
|
||||
"account_number": account_number,
|
||||
"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}",
|
||||
idempotency_key=f"bank-recon:{tenant_id}:{client_id}:{source_job_id}:{account_number}:{tally_guid}:{bank_ledger_name}:{run.id}",
|
||||
priority=8,
|
||||
max_attempts=2,
|
||||
created_by_user_id=user_id,
|
||||
@@ -227,6 +346,74 @@ def queue_reconciliation(
|
||||
return run
|
||||
|
||||
|
||||
def queue_all_mapped_reconciliations(
|
||||
db,
|
||||
*,
|
||||
tenant_id: int,
|
||||
client_id: int,
|
||||
source_job_id: str,
|
||||
tally_guid: str,
|
||||
company_name: str,
|
||||
workstation_id: int,
|
||||
user_id: int,
|
||||
date_tolerance_days: int = 15,
|
||||
):
|
||||
accounts = source_accounts(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
job_id=source_job_id,
|
||||
)
|
||||
if not accounts:
|
||||
raise ValueError("No bank accounts were imported from the selected Bank Analyzer job.")
|
||||
|
||||
mappings = {
|
||||
row.account_number: row
|
||||
for row in list_bank_mappings(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
tally_guid=tally_guid,
|
||||
)
|
||||
}
|
||||
|
||||
missing = [
|
||||
row
|
||||
for row in accounts
|
||||
if row["account_number"] not in mappings
|
||||
]
|
||||
if missing:
|
||||
labels = ", ".join(
|
||||
f"{row['bank_name']} {row['account_number']}"
|
||||
for row in missing
|
||||
)
|
||||
raise ValueError(
|
||||
"Save Tally bank-ledger mapping for every bank account before "
|
||||
f"running all accounts together. Missing: {labels}"
|
||||
)
|
||||
|
||||
runs = []
|
||||
for account in accounts:
|
||||
mapping = mappings[account["account_number"]]
|
||||
runs.append(
|
||||
queue_reconciliation(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
source_job_id=source_job_id,
|
||||
tally_guid=tally_guid,
|
||||
company_name=company_name,
|
||||
bank_ledger_name=mapping.bank_ledger_name,
|
||||
workstation_id=workstation_id,
|
||||
user_id=user_id,
|
||||
account_number=account["account_number"],
|
||||
date_tolerance_days=date_tolerance_days,
|
||||
)
|
||||
)
|
||||
return runs
|
||||
|
||||
|
||||
|
||||
def _tally_side(voucher: dict, bank_ledger_name: str):
|
||||
bank_key = bank_ledger_name.casefold()
|
||||
entries = list(voucher.get("ledger_entries") or [])
|
||||
@@ -256,7 +443,7 @@ def _tally_side(voucher: dict, bank_ledger_name: str):
|
||||
}
|
||||
|
||||
|
||||
def _pair_score(bank: AccountingBankTransaction, tally: dict):
|
||||
def _pair_score(bank: AccountingBankTransaction, tally: dict, tolerance_days: int = 15):
|
||||
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():
|
||||
@@ -268,7 +455,8 @@ def _pair_score(bank: AccountingBankTransaction, tally: dict):
|
||||
return 0, ""
|
||||
|
||||
gap = abs((bd - td).days)
|
||||
if gap > 7:
|
||||
tolerance_days = max(0, min(90, int(tolerance_days or 15)))
|
||||
if gap > tolerance_days:
|
||||
return 0, ""
|
||||
|
||||
bank_ref = _norm_ref(bank.transfer_reference or bank.reference_no)
|
||||
@@ -291,9 +479,14 @@ def _pair_score(bank: AccountingBankTransaction, tally: dict):
|
||||
elif gap <= 4:
|
||||
score += 7
|
||||
reasons.append(f"{gap}-day timing difference")
|
||||
else:
|
||||
elif gap <= 7:
|
||||
score += 2
|
||||
reasons.append(f"{gap}-day timing difference")
|
||||
else:
|
||||
# Long timing differences remain match candidates only because amount
|
||||
# and bank direction are exact. They are surfaced separately rather
|
||||
# than silently treated as a normal probable match.
|
||||
reasons.append(f"{gap}-day timing difference")
|
||||
|
||||
if ref_exact:
|
||||
score += 18
|
||||
@@ -319,6 +512,11 @@ def build_reconciliation(db, run: BankReconciliationRun, vouchers: list[dict]):
|
||||
AccountingBankTransaction.tenant_id == run.tenant_id,
|
||||
AccountingBankTransaction.client_id == run.client_id,
|
||||
AccountingBankTransaction.source_job_id == run.source_job_id,
|
||||
*(
|
||||
[AccountingBankTransaction.account_number == run.account_number]
|
||||
if _s(run.account_number)
|
||||
else []
|
||||
),
|
||||
)
|
||||
.order_by(
|
||||
AccountingBankTransaction.transaction_date,
|
||||
@@ -339,14 +537,14 @@ def build_reconciliation(db, run: BankReconciliationRun, vouchers: list[dict]):
|
||||
for bank in bank_rows:
|
||||
scored = []
|
||||
for index, tally in enumerate(tally_rows):
|
||||
score, reason = _pair_score(bank, tally)
|
||||
score, reason = _pair_score(bank, tally, run.date_tolerance_days)
|
||||
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
|
||||
exact = probable = timing = bank_only = duplicates = 0
|
||||
|
||||
for bank in bank_rows:
|
||||
options = [
|
||||
@@ -372,10 +570,17 @@ def build_reconciliation(db, run: BankReconciliationRun, vouchers: list[dict]):
|
||||
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":
|
||||
bank_date = _date_obj(bank.transaction_date)
|
||||
tally_date = _date_obj(tally["date"])
|
||||
gap = abs((bank_date - tally_date).days) if bank_date and tally_date else 999
|
||||
if top_score >= 90:
|
||||
status = "matched"
|
||||
exact += 1
|
||||
elif gap > 7:
|
||||
status = "timing_difference"
|
||||
timing += 1
|
||||
else:
|
||||
status = "probable_match"
|
||||
probable += 1
|
||||
|
||||
item = BankReconciliationItem(
|
||||
@@ -428,18 +633,74 @@ def build_reconciliation(db, run: BankReconciliationRun, vouchers: list[dict]):
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
bank_only_rows = [
|
||||
row for row in db.execute(
|
||||
select(BankReconciliationItem).where(
|
||||
BankReconciliationItem.run_id == run.id,
|
||||
BankReconciliationItem.match_status == "bank_only",
|
||||
)
|
||||
).scalars().all()
|
||||
]
|
||||
books_only_rows = [
|
||||
row for row in db.execute(
|
||||
select(BankReconciliationItem).where(
|
||||
BankReconciliationItem.run_id == run.id,
|
||||
BankReconciliationItem.match_status == "books_only",
|
||||
)
|
||||
).scalars().all()
|
||||
]
|
||||
|
||||
def _direction_totals(rows, prefix):
|
||||
result = {
|
||||
f"{prefix}_debit_amount": 0.0,
|
||||
f"{prefix}_credit_amount": 0.0,
|
||||
}
|
||||
for row in rows:
|
||||
direction = (
|
||||
row.bank_direction if prefix == "bank_only"
|
||||
else row.tally_direction
|
||||
)
|
||||
amount = (
|
||||
row.bank_amount if prefix == "bank_only"
|
||||
else row.tally_amount
|
||||
)
|
||||
key = (
|
||||
f"{prefix}_debit_amount"
|
||||
if _s(direction).upper() == "DEBIT"
|
||||
else f"{prefix}_credit_amount"
|
||||
)
|
||||
result[key] = round(result[key] + float(amount or 0), 2)
|
||||
return result
|
||||
|
||||
summary = {
|
||||
"bank_transactions": len(bank_rows),
|
||||
"tally_bank_vouchers": len(tally_rows),
|
||||
"matched": exact,
|
||||
"probable_match": probable,
|
||||
"timing_difference": timing,
|
||||
"bank_only": bank_only,
|
||||
"books_only": books_only,
|
||||
"duplicate_candidate": duplicates,
|
||||
"account_number": run.account_number,
|
||||
"bank_ledger_name": run.bank_ledger_name,
|
||||
"date_tolerance_days": int(run.date_tolerance_days or 15),
|
||||
}
|
||||
summary.update(_direction_totals(bank_only_rows, "bank_only"))
|
||||
summary.update(_direction_totals(books_only_rows, "books_only"))
|
||||
summary["bank_only_net"] = round(
|
||||
summary["bank_only_credit_amount"] - summary["bank_only_debit_amount"],
|
||||
2,
|
||||
)
|
||||
summary["books_only_net"] = round(
|
||||
summary["books_only_credit_amount"] - summary["books_only_debit_amount"],
|
||||
2,
|
||||
)
|
||||
summary["unreconciled_net_difference"] = round(
|
||||
summary["bank_only_net"] - summary["books_only_net"],
|
||||
2,
|
||||
)
|
||||
|
||||
run.summary_json = json.dumps(summary, ensure_ascii=False)
|
||||
run.status = "completed"
|
||||
run.completed_at_utc = _utcnow()
|
||||
run.last_error = ""
|
||||
@@ -478,6 +739,64 @@ def sync_run(db, run: BankReconciliationRun):
|
||||
return run
|
||||
|
||||
|
||||
def resolve_reconciliation_item(
|
||||
db,
|
||||
*,
|
||||
run_id: int,
|
||||
item_id: int,
|
||||
action: str,
|
||||
note: str,
|
||||
user_id: int,
|
||||
):
|
||||
item = db.execute(
|
||||
select(BankReconciliationItem).where(
|
||||
BankReconciliationItem.id == int(item_id),
|
||||
BankReconciliationItem.run_id == int(run_id),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not item:
|
||||
raise ValueError("Reconciliation item was not found.")
|
||||
|
||||
action = _s(action)
|
||||
allowed = {
|
||||
"confirm_match",
|
||||
"confirm_timing",
|
||||
"reject_match_bank_only",
|
||||
"confirm_bank_only",
|
||||
"confirm_books_only",
|
||||
"needs_follow_up",
|
||||
"reopen",
|
||||
}
|
||||
if action not in allowed:
|
||||
raise ValueError("Unsupported reconciliation resolution.")
|
||||
|
||||
item.resolution_status = action
|
||||
item.resolution_note = _s(note)
|
||||
item.resolved_by_user_id = int(user_id)
|
||||
item.resolved_at_utc = _utcnow()
|
||||
|
||||
if item.bank_transaction_id:
|
||||
tx = db.get(AccountingBankTransaction, int(item.bank_transaction_id))
|
||||
if tx:
|
||||
if action == "confirm_match":
|
||||
tx.reconciliation_status = "matched"
|
||||
elif action == "confirm_timing":
|
||||
tx.reconciliation_status = "timing_difference_confirmed"
|
||||
elif action in {"reject_match_bank_only", "confirm_bank_only"}:
|
||||
tx.reconciliation_status = "bank_only"
|
||||
elif action == "needs_follow_up":
|
||||
tx.reconciliation_status = "needs_review"
|
||||
elif action == "reopen":
|
||||
tx.reconciliation_status = item.match_status
|
||||
tx.last_reconciliation_run_id = int(run_id)
|
||||
db.add(tx)
|
||||
|
||||
db.add(item)
|
||||
db.commit()
|
||||
return item
|
||||
|
||||
|
||||
|
||||
def list_runs(db, *, tenant_id: int, client_id: int, limit: int = 30):
|
||||
rows = list(
|
||||
db.execute(
|
||||
|
||||
@@ -11,9 +11,14 @@ 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_bank_mappings,
|
||||
list_runs,
|
||||
queue_all_mapped_reconciliations,
|
||||
queue_reconciliation,
|
||||
resolve_reconciliation_item,
|
||||
run_items,
|
||||
save_bank_mapping,
|
||||
source_accounts,
|
||||
visible_bank_ledgers,
|
||||
visible_workstations,
|
||||
)
|
||||
@@ -91,6 +96,9 @@ def page(
|
||||
runs = []
|
||||
items = []
|
||||
selected_run = None
|
||||
accounts = []
|
||||
mappings = []
|
||||
run_summaries = {}
|
||||
|
||||
if selected:
|
||||
jobs = completed_client_jobs(
|
||||
@@ -99,6 +107,13 @@ def page(
|
||||
client_id=selected.id,
|
||||
limit=50,
|
||||
)
|
||||
if job_id:
|
||||
accounts = source_accounts(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
client_id=selected.id,
|
||||
job_id=job_id,
|
||||
)
|
||||
companies = _company_options(db, scope.tenant_id, selected.id)
|
||||
if not tally_guid and len(companies) == 1:
|
||||
tally_guid = companies[0]["guid"]
|
||||
@@ -109,6 +124,12 @@ def page(
|
||||
client_id=selected.id,
|
||||
tally_guid=tally_guid,
|
||||
)
|
||||
mappings = list_bank_mappings(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
client_id=selected.id,
|
||||
tally_guid=tally_guid,
|
||||
)
|
||||
workstations = visible_workstations(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
@@ -136,6 +157,12 @@ def page(
|
||||
status=status,
|
||||
)
|
||||
|
||||
for row in runs:
|
||||
try:
|
||||
run_summaries[int(row.id)] = json.loads(row.summary_json or "{}")
|
||||
except Exception:
|
||||
run_summaries[int(row.id)] = {}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"modules/accounting/templates/accounting/bank_reconciliation.html",
|
||||
{
|
||||
@@ -157,6 +184,9 @@ def page(
|
||||
"selected_run": selected_run,
|
||||
"items": items,
|
||||
"status_filter": status,
|
||||
"source_accounts": accounts,
|
||||
"bank_mappings": mappings,
|
||||
"run_summaries": run_summaries,
|
||||
"message": message,
|
||||
"error": error,
|
||||
},
|
||||
@@ -173,6 +203,8 @@ def create_run(
|
||||
tally_guid: str = Form(...),
|
||||
company_name: str = Form(...),
|
||||
bank_ledger_name: str = Form(...),
|
||||
account_number: str = Form(""),
|
||||
date_tolerance_days: int = Form(15),
|
||||
workstation_id: int = Form(...),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
@@ -197,6 +229,8 @@ def create_run(
|
||||
bank_ledger_name=bank_ledger_name,
|
||||
workstation_id=workstation_id,
|
||||
user_id=user.id,
|
||||
account_number=account_number,
|
||||
date_tolerance_days=date_tolerance_days,
|
||||
)
|
||||
return _go(
|
||||
client.id,
|
||||
@@ -216,3 +250,153 @@ def create_run(
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/mapping")
|
||||
def save_mapping(
|
||||
request: Request,
|
||||
client_id: int = Form(...),
|
||||
source_job_id: str = Form(""),
|
||||
tally_guid: str = Form(...),
|
||||
company_name: str = Form(...),
|
||||
account_number: str = Form(...),
|
||||
bank_name: str = Form(""),
|
||||
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, _clients, scope = _find_visible_client(db, request, user, client_id)
|
||||
if not client:
|
||||
return _go(error="Client is not visible.")
|
||||
|
||||
save_bank_mapping(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
client_id=client.id,
|
||||
account_number=account_number,
|
||||
bank_name=bank_name,
|
||||
tally_guid=tally_guid,
|
||||
company_name=company_name,
|
||||
bank_ledger_name=bank_ledger_name,
|
||||
user_id=user.id,
|
||||
)
|
||||
return _go(
|
||||
client.id,
|
||||
job_id=source_job_id,
|
||||
tally_guid=tally_guid,
|
||||
message=f"Bank account {account_number} mapped to Tally ledger '{bank_ledger_name}'.",
|
||||
)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
return _go(client_id, job_id=source_job_id, tally_guid=tally_guid, error=str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/run-all")
|
||||
def create_all_runs(
|
||||
request: Request,
|
||||
client_id: int = Form(...),
|
||||
source_job_id: str = Form(...),
|
||||
tally_guid: str = Form(...),
|
||||
company_name: str = Form(...),
|
||||
date_tolerance_days: int = Form(15),
|
||||
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.")
|
||||
|
||||
runs = queue_all_mapped_reconciliations(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
client_id=client.id,
|
||||
source_job_id=source_job_id,
|
||||
tally_guid=tally_guid,
|
||||
company_name=company_name,
|
||||
workstation_id=workstation_id,
|
||||
user_id=user.id,
|
||||
date_tolerance_days=date_tolerance_days,
|
||||
)
|
||||
return _go(
|
||||
client.id,
|
||||
job_id=source_job_id,
|
||||
tally_guid=tally_guid,
|
||||
run_id=(runs[0].id if runs else None),
|
||||
message=f"Queued {len(runs)} reconciliation run(s), one for each mapped bank account.",
|
||||
)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
return _go(client_id, job_id=source_job_id, tally_guid=tally_guid, error=str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/items/{item_id}/resolve")
|
||||
def resolve_item(
|
||||
request: Request,
|
||||
run_id: int,
|
||||
item_id: int,
|
||||
client_id: int = Form(...),
|
||||
tally_guid: str = Form(""),
|
||||
action: str = Form(...),
|
||||
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, _clients, scope = _find_visible_client(db, request, user, client_id)
|
||||
if not client:
|
||||
return _go(error="Client is not visible.")
|
||||
|
||||
run = next(
|
||||
(
|
||||
row
|
||||
for row in list_runs(
|
||||
db,
|
||||
tenant_id=scope.tenant_id,
|
||||
client_id=client.id,
|
||||
limit=100,
|
||||
)
|
||||
if int(row.id) == int(run_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not run:
|
||||
raise ValueError("Reconciliation run was not found.")
|
||||
|
||||
resolve_reconciliation_item(
|
||||
db,
|
||||
run_id=run.id,
|
||||
item_id=item_id,
|
||||
action=action,
|
||||
note=note,
|
||||
user_id=user.id,
|
||||
)
|
||||
return _go(
|
||||
client.id,
|
||||
tally_guid=(tally_guid or run.tally_guid),
|
||||
run_id=run.id,
|
||||
message="Reconciliation review decision saved.",
|
||||
)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
return _go(client_id, tally_guid=tally_guid, run_id=run_id, error=str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -299,6 +299,9 @@ def _ensure_reviewed(tx):
|
||||
"matched",
|
||||
"probable_match",
|
||||
"duplicate_candidate",
|
||||
"timing_difference",
|
||||
"timing_difference_confirmed",
|
||||
"needs_review",
|
||||
}:
|
||||
raise ValueError(
|
||||
"Bank Reconciliation indicates that this transaction already has, "
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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.
|
||||
Compare normalized Bank Analyzer transactions with Tally Bank ledgers. Phase 20 remembers each client bank-account ↔ Tally-ledger mapping, can reconcile every mapped account in one action, supports wider cheque/clearing timing windows, and keeps reviewer resolutions. Tally access remains read-only.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -47,54 +47,140 @@
|
||||
{% 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>
|
||||
<h2 class="font-semibold">1. Select the Bank Analyzer job</h2>
|
||||
<p class="text-sm text-slate-500">Choose the completed client-bound analysis whose imported bank transactions you want to reconcile. The job can contain any number of bank accounts.</p>
|
||||
</div>
|
||||
<form method="get" class="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<label class="text-xs font-semibold">Bank Analyzer Job
|
||||
<select name="job_id" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||
<option value="">Select completed 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
|
||||
<select name="tally_guid" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||
{% 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"><button class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold">Load Accounts</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if selected_job_id %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="mb-4">
|
||||
<h2 class="font-semibold">2. Map client bank accounts to Tally Bank ledgers</h2>
|
||||
<p class="text-sm text-slate-500">Mappings are remembered client-wise and company-wise. Once all accounts are mapped, future reconciliation runs do not require selecting the bank ledger again.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/tools/accounting/bank-reconciliation/run" class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div class="space-y-3">
|
||||
{% for account in source_accounts %}
|
||||
{% set existing = namespace(value=None) %}
|
||||
{% for mapping in bank_mappings %}
|
||||
{% if mapping.account_number == account.account_number %}{% set existing.value = mapping %}{% endif %}
|
||||
{% endfor %}
|
||||
<form method="post" action="/tools/accounting/bank-reconciliation/mapping" class="grid gap-3 rounded-xl border border-slate-200 p-4 md:grid-cols-[1fr_1.4fr_auto]">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<input type="hidden" name="source_job_id" value="{{ selected_job_id }}">
|
||||
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
|
||||
{% for company in companies %}{% if company.guid==selected_tally_guid %}<input type="hidden" name="company_name" value="{{ company.name }}">{% endif %}{% endfor %}
|
||||
<input type="hidden" name="account_number" value="{{ account.account_number }}">
|
||||
<input type="hidden" name="bank_name" value="{{ account.bank_name }}">
|
||||
|
||||
<div>
|
||||
<div class="text-xs text-slate-500">Statement account</div>
|
||||
<div class="font-semibold">{{ account.bank_name or 'Bank' }} · {{ account.account_number }}</div>
|
||||
<div class="text-xs text-slate-500">{{ account.transaction_count }} imported transaction(s)</div>
|
||||
</div>
|
||||
<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 }}" {% if existing.value and existing.value.bank_ledger_name==ledger.name %}selected{% endif %}>{{ ledger.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div class="flex items-end"><button class="rounded-lg bg-slate-900 px-4 py-2 text-xs font-semibold text-white">{% if existing.value %}Update Mapping{% else %}Save Mapping{% endif %}</button></div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">No imported bank accounts were found for this job. Ensure its Accounting import status is Completed.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="mb-4">
|
||||
<h2 class="font-semibold">3. Reconcile mapped bank accounts</h2>
|
||||
<p class="text-sm text-slate-500">Use a wider timing tolerance for cheque issue/presentation or deposit/clearing differences. Amount and bank direction must still match exactly before the engine considers a Tally voucher as a candidate.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/tools/accounting/bank-reconciliation/run-all" class="grid gap-4 md:grid-cols-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<input type="hidden" name="source_job_id" value="{{ selected_job_id }}">
|
||||
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
|
||||
{% for company in companies %}{% if company.guid==selected_tally_guid %}<input type="hidden" name="company_name" value="{{ company.name }}">{% endif %}{% endfor %}
|
||||
|
||||
<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 %}
|
||||
<label class="text-xs font-semibold">Timing tolerance
|
||||
<select name="date_tolerance_days" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||
<option value="7">7 days</option>
|
||||
<option value="15" selected>15 days</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="45">45 days</option>
|
||||
<option value="60">60 days</option>
|
||||
<option value="90">90 days</option>
|
||||
</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
|
||||
<label class="text-xs font-semibold">Connected 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>
|
||||
<option value="">Select 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>
|
||||
<div class="flex items-end"><button class="w-full rounded-xl bg-emerald-700 px-5 py-2 text-sm font-semibold text-white">Reconcile All Mapped Accounts</button></div>
|
||||
</form>
|
||||
|
||||
<details class="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<summary class="cursor-pointer text-sm font-semibold">Run one account manually</summary>
|
||||
<form method="post" action="/tools/accounting/bank-reconciliation/run" class="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
|
||||
<input type="hidden" name="source_job_id" value="{{ selected_job_id }}">
|
||||
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
|
||||
{% for company in companies %}{% if company.guid==selected_tally_guid %}<input type="hidden" name="company_name" value="{{ company.name }}">{% endif %}{% endfor %}
|
||||
<label class="text-xs font-semibold">Statement Account
|
||||
<select name="account_number" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||
<option value="">All imported rows (legacy / account number unavailable)</option>
|
||||
{% for account in source_accounts %}<option value="{{ account.account_number }}">{{ account.bank_name }} · {{ account.account_number }}</option>{% 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">Timing tolerance
|
||||
<input type="number" name="date_tolerance_days" value="15" min="0" max="90" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
|
||||
</label>
|
||||
<label class="text-xs font-semibold md:col-span-2">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 workstation</option>
|
||||
{% for ws in workstations %}<option value="{{ ws.id }}">{{ ws.machine_name }} · Agent {{ ws.agent_version or '-' }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div class="flex items-end"><button class="rounded-lg bg-slate-900 px-4 py-2 text-xs font-semibold text-white">Run One Account</button></div>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if selected_client %}
|
||||
<section class="rounded-2xl bg-white shadow-soft">
|
||||
@@ -104,19 +190,21 @@
|
||||
</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>
|
||||
<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 / Account</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"><div class="font-semibold">{{ run.bank_ledger_name }}</div><div class="text-xs text-slate-500">{{ run.account_number or "All imported accounts" }}</div></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">
|
||||
{% set s = run_summaries.get(run.id, {}) %}
|
||||
{% 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>
|
||||
<div>Matched {{ s.get('matched',0) }} · Probable {{ s.get('probable_match',0) }} · Timing {{ s.get('timing_difference',0) }}</div>
|
||||
<div class="mt-1">Bank only {{ s.get('bank_only',0) }} · Books only {{ s.get('books_only',0) }} · Duplicate {{ s.get('duplicate_candidate',0) }}</div>
|
||||
<div class="mt-1 text-slate-500">Unreconciled net difference ₹{{ '%.2f'|format(s.get('unreconciled_net_difference',0)) }}</div>
|
||||
{% 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>
|
||||
@@ -144,7 +232,7 @@
|
||||
<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')] %}
|
||||
{% for value,label in [('matched','Matched'),('probable_match','Probable Match'),('timing_difference','Timing Difference'),('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>
|
||||
@@ -158,6 +246,15 @@
|
||||
{% elif selected_run.status=='failed' %}
|
||||
<div class="p-8 text-center text-red-700">{{ selected_run.last_error }}</div>
|
||||
{% elif selected_run.status=='completed' %}
|
||||
{% set rs = run_summaries.get(selected_run.id, {}) %}
|
||||
<div class="grid gap-3 border-b border-slate-200 p-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||
<div class="rounded-xl bg-emerald-50 p-3"><div class="text-xs text-emerald-700">Matched</div><div class="text-xl font-bold">{{ rs.get('matched',0) }}</div></div>
|
||||
<div class="rounded-xl bg-amber-50 p-3"><div class="text-xs text-amber-700">Probable</div><div class="text-xl font-bold">{{ rs.get('probable_match',0) }}</div></div>
|
||||
<div class="rounded-xl bg-orange-50 p-3"><div class="text-xs text-orange-700">Timing differences</div><div class="text-xl font-bold">{{ rs.get('timing_difference',0) }}</div></div>
|
||||
<div class="rounded-xl bg-blue-50 p-3"><div class="text-xs text-blue-700">Bank only</div><div class="text-xl font-bold">{{ rs.get('bank_only',0) }}</div><div class="text-[11px]">Dr ₹{{ '%.2f'|format(rs.get('bank_only_debit_amount',0)) }} · Cr ₹{{ '%.2f'|format(rs.get('bank_only_credit_amount',0)) }}</div></div>
|
||||
<div class="rounded-xl bg-violet-50 p-3"><div class="text-xs text-violet-700">Books only</div><div class="text-xl font-bold">{{ rs.get('books_only',0) }}</div><div class="text-[11px]">Dr ₹{{ '%.2f'|format(rs.get('books_only_debit_amount',0)) }} · Cr ₹{{ '%.2f'|format(rs.get('books_only_credit_amount',0)) }}</div></div>
|
||||
<div class="rounded-xl bg-slate-100 p-3"><div class="text-xs text-slate-600">Unreconciled net</div><div class="text-xl font-bold">₹{{ '%.2f'|format(rs.get('unreconciled_net_difference',0)) }}</div><div class="text-[11px]">Tolerance {{ rs.get('date_tolerance_days',15) }} days</div></div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -168,6 +265,7 @@
|
||||
<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=='timing_difference' %}bg-orange-100 text-orange-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 %}">
|
||||
@@ -191,13 +289,28 @@
|
||||
{% 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">
|
||||
<td class="px-3 py-3 min-w-[260px]">
|
||||
{% if item.resolution_status and item.resolution_status != 'unresolved' %}
|
||||
<div class="mb-2 rounded-lg bg-slate-100 p-2"><strong>Reviewed:</strong> {{ item.resolution_status|replace('_',' ')|title }}{% if item.resolution_note %}<div class="mt-1 text-slate-500">{{ item.resolution_note }}</div>{% endif %}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/tools/accounting/bank-reconciliation/runs/{{ selected_run.id }}/items/{{ item.id }}/resolve" class="space-y-2">
|
||||
<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_run.tally_guid }}">
|
||||
<select name="action" required class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2">
|
||||
<option value="">Reviewer decision</option>
|
||||
{% if item.match_status in ['matched','probable_match','duplicate_candidate'] %}<option value="confirm_match">Confirm as matched</option><option value="reject_match_bank_only">Reject match — treat as Bank Only</option>{% endif %}
|
||||
{% if item.match_status=='timing_difference' %}<option value="confirm_timing">Confirm timing difference</option><option value="reject_match_bank_only">Reject match — treat as Bank Only</option>{% endif %}
|
||||
{% if item.match_status=='bank_only' %}<option value="confirm_bank_only">Confirm Bank Only / missing in books</option>{% endif %}
|
||||
{% if item.match_status=='books_only' %}<option value="confirm_books_only">Confirm Books Only / outstanding item</option>{% endif %}
|
||||
<option value="needs_follow_up">Needs follow-up</option>
|
||||
<option value="reopen">Reopen system result</option>
|
||||
</select>
|
||||
<input name="note" placeholder="Review note (optional)" class="w-full rounded-lg border border-slate-300 px-2 py-2">
|
||||
<button class="rounded-lg border border-slate-300 bg-white px-3 py-2 font-semibold">Save Decision</button>
|
||||
</form>
|
||||
{% 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>
|
||||
<a href="/tools/accounting/bank-posting?client_id={{ selected_client.id }}" class="mt-2 inline-block font-semibold text-blue-700 hover:underline">Open Accounting Queue</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user