Add Phase 20 stored bank reuse and richer reconciliation
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user