Add adaptive paced Tally extraction for cash payment review

This commit is contained in:
A R R R Associates
2026-09-03 23:37:14 +05:30
parent 3a4370d35e
commit 247c545d20
5 changed files with 253 additions and 159 deletions
+4 -1
View File
@@ -195,8 +195,11 @@ def cash_payment_review(
"cash_limit": float(setting.cash_limit), "cash_limit": float(setting.cash_limit),
"split_window_days": int(setting.split_window_days), "split_window_days": int(setting.split_window_days),
"near_limit_percent": float(setting.near_limit_percent), "near_limit_percent": float(setting.near_limit_percent),
"tally_batch_days": 7,
"tally_pause_seconds": 1.5,
"tally_retry_pause_seconds": 3.0,
}, },
timeout_seconds=300, timeout_seconds=900,
) )
if result.get("ok"): if result.get("ok"):
review = (result.get("result") or {}).get("cash_payment_review") review = (result.get("result") or {}).get("cash_payment_review")
@@ -64,7 +64,7 @@
{% if review %} {% if review %}
<section class="space-y-4"> <section class="space-y-4">
{% if review.safe_paced_mode %} {% if review.safe_paced_mode %}
<div class="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800">Completed in safe-paced mode using {{ review.tally_request_batches or 0 }} Tally voucher batch(es), with approximately {{ review.tally_pause_ms or 0 }} ms pause between batches.</div> <div class="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800">Completed in adaptive safe-paced mode using {{ review.tally_request_batches or 0 }} successful voucher batch(es) across {{ review.tally_request_count or 0 }} Tally request(s), with {{ review.tally_retry_count or 0 }} retry/retries and {{ review.tally_fallback_batches or 0 }} adaptive fallback(s). The agent pauses approximately {{ review.tally_pause_ms or 0 }} ms between Tally calls.</div>
{% endif %} {% endif %}
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5"> <div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{% for label, value in [('Vouchers reviewed', review.summary.vouchers_reviewed), ('Cash payments', review.summary.cash_payment_vouchers), ('Single-voucher exceptions', review.summary.single_voucher_exceptions), ('Same-day exceptions', review.summary.same_day_exceptions), ('Possible split patterns', review.summary.possible_split_patterns)] %} {% for label, value in [('Vouchers reviewed', review.summary.vouchers_reviewed), ('Cash payments', review.summary.cash_payment_vouchers), ('Single-voucher exceptions', review.summary.single_voucher_exceptions), ('Same-day exceptions', review.summary.same_day_exceptions), ('Possible split patterns', review.summary.possible_split_patterns)] %}
@@ -133,7 +133,7 @@
<div class="mt-4 grid grid-cols-4 gap-2 text-center text-[11px] font-semibold uppercase tracking-wide text-slate-400"> <div class="mt-4 grid grid-cols-4 gap-2 text-center text-[11px] font-semibold uppercase tracking-wide text-slate-400">
<span>Agent</span><span>Tally</span><span>Vouchers</span><span>Analysis</span> <span>Agent</span><span>Tally</span><span>Vouchers</span><span>Analysis</span>
</div> </div>
<p class="mt-4 text-xs text-slate-500">Tally is deliberately read in smaller monthly batches with pauses between requests. Please keep TallyPrime and the ERP Local Agent open until this finishes.</p> <p class="mt-4 text-xs text-slate-500">Tally is deliberately read in 7-day batches with pauses between requests. If a batch times out, the agent automatically retries and reduces it to 3-day and then 1-day batches. Please keep TallyPrime and the ERP Local Agent open until this finishes.</p>
</div> </div>
</div> </div>
<script> <script>
@@ -149,8 +149,9 @@
[8, 'Sending the review request to the ERP Local Agent…'], [8, 'Sending the review request to the ERP Local Agent…'],
[18, 'Connecting to TallyPrime on the workstation…'], [18, 'Connecting to TallyPrime on the workstation…'],
[32, 'Reading cash and ledger masters…'], [32, 'Reading cash and ledger masters…'],
[48, 'Reading vouchers in safe monthly batches…'], [42, 'Reading vouchers in safe 7-day batches…'],
[64, 'Continuing voucher scan with controlled pauses…'], [58, 'Continuing voucher extraction with 1.5 second pauses…'],
[70, 'Large batches automatically fall back to 3-day or 1-day reads if needed…'],
[78, 'Checking same-day cash payment aggregation…'], [78, 'Checking same-day cash payment aggregation…'],
[88, 'Checking possible split-payment patterns…'], [88, 'Checking possible split-payment patterns…'],
[94, 'Preparing the compliance report…'] [94, 'Preparing the compliance report…']
@@ -169,7 +170,7 @@
bar.style.width = stage[0] + '%'; bar.style.width = stage[0] + '%';
pct.textContent = stage[0] + '%'; pct.textContent = stage[0] + '%';
text.textContent = stage[1]; text.textContent = stage[1];
if (i < stages.length) window.setTimeout(advance, i < 4 ? 1300 : 2200); if (i < stages.length) window.setTimeout(advance, i < 4 ? 1800 : 4500);
}; };
advance(); advance();
}); });
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.22.6" ERP_LOCAL_AGENT_VERSION = "1.22.7"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -1,2 +1,2 @@
__version__ = "1.22.6" __version__ = "1.22.7"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime, timezone, date as _dt_date, timedelta as _timedelta from datetime import datetime, timezone, date as _dt_date, timedelta as _timedelta
import re import re
import time import time
import threading
from typing import Any from typing import Any
from . import __version__ from . import __version__
@@ -11,6 +12,9 @@ from .tally import TallyLiveConnector
from .native_voucher_engine import NativeVoucherEngine from .native_voucher_engine import NativeVoucherEngine
_CASH_TALLY_EXTRACTION_LOCK = threading.Lock()
class AgentCommandProcessor: class AgentCommandProcessor:
def __init__(self, config, logger): def __init__(self, config, logger):
self.config = config self.config = config
@@ -662,23 +666,6 @@ class AgentCommandProcessor:
if not 1 <= near_limit_percent <= 100: if not 1 <= near_limit_percent <= 100:
raise ValueError("Near-limit percentage must be between 1 and 100.") raise ValueError("Near-limit percentage must be between 1 and 100.")
# TallyPrime is intentionally queried in a paced manner. A full-year XML
# voucher export in one request can make desktop Tally unresponsive on
# larger books. Load masters once, pause briefly, then read vouchers in
# month-sized batches with a small gap between calls.
pace_seconds = 0.75
ledger_masters = self.tally.export_master_collection(company_name, "ledgers")
time.sleep(pace_seconds)
cash_ledgers = set()
for ledger in ledger_masters:
name = str(ledger.get("name") or "").strip()
parent = str(ledger.get("parent") or "").strip().casefold().replace("-", " ")
reserved = str(ledger.get("reserved_name") or "").strip().casefold()
if name and (parent in {"cash in hand", "cash-in-hand"} or reserved == "cash" or name.casefold() == "cash"):
cash_ledgers.add(name.casefold())
if not cash_ledgers:
cash_ledgers.add("cash")
try: try:
scan_from = _dt_date.fromisoformat(date_from) scan_from = _dt_date.fromisoformat(date_from)
scan_to = _dt_date.fromisoformat(date_to) scan_to = _dt_date.fromisoformat(date_to)
@@ -687,151 +674,254 @@ class AgentCommandProcessor:
if scan_to < scan_from: if scan_to < scan_from:
raise ValueError("Cash payment review To date cannot be before From date.") raise ValueError("Cash payment review To date cannot be before From date.")
vouchers = [] # TallyPrime is single-desktop software and large XML exports can make it
batch_count = 0 # appear frozen. Keep only one heavy compliance extraction active on a
cursor = scan_from # workstation and deliberately pace every XML request.
while cursor <= scan_to: if not _CASH_TALLY_EXTRACTION_LOCK.acquire(blocking=False):
next_month = (cursor.replace(day=28) + _timedelta(days=4)).replace(day=1) raise ValueError("Another Cash Payment Tally extraction is already running on this workstation. Please wait for it to finish and try again.")
batch_to = min(scan_to, next_month - _timedelta(days=1))
batch = self.tally.export_vouchers(company_name, cursor.isoformat(), batch_to.isoformat())
vouchers.extend(batch or [])
batch_count += 1
cursor = batch_to + _timedelta(days=1)
if cursor <= scan_to:
time.sleep(pace_seconds)
payments = [] pace_seconds = max(0.5, min(float(payload.get("tally_pause_seconds") or 1.5), 10.0))
for voucher in vouchers: retry_pause_seconds = max(1.0, min(float(payload.get("tally_retry_pause_seconds") or 3.0), 30.0))
if str(voucher.get("is_cancelled") or "").strip().lower() in {"yes", "true", "1"}: base_batch_days = max(1, min(int(payload.get("tally_batch_days") or 7), 31))
continue request_count = 0
if str(voucher.get("is_optional") or "").strip().lower() in {"yes", "true", "1"}: successful_batches = 0
continue fallback_batches = 0
entries = list(voucher.get("ledger_entries") or []) retry_count = 0
cash_credit = [] batch_log: list[dict[str, Any]] = []
for entry in entries:
lname = str(entry.get("ledger_name") or "").strip()
if lname.casefold() not in cash_ledgers:
continue
amount = float(entry.get("amount") or 0)
deemed = str(entry.get("is_deemed_positive") or "").strip().lower()
if amount > 0 or deemed == "no":
cash_credit.append(abs(amount))
cash_amount = round(sum(cash_credit), 2)
if cash_amount <= 0:
continue
non_cash = [entry for entry in entries if str(entry.get("ledger_name") or "").strip().casefold() not in cash_ledgers] def paced_call(callable_, *, label: str):
party = str(voucher.get("party_ledger_name") or "").strip() nonlocal request_count, retry_count
if not party or party.casefold() in cash_ledgers: last_exc = None
candidates = sorted(non_cash, key=lambda row: abs(float(row.get("amount") or 0)), reverse=True) for attempt in range(1, 3):
party = str(candidates[0].get("ledger_name") or "").strip() if candidates else "Unidentified counter-ledger" try:
payments.append({ request_count += 1
"date": str(voucher.get("date") or voucher.get("effective_date") or ""), result = callable_()
"party": party or "Unidentified counter-ledger", time.sleep(pace_seconds)
"amount": cash_amount, return result
"voucher_number": str(voucher.get("voucher_number") or ""), except Exception as exc:
"voucher_type": str(voucher.get("voucher_type_name") or ""), last_exc = exc
"reference": str(voucher.get("reference") or ""), if attempt < 2:
"narration": str(voucher.get("narration") or ""), retry_count += 1
}) self.logger.warning("Tally request retry label=%s attempt=%s error=%s", label, attempt, exc)
time.sleep(retry_pause_seconds)
raise last_exc # type: ignore[misc]
by_party_date = defaultdict(list) def fetch_voucher_range(start_day: _dt_date, end_day: _dt_date, preferred_days: int) -> list[dict[str, Any]]:
for row in payments: nonlocal successful_batches, fallback_batches
by_party_date[(row["party"].casefold(), row["date"])].append(row) span = (end_day - start_day).days + 1
label = f"cash vouchers {start_day.isoformat()} to {end_day.isoformat()}"
exceptions = [] try:
seen = set() rows = paced_call(
single_count = 0 lambda: self.tally.export_vouchers(company_name, start_day.isoformat(), end_day.isoformat()),
same_day_count = 0 label=label,
for row in payments: ) or []
if row["amount"] > cash_limit + 0.009: successful_batches += 1
single_count += 1 batch_log.append({
key = (row["party"].casefold(), row["date"], "single", row["voucher_number"]) "date_from": start_day.isoformat(),
if key not in seen: "date_to": end_day.isoformat(),
seen.add(key) "days": span,
exceptions.append({ "vouchers": len(rows),
"date": row["date"], "party": row["party"], "amount": row["amount"], "status": "ok",
"reason": "Single cash-payment voucher exceeds configured limit", })
"voucher_numbers": [row["voucher_number"] or "-"], return list(rows)
}) except Exception as exc:
for (_party_key, paid_on), rows in by_party_date.items(): # Adaptive fallback: 7-day request -> approx 3-day chunks -> 1-day.
total = round(sum(row["amount"] for row in rows), 2) if span <= 1:
if total > cash_limit + 0.009 and len(rows) > 1: batch_log.append({
same_day_count += 1 "date_from": start_day.isoformat(),
key = (_party_key, paid_on, "aggregate") "date_to": end_day.isoformat(),
if key not in seen: "days": 1,
seen.add(key) "vouchers": 0,
exceptions.append({ "status": "failed",
"date": paid_on, "party": rows[0]["party"], "amount": total, "error": str(exc),
"reason": "Same-day aggregate cash payments to the same party exceed configured limit",
"voucher_numbers": [row["voucher_number"] or "-" for row in rows],
}) })
raise ValueError(
f"TallyPrime timed out while reading vouchers for {start_day.isoformat()}. "
"The agent already retried the request slowly. Keep TallyPrime open and try this period again. "
f"Details: {exc}"
) from exc
daily_by_party = defaultdict(lambda: defaultdict(float)) fallback_batches += 1
for row in payments: if span > 3:
daily_by_party[row["party"].casefold()][row["date"]] += row["amount"] chunk_days = 3
near_floor = cash_limit * near_limit_percent / 100.0 else:
split_patterns = [] chunk_days = 1
for party_key, day_map in daily_by_party.items(): self.logger.warning(
day_rows = sorted((_date.fromisoformat(day), round(amount, 2)) for day, amount in day_map.items() if day) "Reducing Tally cash extraction batch %s..%s from %s day(s) to %s day chunk(s) after error: %s",
for start_idx in range(len(day_rows)): start_day, end_day, span, chunk_days, exc,
window = [] )
for idx in range(start_idx, len(day_rows)): output: list[dict[str, Any]] = []
day_value, amount = day_rows[idx] cursor = start_day
if (day_value - day_rows[start_idx][0]).days >= split_window_days: while cursor <= end_day:
break child_end = min(end_day, cursor + _timedelta(days=chunk_days - 1))
window.append((day_value, amount)) output.extend(fetch_voucher_range(cursor, child_end, chunk_days))
if len(window) < 2: cursor = child_end + _timedelta(days=1)
return output
try:
# Masters are also paced and retried; the previous implementation
# could fail before voucher extraction even began on large books.
ledger_masters = paced_call(
lambda: self.tally.export_master_collection(company_name, "ledgers"),
label="cash ledger masters",
) or []
cash_ledgers = set()
for ledger in ledger_masters:
name = str(ledger.get("name") or "").strip()
parent = str(ledger.get("parent") or "").strip().casefold().replace("-", " ")
reserved = str(ledger.get("reserved_name") or "").strip().casefold()
if name and (parent in {"cash in hand", "cash-in-hand"} or reserved == "cash" or name.casefold() == "cash"):
cash_ledgers.add(name.casefold())
if not cash_ledgers:
cash_ledgers.add("cash")
vouchers: list[dict[str, Any]] = []
cursor = scan_from
while cursor <= scan_to:
batch_to = min(scan_to, cursor + _timedelta(days=base_batch_days - 1))
vouchers.extend(fetch_voucher_range(cursor, batch_to, base_batch_days))
cursor = batch_to + _timedelta(days=1)
payments = []
for voucher in vouchers:
if str(voucher.get("is_cancelled") or "").strip().lower() in {"yes", "true", "1"}:
continue continue
if any(amount > cash_limit + 0.009 for _, amount in window): if str(voucher.get("is_optional") or "").strip().lower() in {"yes", "true", "1"}:
continue continue
total = round(sum(amount for _, amount in window), 2) entries = list(voucher.get("ledger_entries") or [])
if total <= cash_limit + 0.009: cash_credit = []
for entry in entries:
lname = str(entry.get("ledger_name") or "").strip()
if lname.casefold() not in cash_ledgers:
continue
amount = float(entry.get("amount") or 0)
deemed = str(entry.get("is_deemed_positive") or "").strip().lower()
if amount > 0 or deemed == "no":
cash_credit.append(abs(amount))
cash_amount = round(sum(cash_credit), 2)
if cash_amount <= 0:
continue continue
near_days = sum(1 for _, amount in window if amount >= near_floor)
if near_days < 2: non_cash = [entry for entry in entries if str(entry.get("ledger_name") or "").strip().casefold() not in cash_ledgers]
continue party = str(voucher.get("party_ledger_name") or "").strip()
party_name = next((row["party"] for row in payments if row["party"].casefold() == party_key), party_key) if not party or party.casefold() in cash_ledgers:
signature = (party_key, window[0][0].isoformat(), window[-1][0].isoformat()) candidates = sorted(non_cash, key=lambda row: abs(float(row.get("amount") or 0)), reverse=True)
if any((r["party"].casefold(), r["date_from"], r["date_to"]) == signature for r in split_patterns): party = str(candidates[0].get("ledger_name") or "").strip() if candidates else "Unidentified counter-ledger"
continue payments.append({
split_patterns.append({ "date": str(voucher.get("date") or voucher.get("effective_date") or ""),
"party": party_name, "party": party or "Unidentified counter-ledger",
"date_from": window[0][0].isoformat(), "amount": cash_amount,
"date_to": window[-1][0].isoformat(), "voucher_number": str(voucher.get("voucher_number") or ""),
"total_amount": total, "voucher_type": str(voucher.get("voucher_type_name") or ""),
"days": [{"date": d.isoformat(), "amount": a} for d, a in window], "reference": str(voucher.get("reference") or ""),
"review_only": True, "narration": str(voucher.get("narration") or ""),
}) })
exceptions.sort(key=lambda row: (row["date"], row["party"].casefold())) by_party_date = defaultdict(list)
split_patterns.sort(key=lambda row: (row["date_from"], row["party"].casefold())) for row in payments:
return { by_party_date[(row["party"].casefold(), row["date"])].append(row)
"cash_payment_review": {
"company_name": company_name, exceptions = []
"company_guid": str(company.get("guid") or ""), seen = set()
"date_from": date_from, single_count = 0
"date_to": date_to, same_day_count = 0
"cash_limit": cash_limit, for row in payments:
"cash_ledgers": sorted(cash_ledgers), if row["amount"] > cash_limit + 0.009:
"summary": { single_count += 1
"vouchers_reviewed": len(vouchers), key = (row["party"].casefold(), row["date"], "single", row["voucher_number"])
"cash_payment_vouchers": len(payments), if key not in seen:
"single_voucher_exceptions": single_count, seen.add(key)
"same_day_exceptions": same_day_count, exceptions.append({
"possible_split_patterns": len(split_patterns), "date": row["date"], "party": row["party"], "amount": row["amount"],
"reason": "Single cash-payment voucher exceeds configured limit",
"voucher_numbers": [row["voucher_number"] or "-"],
})
for (_party_key, paid_on), rows in by_party_date.items():
total = round(sum(row["amount"] for row in rows), 2)
if total > cash_limit + 0.009 and len(rows) > 1:
same_day_count += 1
key = (_party_key, paid_on, "aggregate")
if key not in seen:
seen.add(key)
exceptions.append({
"date": paid_on, "party": rows[0]["party"], "amount": total,
"reason": "Same-day aggregate cash payments to the same party exceed configured limit",
"voucher_numbers": [row["voucher_number"] or "-" for row in rows],
})
daily_by_party = defaultdict(lambda: defaultdict(float))
for row in payments:
daily_by_party[row["party"].casefold()][row["date"]] += row["amount"]
near_floor = cash_limit * near_limit_percent / 100.0
split_patterns = []
for party_key, day_map in daily_by_party.items():
day_rows = sorted((_date.fromisoformat(day), round(amount, 2)) for day, amount in day_map.items() if day)
for start_idx in range(len(day_rows)):
window = []
for idx in range(start_idx, len(day_rows)):
day_value, amount = day_rows[idx]
if (day_value - day_rows[start_idx][0]).days >= split_window_days:
break
window.append((day_value, amount))
if len(window) < 2:
continue
if any(amount > cash_limit + 0.009 for _, amount in window):
continue
total = round(sum(amount for _, amount in window), 2)
if total <= cash_limit + 0.009:
continue
near_days = sum(1 for _, amount in window if amount >= near_floor)
if near_days < 2:
continue
party_name = next((row["party"] for row in payments if row["party"].casefold() == party_key), party_key)
signature = (party_key, window[0][0].isoformat(), window[-1][0].isoformat())
if any((r["party"].casefold(), r["date_from"], r["date_to"]) == signature for r in split_patterns):
continue
split_patterns.append({
"party": party_name,
"date_from": window[0][0].isoformat(),
"date_to": window[-1][0].isoformat(),
"total_amount": total,
"days": [{"date": d.isoformat(), "amount": a} for d, a in window],
"review_only": True,
})
exceptions.sort(key=lambda row: (row["date"], row["party"].casefold()))
split_patterns.sort(key=lambda row: (row["date_from"], row["party"].casefold()))
return {
"cash_payment_review": {
"company_name": company_name,
"company_guid": str(company.get("guid") or ""),
"date_from": date_from,
"date_to": date_to,
"cash_limit": cash_limit,
"cash_ledgers": sorted(cash_ledgers),
"summary": {
"vouchers_reviewed": len(vouchers),
"cash_payment_vouchers": len(payments),
"single_voucher_exceptions": single_count,
"same_day_exceptions": same_day_count,
"possible_split_patterns": len(split_patterns),
},
"exceptions": exceptions,
"possible_split_payments": split_patterns,
"cash_payments": payments,
"read_only": True,
"safe_paced_mode": True,
"adaptive_extraction": True,
"tally_request_batches": successful_batches,
"tally_request_count": request_count,
"tally_retry_count": retry_count,
"tally_fallback_batches": fallback_batches,
"tally_batch_days": base_batch_days,
"tally_pause_ms": int(pace_seconds * 1000),
"tally_batch_log": batch_log,
"review_note": "Possible split-payment patterns are review indicators only and do not determine tax treatment.",
}, },
"exceptions": exceptions, "agent": self._agent_info(),
"possible_split_payments": split_patterns, }
"cash_payments": payments, finally:
"read_only": True, _CASH_TALLY_EXTRACTION_LOCK.release()
"safe_paced_mode": True,
"tally_request_batches": batch_count,
"tally_pause_ms": int(pace_seconds * 1000),
"review_note": "Possible split-payment patterns are review indicators only and do not determine tax treatment.",
},
"agent": self._agent_info(),
}
def _tds_compliance(self, payload: dict[str, Any]) -> dict[str, Any]: def _tds_compliance(self, payload: dict[str, Any]) -> dict[str, Any]:
from collections import defaultdict from collections import defaultdict