Move cash payment compliance to SQLite accounting mirror

This commit is contained in:
A R R R Associates
2026-09-06 19:48:24 +05:30
parent 1fd15d652b
commit 6c2a2081ed
5 changed files with 422 additions and 520 deletions
@@ -1,2 +1,2 @@
__version__ = "1.24.4"
__version__ = "1.24.6"
AGENT_NAME = "ERP Local Agent"
@@ -144,6 +144,8 @@ class AgentCommandProcessor:
result = self._cash_payment_cache_cancel(payload)
elif action == "accounting_cash_payment_cache_analyze":
result = self._cash_payment_cache_analyze(payload)
elif action == "accounting_cash_payment_mirror_analyze":
result = self._cash_payment_mirror_analyze(payload)
elif action == "accounting_cash_payment_compliance":
result = self._cash_payment_compliance(payload)
elif action == "accounting_tds_compliance":
@@ -208,6 +210,7 @@ class AgentCommandProcessor:
"purchase_voucher_write_capability": True,
"it_depreciation_capability": True,
"cash_payment_compliance_capability": True,
"cash_payment_mirror_only_analysis_capability": True,
"cash_payment_sqlite_cache_capability": True,
"cash_payment_background_sync_capability": True,
"cash_payment_ledger_confirmation_capability": True,
@@ -2118,6 +2121,308 @@ class AgentCommandProcessor:
"agent": self._agent_info(),
}
def _cash_payment_mirror_analyze(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Analyse cash payments entirely from the client's local ODBC Accounting Mirror.
This path never calls TallyPrime. Tally is required only when the user
explicitly runs "Mirror Tally to SQLite" from the Tally Accounting page.
"""
from collections import defaultdict
from datetime import date as _date
client_id = int(payload.get("client_id") or 0)
if client_id <= 0:
raise ValueError("client_id is required.")
if not self.store.exists(client_id):
raise ValueError("Accounting local storage is not initialized for this client.")
date_from = str(payload.get("date_from") or "").strip()
date_to = str(payload.get("date_to") or "").strip()
try:
start_day = _date.fromisoformat(date_from)
end_day = _date.fromisoformat(date_to)
except Exception as exc:
raise ValueError("Select valid From Date and To Date.") from exc
if start_day > end_day:
raise ValueError("From Date cannot be after To Date.")
cash_limit = round(float(payload.get("cash_limit") or 10000.0), 2)
split_window_days = max(1, int(payload.get("split_window_days") or 3))
near_limit_percent = max(1.0, min(float(payload.get("near_limit_percent") or 80.0), 100.0))
requested_cash_ledger = str(payload.get("cash_ledger_name") or "").strip()
mirror_status = self.tally.mirror.status(client_id)
mirror = mirror_status.get("mirror") or {}
if not mirror.get("ready"):
raise ValueError(
"Accounting Mirror is not ready for this client. "
"Open Tally Accounting and run 'Mirror Tally to SQLite' first."
)
company = mirror.get("company") or {}
company_name = str(company.get("company_name") or "").strip()
company_guid = str(company.get("company_guid") or "").strip()
if not company_name and not company_guid:
raise ValueError("Accounting Mirror does not contain a valid Tally company identity.")
counts = mirror.get("counts") or {}
if int(counts.get("voucher") or 0) <= 0:
raise ValueError(
"The Accounting Mirror contains no vouchers. "
"Run 'Mirror Tally to SQLite' for the required Tally period first."
)
db = self.tally.mirror.open_db(client_id, company_name, company_guid)
try:
ledger_rows = db.execute(
"SELECT ledger_name, parent_group FROM ledger_master ORDER BY ledger_name"
).fetchall()
cash_ledger_names: list[str] = []
for row in ledger_rows:
name = str(row["ledger_name"] or "").strip()
parent = re.sub(r"[\s_-]+", " ", str(row["parent_group"] or "").strip()).casefold()
if not name:
continue
if parent in {"cash in hand", "cash"} or name.casefold() == "cash":
cash_ledger_names.append(name)
if requested_cash_ledger:
matched = next(
(x for x in cash_ledger_names if x.casefold() == requested_cash_ledger.casefold()),
None,
)
if not matched:
all_names = {str(row["ledger_name"] or "").strip().casefold() for row in ledger_rows}
if requested_cash_ledger.casefold() not in all_names:
raise ValueError(
f"Cash ledger '{requested_cash_ledger}' is not present in the Accounting Mirror."
)
matched = requested_cash_ledger
cash_ledger_names = [matched]
# Preserve legacy behaviour if a very old Tally company has no
# Cash-in-Hand hierarchy metadata but contains the standard Cash ledger.
if not cash_ledger_names:
cash_ledger_names = [
str(row["ledger_name"] or "").strip()
for row in ledger_rows
if str(row["ledger_name"] or "").strip().casefold() == "cash"
]
if not cash_ledger_names:
raise ValueError(
"No Cash / Cash-in-Hand ledger was found in the Accounting Mirror. "
"Refresh the Tally mirror and verify the ledger hierarchy."
)
placeholders = ",".join("?" for _ in cash_ledger_names)
params = [date_from, date_to] + [x.casefold() for x in cash_ledger_names]
total_vouchers = int(db.execute(
"""SELECT COUNT(*) FROM voucher
WHERE voucher_date>=? AND voucher_date<=?
AND COALESCE(is_cancelled,0)=0 AND COALESCE(is_optional,0)=0""",
(date_from, date_to),
).fetchone()[0])
sql = f"""
SELECT
v.voucher_guid, v.voucher_date, v.voucher_type, v.voucher_number,
v.party_ledger, v.narration, v.reference,
e.ledger_name, e.dr_cr, e.raw_amount, e.amount, e.raw_is_deemed_positive
FROM voucher v
JOIN voucher_ledger_entry e
ON e.company_instance_id=v.company_instance_id
AND e.voucher_guid=v.voucher_guid
WHERE v.voucher_date>=? AND v.voucher_date<=?
AND COALESCE(v.is_cancelled,0)=0
AND COALESCE(v.is_optional,0)=0
AND EXISTS (
SELECT 1
FROM voucher_ledger_entry ce
WHERE ce.company_instance_id=v.company_instance_id
AND ce.voucher_guid=v.voucher_guid
AND LOWER(ce.ledger_name) IN ({placeholders})
)
ORDER BY v.voucher_date, v.voucher_type, v.voucher_number, e.line_no
"""
rows = db.execute(sql, params).fetchall()
finally:
db.close()
cash_keys = {x.casefold() for x in cash_ledger_names}
grouped: dict[str, dict[str, Any]] = {}
for row in rows:
guid = str(row["voucher_guid"] or "")
item = grouped.setdefault(guid, {
"date": str(row["voucher_date"] or ""),
"voucher_type": str(row["voucher_type"] or ""),
"voucher_number": str(row["voucher_number"] or ""),
"party": str(row["party_ledger"] or "").strip(),
"reference": str(row["reference"] or ""),
"narration": str(row["narration"] or ""),
"entries": [],
})
item["entries"].append({
"ledger_name": str(row["ledger_name"] or "").strip(),
"dr_cr": str(row["dr_cr"] or "").strip().upper(),
"raw_amount": float(row["raw_amount"] or 0),
"amount": float(row["amount"] or 0),
"is_deemed_positive": str(row["raw_is_deemed_positive"] or "").strip(),
})
payments: list[dict[str, Any]] = []
for voucher in grouped.values():
cash_credit: list[float] = []
for entry in voucher["entries"]:
if entry["ledger_name"].casefold() not in cash_keys:
continue
raw_amount = float(entry["raw_amount"] or 0)
deemed = str(entry["is_deemed_positive"] or "").strip().lower()
# Keep the sign interpretation used by the existing Cash Payment
# Compliance engine, while also respecting the mirror's DR/CR field.
if entry["dr_cr"] == "CR" or raw_amount > 0 or deemed == "no":
cash_credit.append(abs(raw_amount if raw_amount else float(entry["amount"] or 0)))
cash_amount = round(sum(cash_credit), 2)
if cash_amount <= 0:
continue
non_cash = [e for e in voucher["entries"] if e["ledger_name"].casefold() not in cash_keys]
party = str(voucher["party"] or "").strip()
if not party or party.casefold() in cash_keys:
candidates = sorted(
non_cash,
key=lambda e: abs(float(e["raw_amount"] or e["amount"] or 0)),
reverse=True,
)
party = candidates[0]["ledger_name"] if candidates else "Unidentified counter-ledger"
payments.append({
"date": voucher["date"],
"party": party or "Unidentified counter-ledger",
"amount": cash_amount,
"voucher_number": voucher["voucher_number"],
"voucher_type": voucher["voucher_type"],
"reference": voucher["reference"],
"narration": voucher["narration"],
})
by_party_date = defaultdict(list)
for item in payments:
by_party_date[(item["party"].casefold(), item["date"])].append(item)
exceptions: list[dict[str, Any]] = []
seen = set()
single_count = 0
same_day_count = 0
for item in payments:
if item["amount"] > cash_limit + 0.009:
single_count += 1
key = (item["party"].casefold(), item["date"], "single", item["voucher_number"])
if key not in seen:
seen.add(key)
exceptions.append({
"date": item["date"],
"party": item["party"],
"amount": item["amount"],
"reason": "Single cash-payment voucher exceeds configured limit",
"voucher_numbers": [item["voucher_number"] or "-"],
})
for (party_key, paid_on), party_rows in by_party_date.items():
total = round(sum(x["amount"] for x in party_rows), 2)
if total > cash_limit + 0.009 and len(party_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": party_rows[0]["party"],
"amount": total,
"reason": "Same-day aggregate cash payments to the same party exceed configured limit",
"voucher_numbers": [x["voucher_number"] or "-" for x in party_rows],
})
daily_by_party = defaultdict(lambda: defaultdict(float))
for item in payments:
daily_by_party[item["party"].casefold()][item["date"]] += item["amount"]
near_floor = cash_limit * near_limit_percent / 100.0
split_patterns: list[dict[str, Any]] = []
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)):
d, amount = day_rows[idx]
if (d - day_rows[start_idx][0]).days >= split_window_days:
break
window.append((d, amount))
if len(window) < 2 or 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
if sum(1 for _, amount in window if amount >= near_floor) < 2:
continue
party_name = next(
(x["party"] for x in payments if x["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 x: (x["date"], x["party"].casefold()))
split_patterns.sort(key=lambda x: (x["date_from"], x["party"].casefold()))
return {
"cash_payment_review": {
"company_name": company_name,
"company_guid": company_guid,
"date_from": date_from,
"date_to": date_to,
"cash_limit": cash_limit,
"cash_ledgers": cash_ledger_names,
"summary": {
"vouchers_reviewed": total_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,
"sqlite_mirror": True,
"sqlite_cache": False,
"mirror_db_path": str(mirror_status.get("mirror_db_path") or mirror.get("path") or ""),
"mirror_period": mirror.get("voucher_period") or {},
"tally_contacted": False,
"tally_request_count": 0,
"tally_retry_count": 0,
"tally_pause_ms": 0,
"read_only": True,
},
"mirror": mirror,
"agent": self._agent_info(),
}
def _cash_payment_cache_analyze(self, payload: dict[str, Any]) -> dict[str, Any]:
from collections import defaultdict
from datetime import date as _date